From 5c50bcffc4ca2c18457351984ce7d1c07ce28721 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:24:00 -0400 Subject: [PATCH 1/6] tasks: a row says its state once, an abandoned call is said in whole seconds, and a conversation root names its folder only where that is news MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects on one screenshot, and all three were one shape: a fact with two writers. `stopped · stopped`. The engine grades a node somebody stopped with the outcome `stopped`, and this page hung that outcome off its own state word. [session.TaskStatus.RowWord] is the join — the word, its reason, and the rule that a reason restating the word is not said again — and it is what the column beside a conversation already reads. The three hand-rolled compositions in [tasksMiddle] and the state word [tasksNote] spelled for a row nobody is behind are gone. `one call ran 29.24078975s without answering`. [time.Duration.String] is the engine talking to itself; [taskSpanWord] is this package's one spelling of how long something took. The `~` at the right of a conversation root. The mission-control ruling retired `~` as a project name and the tag on rows in this window's own folder; home obeyed it and the tasks place drew both. [chatProjectWord] is that rule, asked by both pages. Co-Authored-By: Claude Opus 5 --- internal/session/task_audit.go | 8 +- internal/session/task_audit_words_test.go | 32 +++++ internal/tui3/homepanel_recent.go | 9 +- internal/tui3/place_tasks.go | 2 +- internal/tui3/projecttag.go | 38 ++++++ internal/tui3/taskphone_test.go | 8 +- internal/tui3/tasksplace.go | 144 +++++++++++++--------- internal/tui3/tasksplace_test.go | 35 ++++-- internal/tui3/tasksplacewords_test.go | 100 +++++++++++++++ 9 files changed, 300 insertions(+), 76 deletions(-) create mode 100644 internal/session/task_audit_words_test.go create mode 100644 internal/tui3/projecttag.go create mode 100644 internal/tui3/tasksplacewords_test.go diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index 23828fe4c1..fcb993b1f6 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -735,8 +735,14 @@ 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 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. The one +// spelling of how long something took answers in the unit the reader is in. 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..f7c06aedc2 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(row.project, 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..49eed803c7 --- /dev/null +++ b/internal/tui3/projecttag.go @@ -0,0 +1,38 @@ +package tui3 + +import "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. + +// 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. +func chatProjectWord(project, workspace, folder, here, tilde string) string { + project = strings.TrimSpace(project) + switch { + case project == "" || project == "~": + return "" + case homeScratchFolder(workspace, tilde): + return "" + case here != "" && folder == here: + return "" + } + return project +} diff --git a/internal/tui3/taskphone_test.go b/internal/tui3/taskphone_test.go index d86aa7edbe..d8a67a09cc 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,7 @@ 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") { + if tail := tasksCardTail(last, a.now()); head+1 >= len(lines) || tail == "" || !strings.Contains(lines[head+1], taskStateWord(last.entry, last.runs)) { 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..499c42c3a9 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,10 @@ 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, + folder: strings.TrimSpace(mine.row.ProjectDir), 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. @@ -1129,7 +1143,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 +1456,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 +1465,13 @@ 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. + if project := chatProjectWord(chat.row.Project, chat.row.Workspace, chat.row.ProjectDir, folder, tilde); project != "" && project != chat.title { facts = append(facts, rowSay(project)) } plan := rowPlan{primary: chat.title, fields: facts} @@ -1604,7 +1623,7 @@ func tasksFacts(line tasksLine, now time.Time, pal palette) []tasksFact { {field: rowSay(tasksNote(item))}, {field: tasksAgeField(item, now)}, {field: rowSay(source)}, - {field: tasksMiddleField(entry)}, + {field: tasksMiddleField(item)}, {field: money, ink: placeMoneyInk(pal)}, } } @@ -1718,29 +1737,31 @@ 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 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()) + switch { + case whole == "" || whole == full: + return rowSay(full) + case word == "" || word == whole: + return rowSay(full, whole) } - return rowSay(full) + return rowSay(full, whole, word) } // tasksCardHead is the first line of a phone card: the state and the name, in @@ -1760,7 +1781,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 +1795,17 @@ 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 [taskRecordStoppedWord] +// here, 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 +1818,6 @@ func tasksNote(item tasksItem) string { } return taskAwayNote(item.window) } - if item.entry.Live() && !item.runs { - return taskRecordStoppedWord - } return "" } @@ -1812,36 +1833,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..d2b77a59b1 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, "5h · 2 files · done · $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.HasSuffix(narrow, "· done") { + t.Fatalf("at 60 columns the row gave up the one word it is read for:\n %s", 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..73d6178ee6 --- /dev/null +++ b/internal/tui3/tasksplacewords_test.go @@ -0,0 +1,100 @@ +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) + } +} From ce9884b029e4b8b5b5a5e24b9bc2d9eeea6a14da Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:24:49 -0400 Subject: [PATCH 2/6] manual: the tasks place says what a row carries now, and when a root names its folder Co-Authored-By: Claude Opus 5 --- internal/manual/chat/tasks.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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. From 1e2cd434620b680ea01d36b78ec72b02a764cc16 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:25:51 -0400 Subject: [PATCH 3/6] changes: the entry for #895 Co-Authored-By: Claude Opus 5 --- .../895-tasks-row-says-its-state-once.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/895-tasks-row-says-its-state-once.md 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..63d83bebc0 --- /dev/null +++ b/docs/changes/unreleased/895-tasks-row-says-its-state-once.md @@ -0,0 +1,20 @@ +--- +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." + - "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. From 74d8c49e743282bf2752a951c265c1f4e3ce99ce Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:30:35 -0400 Subject: [PATCH 4/6] tasks: the window's own folder is read off its own conversation row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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, which left every row of the folder a person was sitting in wearing its name — the thing the ruling retired. Co-Authored-By: Claude Opus 5 --- internal/tui3/tasksplace.go | 17 ++++++++++++---- internal/tui3/tasksplacewords_test.go | 29 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 499c42c3a9..a2c9ce27cb 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -235,10 +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, - folder: strings.TrimSpace(mine.row.ProjectDir), tilde: mine.tilde, - } + 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. @@ -301,6 +298,18 @@ 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. + 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 { diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go index 73d6178ee6..29ae4c30d3 100644 --- a/internal/tui3/tasksplacewords_test.go +++ b/internal/tui3/tasksplacewords_test.go @@ -98,3 +98,32 @@ func TestAConversationRootWearsAFolderTagOnlyWhereItIsNews(t *testing.T) { 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) + } + if page := tasksPage(r, 100); strings.Contains(page, "aforge") && !strings.Contains(page, "Sweeping") { + t.Fatalf("the page drew the folder it is already in:\n%s", page) + } +} From 598a96abeccc7d9c57216db5134a5c985383395f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:47:37 -0400 Subject: [PATCH 5/6] tasks: the state word ranks ahead of the age, degrades to itself, and the rule about a folder's name takes named facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings from a fresh-eyes pass over #895; these are the behaviour ones and the comments that stopped being true. THE WORD IS THE LAST THING A ROW GIVES UP. Moving the state out of the note demoted it from second in the ranked tail to fifth, behind the age and the conversation the work came out of — so at eighty-five cells a row nothing was running kept `The Skill Model Rebuild` and dropped `incomplete`. It ranks third now, directly behind the one fact that says the work is somewhere else, which is the argument [tasksFacts] was always making and the law tasktier.go states for the column beside a conversation. AND A FIELD WITH ONE SPELLING EITHER FITS OR GOES. A `your call` row that changed no files had its file count and its state collapse into the same string, so the ladder offered a single rung and a hundred-column frame drew the age alone. The rungs are built by adding now, and a rung equal to the one above it is not a rung. A live row's activity is returned whole: it is a different fact and not a longer spelling of the state. [chatProjectWord] takes a [chatFolder] rather than five positional paths — every one of them a path, so a transposition compiled — and its own comparison is cleaned on both sides like the check above it. [refused] was [tasksMiddle]'s last caller and its comment stated a rule this surface no longer applies; both are deleted. The abandoned-call comment no longer claims one spelling of a duration where there are two, and says why the window's ceiling keeps the other. Co-Authored-By: Claude Opus 5 --- .../895-tasks-row-says-its-state-once.md | 2 + internal/session/task_audit.go | 18 +++-- internal/tui3/homepanel_recent.go | 2 +- internal/tui3/projecttag.go | 45 ++++++++++-- internal/tui3/taskending.go | 7 -- internal/tui3/taskphone_test.go | 5 +- internal/tui3/tasksplace.go | 69 ++++++++++++++----- internal/tui3/tasksplace_test.go | 6 +- internal/tui3/tasksplacewords_test.go | 53 +++++++++++++- 9 files changed, 165 insertions(+), 42 deletions(-) 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 index 63d83bebc0..c50550c5ee 100644 --- a/docs/changes/unreleased/895-tasks-row-says-its-state-once.md +++ b/docs/changes/unreleased/895-tasks-row-says-its-state-once.md @@ -7,6 +7,8 @@ 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." --- diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index fcb993b1f6..001f121629 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -735,12 +735,18 @@ 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 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. The one -// spelling of how long something took answers in the unit the reader is in. +// 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 " + taskSpanWord(bound) + " without answering and was abandoned" } diff --git a/internal/tui3/homepanel_recent.go b/internal/tui3/homepanel_recent.go index f7c06aedc2..f9aee23322 100644 --- a/internal/tui3/homepanel_recent.go +++ b/internal/tui3/homepanel_recent.go @@ -98,7 +98,7 @@ func recentCell(row switcherRow, in *homeGridInput) *homeCell { // 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 { - return chatProjectWord(row.project, row.session.Workspace, "", "", tilde) + 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/projecttag.go b/internal/tui3/projecttag.go index 49eed803c7..30d92ba983 100644 --- a/internal/tui3/projecttag.go +++ b/internal/tui3/projecttag.go @@ -1,6 +1,9 @@ package tui3 -import "strings" +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. @@ -20,19 +23,49 @@ import "strings" // 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. -func chatProjectWord(project, workspace, folder, here, tilde string) string { - project = strings.TrimSpace(project) +// 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(workspace, tilde): + case homeScratchFolder(chat.workspace, tilde): return "" - case here != "" && folder == here: + // 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..0d8cf9362c 100644 --- a/internal/tui3/taskending.go +++ b/internal/tui3/taskending.go @@ -89,10 +89,3 @@ func halted(ending session.TaskEnding) bool { // 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 d8a67a09cc..0773cff44f 100644 --- a/internal/tui3/taskphone_test.go +++ b/internal/tui3/taskphone_test.go @@ -183,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 tail := tasksCardTail(last, a.now()); head+1 >= len(lines) || tail == "" || !strings.Contains(lines[head+1], taskStateWord(last.entry, last.runs)) { + // 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 a2c9ce27cb..fc0e701de5 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -304,6 +304,11 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, see // 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) @@ -1480,7 +1485,8 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string // 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. - if project := chatProjectWord(chat.row.Project, chat.row.Workspace, chat.row.ProjectDir, folder, tilde); project != "" && project != chat.title { + 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} @@ -1581,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` @@ -1630,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(item)}, {field: money, ink: placeMoneyInk(pal)}, } } @@ -1762,15 +1778,32 @@ func tasksMiddleField(item tasksItem) rowField { if full == "" { return rowSay() } + // 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) + } status := item.status() word, whole := strings.TrimSpace(status.Word), strings.TrimSpace(status.RowWord()) - switch { - case whole == "" || whole == full: - return rowSay(full) - case word == "" || word == whole: - return rowSay(full, whole) + // 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, whole, word) + return rowSay(rungs...) } // tasksCardHead is the first line of a phone card: the state and the name, in @@ -1808,8 +1841,10 @@ func tasksCardTail(item tasksItem, now time.Time) string { // WHAT STATE IT IS IN. // // 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 [taskRecordStoppedWord] -// here, while [tasksMiddle] put the row's own state word in front of the outcome +// 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 diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index d2b77a59b1..2bd67cbb11 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -650,7 +650,7 @@ 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 · done · $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) @@ -660,8 +660,8 @@ func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { // 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.HasSuffix(narrow, "· done") { - t.Fatalf("at 60 columns the row gave up the one word it is read for:\n %s", narrow) + 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} { if !strings.Contains(plain(tasksPage(reading, width)), "The Annual Toggle") { diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go index 29ae4c30d3..2083e643d9 100644 --- a/internal/tui3/tasksplacewords_test.go +++ b/internal/tui3/tasksplacewords_test.go @@ -123,7 +123,58 @@ func TestTheReadingKnowsTheFolderThisWindowIsSittingIn(t *testing.T) { 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) } - if page := tasksPage(r, 100); strings.Contains(page, "aforge") && !strings.Contains(page, "Sweeping") { + // 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) + } + } + } +} From 8847dae8f42067bf525900c777a5e780d1846b7f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:48:56 -0400 Subject: [PATCH 6/6] gofmt Co-Authored-By: Claude Opus 5 --- internal/tui3/taskending.go | 1 - internal/tui3/tasksplacewords_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/tui3/taskending.go b/internal/tui3/taskending.go index 0d8cf9362c..d90e29eea2 100644 --- a/internal/tui3/taskending.go +++ b/internal/tui3/taskending.go @@ -88,4 +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 = "!" - diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go index 2083e643d9..4e0915d7a8 100644 --- a/internal/tui3/tasksplacewords_test.go +++ b/internal/tui3/tasksplacewords_test.go @@ -162,7 +162,7 @@ func TestTheStateWordIsTheLastThingARowGivesUp(t *testing.T) { 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", + 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"},