From 179cdec2b96ef6974e0055b870c1ebb43ff4667a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:52:59 -0400 Subject: [PATCH 01/20] tokens: a filter box's mark is its own slot, because it parts company with search at the ASCII tier Co-Authored-By: Claude Opus 5 --- internal/tui2/tokens/glyph.go | 2 ++ internal/tui2/tokens/glyphset.go | 7 +++++++ internal/tui2/tokens/glyphvocab_test.go | 6 ++++++ internal/tui2/tokens/nerdfont.go | 9 +++++++++ 4 files changed, 24 insertions(+) diff --git a/internal/tui2/tokens/glyph.go b/internal/tui2/tokens/glyph.go index 854cccee73..3f715734b4 100644 --- a/internal/tui2/tokens/glyph.go +++ b/internal/tui2/tokens/glyph.go @@ -143,6 +143,7 @@ const ( GlyphThought = "✳" // the model's own words between calls GlyphShell = "$" // a shell call — the prompt a person types at GlyphSearch = "⌕" // a call that went out to the world + GlyphFilter = "⌕" // narrowing what is already on the page GlyphWrite = "✎" // a call that wrote something down // The action families (internal/tui3's step gutter). One still, monochrome @@ -403,6 +404,7 @@ func Glyphs() []GlyphInfo { {"Thought", GlyphThought, '✳', false}, {"Shell", GlyphShell, '$', false}, {"Search", GlyphSearch, '⌕', false}, + {"Filter", GlyphFilter, '⌕', false}, {"Write", GlyphWrite, '✎', false}, {"ActionRead", GlyphActionRead, '▤', true}, {"ActionCreate", GlyphActionCreate, '+', false}, diff --git a/internal/tui2/tokens/glyphset.go b/internal/tui2/tokens/glyphset.go index 3014596151..eb83f7b26d 100644 --- a/internal/tui2/tokens/glyphset.go +++ b/internal/tui2/tokens/glyphset.go @@ -164,6 +164,13 @@ const ( GProseBullet GProseQuote GCodeGutter + // GFilter is the mark in front of a box that NARROWS WHAT IS ALREADY HERE, + // and it is deliberately not [GSearch] — which means a call that went out to + // the WORLD. The two draw the same shape because they are the same idea at + // two distances, and they part company at the ASCII tier: a screen reader + // hears `?` for a search, and `?` is already the mark for work waiting on a + // person, which is a row the filter box is very often sitting above. + GFilter glyphIDCount ) diff --git a/internal/tui2/tokens/glyphvocab_test.go b/internal/tui2/tokens/glyphvocab_test.go index 5005297038..5358e7486d 100644 --- a/internal/tui2/tokens/glyphvocab_test.go +++ b/internal/tui2/tokens/glyphvocab_test.go @@ -93,6 +93,12 @@ func TestOneGlyphOneMeaning(t *testing.T) { GlyphFileDocument: "a page of text, at two scales: a call that read one, and one on an " + "attachment chip — and the tier draws BOTH as nf-fa-file_text, so nothing the floor " + "draws apart is collapsed", + GlyphSearch: "looking for something, at two distances: a call that went out to the world, " + + "and the box that narrows what is already on the page — and the tier draws BOTH as " + + "nf-fa-search, so nothing the floor draws apart is collapsed. They are two SLOTS " + + "rather than one because they part company at the ASCII tier and nowhere else: a " + + "search is heard as `?`, and `?` is already this product's mark for work waiting on " + + "a person, which is the row a filter box most often sits directly above", } plainOwner := map[string]string{} for _, b := range Vocabulary() { diff --git a/internal/tui2/tokens/nerdfont.go b/internal/tui2/tokens/nerdfont.go index eecafd208f..5db37bf322 100644 --- a/internal/tui2/tokens/nerdfont.go +++ b/internal/tui2/tokens/nerdfont.go @@ -266,6 +266,15 @@ var vocabulary = []GlyphBinding{ ASCII: "?", UsualTint: Cyan, NFAmbiguous: true, AutoUpgrade: true, }, + { + ID: GFilter, Name: "Filter", Meaning: "narrowing what is already on the page", + Plain: GlyphFilter, NerdFont: "\uF002", NFName: "nf-fa-search", + // THE ASCII IS `/` AND NOT `?`. A screen reader on this page hears `?` + // for every row of work waiting on a person, and the filter box very + // often sits directly above one. + ASCII: "/", + UsualTint: Cyan, NFAmbiguous: true, AutoUpgrade: true, + }, { ID: GWrite, Name: "Write", Meaning: "a call that wrote something down", Plain: GlyphWrite, NerdFont: "\uF040", NFName: "nf-fa-pencil", From 3dd49dbf282ec0a53e9d62ef6ade348fa9a527f0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:56:49 -0400 Subject: [PATCH 02/20] wip: sort keys, ranks and the column arithmetic Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 23 ++- internal/tui3/tasksplace.go | 159 +++++++++++++++++--- internal/tui3/taskstable.go | 271 +++++++++++++++++++++++++++++++++++ internal/tui3/tassort.go | 248 ++++++++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 25 deletions(-) create mode 100644 internal/tui3/taskstable.go create mode 100644 internal/tui3/tassort.go diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 638b10523f..7cfea6acbf 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -54,7 +54,16 @@ type tasksPlace struct { // this surface is rather than a string of its own: backspace, ctrl+u and // ctrl+w are edits a person's hands already know, and a second implementation // of them would be a second set of bugs in them. + // + // IT IS ON SCREEN NOW, as the first row of the list ([tasksControlRow]). It + // used to be invisible, with a note line UNDER the rows saying back what had + // been typed — a correction printed below the thing it was correcting. query editor + // order is which column the list is sorted by and which way. It is the + // PLACE's, beside the folds and for the same reason: the reading is replaced + // whole every time a node lands, and an order kept there would reset itself + // under somebody who had just chosen one. + order tasksSort // detail is the row of the record this place is standing INSIDE, and // detailOn is what says it is (taskrecord.go). Together they are the place's @@ -198,7 +207,7 @@ func (a *app) takeTaskReading() tasksPlace { mine: mine, awayAt: a.elsewhere().Read, mineAt: a.railStamp, - reading: readTasks(world, mine, session.LastDays(now, taskSheetDays), + reading: readTasks(world, mine, session.LastDays(now, taskSheetDays), tasksSort{}, session.LastLookAt(a.looksRoot(), pageTasks.word()), now), } } @@ -251,7 +260,7 @@ func (p *tasksPlace) regroup(a *app) { was, held := p.rowAt(a, p.cursor) p.awayAt, p.mineAt = at, stamp p.mine = a.taskSheetMine() - p.reading = readTasks(p.world, p.mine, p.reading.win, p.reading.seen, p.reading.now) + p.reading = readTasks(p.world, p.mine, p.reading.win, p.order, p.reading.seen, p.reading.now) if !held { return } @@ -290,6 +299,12 @@ func (p *tasksPlace) lineOf(a *app, want tasksKey) (int, bool) { func (p *tasksPlace) filtered(a *app) tasksReading { r := p.reading r.open = p.opened + // THE ORDER IS THE PLACE'S TOO, and it is joined here for the same reason the + // folds are: this is the one door onto the reading, so a key pressed between + // two frames reaches every one of its readers at once ([tasksReading.tree] + // rebuilds the shape when the order it was built in is not the one being + // asked for). + r.order = p.order needle := a.taskSheetFilter() if needle == "" { return r @@ -343,7 +358,7 @@ func (p *tasksPlace) filtered(a *app) tasksReading { } } r.chats = chats - tree = tasksTreeOf(kept, r.now, chats...) + tree = tasksTreeOf(kept, r.now, r.order, chats...) r.shape = &tree return r } @@ -1040,7 +1055,7 @@ func (p *tasksPlace) window(a *app, key string) bool { if next == before { return false } - p.reading = readTasks(p.world, p.mine, next, p.reading.seen, a.now()) + p.reading = readTasks(p.world, p.mine, next, p.order, p.reading.seen, a.now()) p.top = 0 p.cursor = a.tasksSettle(0) return true diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index cce81ab753..6dbf76dd1c 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -214,6 +214,11 @@ type tasksReading struct { // the folder a person is already in tells one row from no other. folder string tilde string + // order is the column this page is sorted by and which way, handed in by the + // place the way the folds are ([tasksReading.open] states the same law): a + // snapshot is replaced whole every time a node lands, and an order that lived + // on the reading would reset itself under somebody who had just chosen one. + order tasksSort } // tasksKey is what identifies ONE piece of work across every authority: the @@ -234,8 +239,8 @@ func tasksKeyOf(entry session.TaskIndexEntry) tasksKey { // means in one pass: the file, then this project's in-memory index and live // 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, tilde: mine.tilde} +func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by tasksSort, seen, now time.Time) tasksReading { + r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde, order: by} // 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. @@ -328,7 +333,7 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, see // AND THE ORDER THE ROWS ARRIVE IN IS THE ORDER THE PAGE DRAWS THEM, settled // once here so that every reader of [tasksReading.items] — the tally, the // filter, the layout — walks one list in one order. - tree := tasksTreeOf(visible, now, r.chats...) + tree := tasksTreeOf(visible, now, by, r.chats...) r.items = tree.order() r.shape = &tree // WHAT THE PLACE IS HOLDING IS COUNTED HERE, ONCE, off the rows before any @@ -513,6 +518,13 @@ type tasksLine struct { // kids is how many pieces of work are under this root, which the row says // out loud while the fold is shut. Zero everywhere else. kids int + // rank is the row's answer to every sort key, FOLDED WITH EVERYTHING UNDER IT + // ([tasksRank.fold]). A row with work beneath it shows the family's total, + // because a shut fold stands in for what it is hiding and an open one is the + // sum of the rows under it — which is what `$0.49` over `$0.47` and `$0.02` + // reads as. It is carried on the LINE and not re-derived in the paint, for the + // same reason the fold mark is: it is a fact about the layout. + rank tasksRank } // tasksBareLead is the cell in front of every row of work: the place's one left @@ -819,6 +831,18 @@ type tasksChat struct { // live work is dated `now` for ranking and says nothing at all about when // ([tasksEntryAt] and [tasksEntryStamp] hold the two halves apart). at time.Time + // rank is every sort key's answer for this conversation AT ONCE, folded from + // the work under it ([tasksRank.fold]): the newest age, the total spend, the + // total files, the most urgent state. It is what the row is ORDERED by and + // what its own key cell SAYS, which are one question. + rank tasksRank + // word is the state word the most urgent piece of work under it wears, and it + // is what the conversation's `state` cell says after the count: `5 your call`, + // `9 done`. A conversation has no state of its own and this is not one. + word string + // state is where that puts it, kept beside the word so the cell and the + // section cannot be worked out two ways. + state tasksSection } // tasksGroup is what one section holds: a conversation and the work under it, @@ -847,18 +871,50 @@ type tasksTree struct { up map[tasksKey]tasksKey at map[tasksKey]tasksItem filed map[tasksKey]tasksSection + // rank is every row's answer to every sort key, folded with everything under + // it. It is computed once here because the comparison is asked at every level + // and a walk per comparison is a walk per log n. + rank map[tasksKey]tasksRank + // sort is the order this shape was BUILT in, and it is kept so the reading can + // tell a cached shape from one that has to be built again ([tasksReading.tree]). + // A tree is a sorted thing; two sorts are two trees. + sort tasksSort +} + +// tasksSort is the whole of what a person has chosen about the order: which key, +// and which way. It is one value so that the place, the reading and the tree +// cannot hold two thirds of an answer between them. +type tasksSort struct { + key tasksSortKey + back bool +} + +// on returns this sort with a key pressed: the same key again REVERSES, and a +// different one starts that key the way round it is naturally read +// ([tasksSortKey.ahead]). +// +// IT IS ONE RULE FOR THE KEYBOARD AND THE POINTER ALIKE. `s` cycles to the next +// key and a click names one directly, and both land here, so a label clicked +// twice and a key cycled round to itself behave the same way. +func (s tasksSort) on(key tasksSortKey) tasksSort { + if s.key == key { + return tasksSort{key: key, back: !s.back} + } + return tasksSort{key: key} } // tasksTreeOf builds that shape, and it is the ONE place the page's structure is // decided — the layout and the section headings both read this rather than each // walking the rows their own way. The tally deliberately reads each piece of // work's own state instead ([tasksReading.tally]). -func tasksTreeOf(items []tasksItem, now time.Time, chats ...session.SessionRow) tasksTree { +func tasksTreeOf(items []tasksItem, now time.Time, order tasksSort, chats ...session.SessionRow) tasksTree { t := tasksTree{ kids: map[tasksKey][]tasksItem{}, up: make(map[tasksKey]tasksKey, len(items)), at: make(map[tasksKey]tasksItem, len(items)), filed: make(map[tasksKey]tasksSection, len(items)), + rank: make(map[tasksKey]tasksRank, len(items)), + sort: order, } for _, item := range items { t.at[tasksKeyOf(item.entry)] = item @@ -917,8 +973,34 @@ func tasksTreeOf(items []tasksItem, now time.Time, chats ...session.SessionRow) } roots = append(roots, item) } + // EVERY ROW'S RANK IS FOLDED BEFORE ANYTHING IS ORDERED, because a parent is + // compared by what is under it and a walk per comparison would be a walk per + // log n. The roots are done after the groups are formed, since a root's fold + // reaches every generation below it. + for _, item := range items { + key := tasksKeyOf(item.entry) + t.rank[key] = tasksRankOf(item, now) + } + var folded func(item tasksItem) tasksRank + folded = func(item tasksItem) tasksRank { + key := tasksKeyOf(item.entry) + rank := tasksRankOf(item, now) + for _, kid := range t.kids[key] { + rank = rank.fold(folded(kid)) + } + t.rank[key] = rank + return rank + } + for _, root := range roots { + folded(root) + } + // AND THE ORDER IS THE CHOSEN ONE, AT EVERY LEVEL. The tree never flattens: + // what changes with the key is which of two SIBLINGS comes first, never + // whether a row is still under its parent. for key, kids := range t.kids { - sort.SliceStable(kids, func(i, j int) bool { return tasksNewer(kids[i], kids[j], now) }) + sort.SliceStable(kids, func(i, j int) bool { + return t.sort.key.less(t.rank[tasksKeyOf(kids[i].entry)], t.rank[tasksKeyOf(kids[j].entry)], t.sort.back) + }) t.kids[key] = kids } @@ -972,20 +1054,31 @@ func tasksTreeOf(items []tasksItem, now time.Time, chats ...session.SessionRow) for i := range t.groups { g := &t.groups[i] g.section = tasksSectionCount + g.chat.rank = tasksRankZero() if g.named { g.order, g.chat.at = g.chat.row.At, g.chat.row.At + g.chat.rank.name = g.chat.title if g.chat.row.NeedsPerson() { g.section = tasksNeeds } else if g.chat.row.Live && g.chat.row.Presence.State == session.PresenceWorking { g.section = tasksRunning } } + // urgent is the piece of work whose state the conversation's own cell says + // after its count. It is the SAME row that decides which section the whole + // conversation stands under, so the heading a person reads and the word on + // the row under it cannot disagree. + var urgent *tasksItem for _, root := range g.roots { t.under(root, func(item tasksItem, _ int) { g.held++ if item.section < g.section { g.section = item.section } + if urgent == nil || item.section < urgent.section { + held := item + urgent = &held + } if stamp := tasksEntryAt(item.entry, now); stamp.After(g.order) { g.order = stamp } @@ -994,23 +1087,38 @@ func tasksTreeOf(items []tasksItem, now time.Time, chats ...session.SessionRow) } }) } + for _, root := range g.roots { + g.chat.rank = g.chat.rank.fold(t.rank[tasksKeyOf(root.entry)]) + } if g.section == tasksSectionCount { g.section = tasksEarlier } - sort.SliceStable(g.roots, func(a, b int) bool { return tasksNewer(g.roots[a], g.roots[b], now) }) + g.chat.state = g.section + if urgent != nil { + g.chat.word = taskStateWord(urgent.entry, urgent.runs) + if urgent.live != nil { + g.chat.word = urgent.live.Word + } + } + sort.SliceStable(g.roots, func(a, b int) bool { + return t.sort.key.less(t.rank[tasksKeyOf(g.roots[a].entry)], t.rank[tasksKeyOf(g.roots[b].entry)], t.sort.back) + }) g.chat.kids = len(g.roots) for _, root := range g.roots { t.under(root, func(item tasksItem, _ int) { t.filed[tasksKeyOf(item.entry)] = g.section }) } } - // THE MOST URGENT CONVERSATION IS AT THE TOP AND THE REST READ NEWEST FIRST, - // which is the same ranking the rows themselves have always had, applied one - // level up. + // THE MOST URGENT CONVERSATION IS AT THE TOP, AND INSIDE ONE SECTION THE + // CONVERSATIONS ARE ORDERED BY THE SAME KEY THEIR OWN ROWS ARE — by their + // AGGREGATE, which is the question a person sorting by cost is asking about a + // conversation. The section outranks the key because the sections are what the + // page is FOR: a heading a sort could reorder under would be a page whose own + // argument moved when somebody clicked a column. sort.SliceStable(t.groups, func(a, b int) bool { if t.groups[a].section != t.groups[b].section { return t.groups[a].section < t.groups[b].section } - return t.groups[a].order.After(t.groups[b].order) + return t.sort.key.less(t.groups[a].chat.rank, t.groups[b].chat.rank, t.sort.back) }) return t } @@ -1110,24 +1218,33 @@ func (t tasksTree) column() bool { // tree is the shape of THIS reading, built from the rows it is holding at this // instant — which is what makes a filtered page a tree of what survived rather // than a tree with holes in it. +// AND A SHAPE BUILT IN ANOTHER ORDER IS NOT THIS READING'S SHAPE. A tree is a +// sorted thing — the order decides which of two siblings comes first at every +// level — so the cached one is used only while it was built the way this reading +// is asking for. Pressing a sort key does not rebuild anything: it changes the +// answer this comparison gives, and the next frame notices. func (r tasksReading) tree() tasksTree { - if r.shape != nil { + if r.shape != nil && r.shape.sort == r.order { return *r.shape } - return tasksTreeOf(r.items, r.now, r.chats...) + return tasksTreeOf(r.items, r.now, r.order, r.chats...) } // opens reports whether one foldable row is open: what a person set, and the // row's own default where they have set nothing. // -// A CONVERSATION OPENS AND A FAMILY DOES NOT, and the two defaults are opposite -// on purpose. The fold under a task exists to keep eight workers from burying -// six other things this machine did, and it opens on demand; the fold under a -// CONVERSATION is the page's own structure, and a page that opened with every -// conversation shut would be a list of chat titles with the work — the thing -// this place is for — hidden one keypress behind each of them. -// -// AND A QUERY OPENS EVERYTHING ([tasksReading.unfolded]). +// EVERYTHING OPENS SHUT (owner, 2026-09-11). A conversation used to open showing +// its work, on the argument that a page of chat titles hides the thing the place +// is for. What that argument missed is the SIZE of a real record: a hundred +// conversations with a hundred and ninety subtasks under them is a page nobody +// can scan, and the work a person came for is behind the ninety conversations +// they did not want. A shut root says what it is holding and how urgent the most +// urgent of it is ([tasksChatStateField]), which is enough to decide whether to +// open it — and `→` opens it. +// +// AND A QUERY OPENS EVERYTHING ([tasksReading.unfolded]). A row that matched and +// is sitting behind a shut fold is a row the query appears not to have found, and +// the person's own folds come back when the query clears. func (r tasksReading) opens(key tasksKey) bool { if r.unfolded { return true @@ -1135,7 +1252,7 @@ func (r tasksReading) opens(key tasksKey) bool { if open, set := r.open[key]; set { return open } - return key.chat() + return false } // rows is the whole page painted with no cursor anywhere on it, which is what a diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go new file mode 100644 index 0000000000..fa7a4db6d4 --- /dev/null +++ b/internal/tui3/taskstable.go @@ -0,0 +1,271 @@ +package tui3 + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// taskstable.go is THE LEFT HALF OF THE TASKS PLACE AS A TABLE: what the columns +// are, where they land at every width, and what one row puts in each of them. +// +// WHY IT IS A TABLE AND NO LONGER A RANKED TAIL. The old row offered six facts +// and degraded them by spelling from the right (rowfit.go's laws 2 and 3), which +// is the right shape for a list whose facts are OPTIONAL — a model's prices, a +// lane's numbers. It is the wrong shape for a page a person SCANS: every row gave +// up a different fact at a different width, so no two rows on one frame answered +// the same questions, most rows could only say two of the six, and the right edge +// was holes and prose. A blank cell meant either "this row has nothing to say" or +// "the frame ran out", with nothing on screen telling the two apart. +// +// So the facts are FIXED COLUMNS and the name is what flexes. rowfit.go's law 1 +// is unchanged and is in fact the whole design: the name keeps every cell the two +// columns do not need, and it is the only thing on the row that is ever cut. Law +// 2 has little left to do — a fixed column cannot degrade by spelling, though the +// two cells that can say a longer and a shorter thing still do — and law 3's +// ranked prefix is answered by both columns being always filled. +// +// THE COLUMNS ARE FOUR AND THE LAST TWO ARE FIXED: +// +// fold and family · mark · name · state · the sort key's column · one cell of air +// +// The fold and the family column in front are [tasksKin]'s and are decided in the +// layout; the mark is [tasksGlyph]'s. What is here is everything from the name +// rightwards. + +// The table's own measurements, and THE ONE PLACE THEY ARE WRITTEN DOWN. The +// manual quotes them and the tests interpolate them; a number restated anywhere +// else is a number that will drift. +const ( + // tasksStateCells is the `state` column. It is the longest state word this + // surface has with a shut fold's count after it at its shorter spelling — + // `incomplete · holds 4` — because that is the widest thing the cell is ever + // asked to say whole. + tasksStateCells = 20 + // tasksColumnAir is the one cell between the last column and the frame's edge, + // so a figure never touches the right border. + tasksColumnAir = 1 + // tasksStateFloor is the narrowest frame that still draws the state column. + // Under it the state goes and the sort key's column stays, because the key is + // the column a person CHOSE and the state is the one they get for nothing. + tasksStateFloor = 90 + // tasksNameFloor is the least a name may be left with before the row has + // stopped naming the work at all. It is [tierTitleFloor]'s argument said again + // for this page: about one word, under which the row has told nobody which + // task this is. + tasksNameFloor = tierTitleFloor +) + +// tasksColumns is where the two fact columns land on a frame this wide: how many +// cells the state gets, how many the sort key's column gets, and what is left +// over for the name. +// +// IT IS ASKED BY THE PAINT AND BY THE POINTER ALIKE, which is why it is a +// function rather than arithmetic in each of them: a label a person clicks and +// the cells it stands over have to be the same cells, and two answers to where +// the `cost` column is is a click that sorts by the wrong thing. +// +// `room` is the cells the ROW has, after the place's left edge and the family +// column in front of it have been spent. +func tasksColumns(room int, key tasksSortKey) (state, second, name int) { + second = key.cells() + if room >= tasksStateFloor { + state = tasksStateCells + } + if name = room - state - second - tasksColumnAir; name >= tasksNameFloor { + return state, second, name + } + // A FRAME WITH NO ROOM FOR A NAME DROPS THE COLUMNS AND KEEPS THE NAME, in + // that order, because a row that has spent its cells on two facts about work + // it has not named has said nothing at all (rowfit.go, law 1). + if state > 0 { + state = 0 + if name = room - second - tasksColumnAir; name >= tasksNameFloor { + return state, second, name + } + } + if name = room - tasksColumnAir; name < 1 { + name = 1 + } + return 0, 0, name +} + +// ── what one row puts in the columns ──────────────────────────────────────── + +// tasksStateField is the `state` column's cell, and IT IS ALWAYS FILLED. +// +// It is the one word every row of work has ([taskStateWord], out of the table +// tasktier.go states is the only one), with two things that can stand in its +// place or after it and nothing else: +// +// - WORK IN ANOTHER WINDOW SAYS WHERE IT IS INSTEAD. That is the one fact that +// can correct the mark beside it, and a row whose mark and whose words +// disagree is worse than a row missing a fact ([tasksNote]). +// - A SHUT FOLD SAYS WHAT IT IS HOLDING after it. A mark with no count is a +// mark a person has to open to find out whether it was worth opening. +// +// THE REASON IS NOT HERE. A row is a name and two facts now; WHY the work ended +// as it did is the record's, and it is read in the pane beside the list or on the +// one line the cursor's own row grows where there is no room for a pane +// ([tasksReasonLine]). It was on the row for as long as this page has existed and +// it is what made the right edge prose. +func tasksStateField(line tasksLine) rowField { + item := line.item + if note := tasksNote(item); note != "" { + return rowSay(note) + } + word := taskStateWord(item.entry, item.runs) + if item.live != nil { + // A NODE THIS WINDOW IS HOLDING IS READ FROM THE NODE, which is the same + // reading the mark in front of it is drawn from ([tasksItem.status]). The + // record's own row knows less than the node does. + word = item.live.Word + } + if !line.folds || line.open || line.kids <= 0 { + return rowSay(word) + } + return rowSay(word+rowSep+tasksUnderWord(line.kids), word+rowSep+tasksHoldsShort(line.kids), word) +} + +// tasksHoldsShort is [tasksUnderWord] with the room a COLUMN has rather than a +// row's whole width: `holds 4` where the line says `holds 4 more`. It is law 2 +// applied to the one cell on this page that still has a longer and a shorter +// thing to say — the state word itself cannot be shortened and must not be cut. +func tasksHoldsShort(kids int) string { return "holds " + itoa(kids) } + +// tasksChatStateField is a CONVERSATION's cell: how much work opening it puts on +// the page, and the most urgent thing among that work. +// +// A CONVERSATION HAS NO STATE OF ITS OWN, and this is not one — it is the count +// and the word of what is under it, which is exactly the question a shut root +// raises. `5 your call` is five rows and at least one of them wants somebody; +// `9 done` is nine rows and nothing to do. +func tasksChatStateField(chat tasksChat) rowField { + if chat.kids <= 0 || strings.TrimSpace(chat.word) == "" { + return rowSay() + } + return rowSay(itoa(chat.kids)+" "+chat.word, itoa(chat.kids)) +} + +// ── the row ───────────────────────────────────────────────────────────────── + +// tasksTableRow lays one row of the table out: the name in what the columns +// leave, then the two columns, then one cell of air. +// +// EVERY ROW OF ONE FRAME ANSWERS THE SAME TWO QUESTIONS. That is the whole +// difference from the tail it replaces — the columns are in the same cells on +// every row, so the eye reads DOWN a column instead of re-parsing each row's own +// ranked prefix, and a row with nothing to say in a column draws nothing there +// rather than pulling the next fact leftwards into the hole. +func tasksTableRow(name string, state, second rowField, secondInk func(string) string, + room int, key tasksSortKey, pal palette, lit bool) string { + stateCells, secondCells, nameCells := tasksColumns(room, key) + said := fit(name, nameCells) + out := placeSubject(said, lit, pal) + pad(nameCells-ansi.StringWidth(said)) + if stateCells > 0 { + word := rowTail([]rowField{state}, stateCells) + out += placeFactInk(lit, pal)(word) + pad(stateCells-ansi.StringWidth(word)) + } + if secondCells > 0 { + // THE SORT KEY'S COLUMN IS RIGHT-ALIGNED, because every one of its three + // answers is a FIGURE — an age, a count, a price — and figures are read + // down their last digit. + figure := rowTail([]rowField{second}, secondCells) + out += pad(secondCells-ansi.StringWidth(figure)) + secondInk(figure) + } + return out + pad(tasksColumnAir) +} + +// pad is n spaces, and none for a negative count. +func pad(n int) string { + if n <= 0 { + return "" + } + return strings.Repeat(" ", n) +} + +// ── the control row ───────────────────────────────────────────────────────── + +// THE FILTER BOX IS THE FIRST ROW OF THE LIST, AND THE COLUMN LABELS STAND OVER +// THE COLUMNS THEY NAME. +// +// It replaces the note line under the list that said the filter back to a person +// who could not see the filter itself. That line existed because the box two rows +// below it was the router's message box and said nothing about narrowing +// anything: what somebody typed went somewhere they could not see, and the only +// correction available was an echo UNDER the rows their keystrokes had just +// changed. The box is on the list now, where the typing lands. + +// tasksSortArrow is which way the sorted column is pointing. +// +// IT IS TYPOGRAPHY AND NOT A MARK FROM THE VOCABULARY, which is the same +// judgement every other arrow on this surface is spelled under — `→ verbs:`, +// `↑↓ choose`, the window control's own `shift+← … →`. internal/iconlaw owns the +// runes that NAME A STATE, and a direction is not one of them. +const ( + tasksSortDown = "↓" + tasksSortUp = "↑" +) + +// tasksControlLabels is the right-hand end of the control row: the two labels, +// with the arrow on whichever column the list is sorted by. +// +// THERE ARE EXACTLY TWO LABELS BECAUSE THERE ARE EXACTLY TWO COLUMNS. `state` is +// always one of them; the other is whatever the second column is showing, which +// is the sort key itself or the age standing in for a key that has no cell +// ([tasksSortKey.column]). Sorting by name puts the arrow on neither, because +// neither column is the name — and that is honest rather than a gap: the control +// row's left half is showing the filter, and the foot names the key. +func tasksControlLabels(key tasksSortKey, back bool) (state, second string) { + arrow := tasksSortDown + if back { + arrow = tasksSortUp + } + state, second = tasksByState.word(), key.column().word() + if key == tasksByState { + return state + " " + arrow, second + } + if key == key.column() { + return state, second + " " + arrow + } + return state, second +} + +// tasksControlRow is that line: the mark, then what has been typed or the dim +// invitation to type it, and at the right the two labels. +func tasksControlRow(query string, key tasksSortKey, back bool, room int, pal palette) string { + stateLabel, secondLabel := tasksControlLabels(key, back) + mark := pal.glyph(tokens.GFilter) + lead := pal.dim(mark) + " " + box := pal.dim(tasksTypeWord) + if query != "" { + box = pal.ink(query) + } + // THE LABELS ARE FITTED TO THEIR OWN COLUMNS AND THE BOX TAKES THE REST, which + // is the row under it laid out with a label where its name goes — so the two + // lines cannot drift apart as the frame moves. + stateCells, secondCells, nameCells := tasksColumns(room, key) + boxCells := nameCells - ansi.StringWidth(mark) - 1 + if boxCells < 1 { + boxCells = 1 + } + said := fit(ansi.Strip(query), boxCells) + if query == "" { + said = fit(tasksTypeWord, boxCells) + } + _ = box + out := lead + pal.dim(said) + pad(boxCells-ansi.StringWidth(said)) + if query != "" { + out = lead + pal.ink(said) + pad(boxCells-ansi.StringWidth(said)) + } + if stateCells > 0 { + out += pal.dim(fit(stateLabel, stateCells)) + pad(stateCells-ansi.StringWidth(fit(stateLabel, stateCells))) + } + if secondCells > 0 { + label := fit(secondLabel, secondCells) + out += pad(secondCells-ansi.StringWidth(label)) + pal.dim(label) + } + return out + pad(tasksColumnAir) +} diff --git a/internal/tui3/tassort.go b/internal/tui3/tassort.go new file mode 100644 index 0000000000..71796c18d0 --- /dev/null +++ b/internal/tui3/tassort.go @@ -0,0 +1,248 @@ +package tui3 + +import ( + "strings" + "time" +) + +// tassort.go is WHAT THE LIST IS ORDERED BY, AND IT IS ALSO THE COLUMN THE LIST +// DRAWS. The two are one fact: THE SORT KEY IS THE COLUMN YOU SEE. +// +// The table has exactly two fact columns and both are always filled. `state` is +// the one word every row has. The second is the sort key — age by default, and +// cost or files only while the list is sorted by one of them — so a blank cell +// is now a TRUE ANSWER (this row cost nothing anybody recorded) rather than a +// missing one. Before it, the row's tail offered six facts and most rows could +// say two of them, so the right edge was holes, and a blank meant either +// "nothing to say" or "the frame ran out" with nothing on screen telling the two +// apart. +// +// SORTING IS INSIDE EACH LEVEL OF THE TREE AND THE TREE NEVER FLATTENS. +// Conversations order by their aggregate inside their section, the work under a +// conversation orders by its own value, and a family's children order among +// themselves. [tasksTreeOf] applies ONE comparison at each level it builds, so +// there is one answer to "which of these two rows comes first" wherever the +// question is asked. + +// tasksSortKey is one of the five things this list can be ordered by. +type tasksSortKey uint8 + +const ( + // tasksByAge is the default, and it is the reason this page exists: a record + // is read newest first. + tasksByAge tasksSortKey = iota + tasksByName + tasksByState + tasksByFiles + tasksByCost + tasksSortKeyCount +) + +// word is the label the control row draws, the word the foot names and the word +// the manual quotes. ONE SPELLING PER KEY. +func (k tasksSortKey) word() string { + switch k { + case tasksByName: + return "name" + case tasksByState: + return "state" + case tasksByFiles: + return "files" + case tasksByCost: + return "cost" + } + return "age" +} + +// next is the cycle the sort key walks, and it wraps. +func (k tasksSortKey) next() tasksSortKey { return (k + 1) % tasksSortKeyCount } + +// column is WHICH KEY'S VALUE THE SECOND COLUMN SHOWS. +// +// Three of the five have a value a cell can hold. The other two order the list +// by something a person can already see — the row's own name, and the `state` +// column standing beside this one — so putting either in the second column would +// be one fact in two places. The column falls back to the AGE, which is what a +// record is read by when nothing else is asked for, and which is why every +// section on this page is named by time. +func (k tasksSortKey) column() tasksSortKey { + switch k { + case tasksByFiles, tasksByCost: + return k + } + return tasksByAge +} + +// cells is how wide that column has to be to say its key's longest answer WHOLE. +// +// IT IS SIZED BY WHAT IT HOLDS AND NOT BY ONE NUMBER THAT SUITS THREE OF THE +// FIVE. A figure with its end cut off is a wrong number, which is the one thing +// rowfit.go's law forbids anywhere on this surface: the widest money this page +// draws needs six cells and `12 files` needs eight, so a single width would +// either cut a price or leave four cells of air down every frame sorted by age. +func (k tasksSortKey) cells() int { + switch k.column() { + case tasksByFiles: + return len("12 files") + case tasksByCost: + return len("$60.11") + } + return len("11h") +} + +// tasksRank is ONE ROW'S ANSWER TO EVERY SORT KEY AT ONCE, gathered once while +// the tree is built. +// +// IT IS ONE STRUCT AND NOT FIVE ACCESSORS for two reasons. The comparison is +// asked O(n log n) times at every level, and each field is a walk or a format +// away from the record. And a CONVERSATION's answer is an AGGREGATE of the work +// under it ([tasksRank.fold]) rather than a field of anything — so a group and a +// row can only be compared at all through one shape. +type tasksRank struct { + // at is when this row IS, for an age to be drawn from, and the zero time + // wherever nobody knows one ([tasksEntryStamp] holds that judgement and says + // why it is not the stamp the time window sorts by). + at time.Time + name string + // state is the row's urgency as this page files it, which is THE SAME ANSWER + // that decides which section it stands under ([tasksSectionOf]). Sorting by + // state off a second table would be two orders for one word. + state tasksSection + files int + cost float64 +} + +// tasksRankOf is one piece of work's answer. +func tasksRankOf(item tasksItem, now time.Time) tasksRank { + return tasksRank{ + at: tasksEntryStamp(item, now), + name: tasksLabel(item.entry), + state: item.section, + files: item.entry.FilesChanged, + cost: item.entry.Cost, + } +} + +// tasksRankZero is the empty aggregate a fold starts from: nothing known about +// any key, and the LEAST urgent state, so the first row folded in decides it. +func tasksRankZero() tasksRank { return tasksRank{state: tasksSectionCount} } + +// fold folds one row's answer into a group's: the newest age, the total spend, +// the total files, and the most urgent state. +// +// THE AGGREGATE IS THE SAME QUESTION ASKED OF A GROUP. A person sorting by cost +// wants to know which CONVERSATION cost the most; a root showing one child's +// figure would be answering a question nobody asked. It is also what a shut fold +// must say, because a shut fold is standing in for everything behind it. +func (r tasksRank) fold(kid tasksRank) tasksRank { + if kid.at.After(r.at) { + r.at = kid.at + } + if r.name == "" { + r.name = kid.name + } + if kid.state < r.state { + r.state = kid.state + } + r.files += kid.files + r.cost += kid.cost + return r +} + +// known reports whether this row has anything to say about one key. A row that +// has nothing draws NOTHING in the column — the emptiness law — and sinks to the +// bottom of its group whichever way the column is pointing. +func (k tasksSortKey) known(r tasksRank) bool { + switch k { + case tasksByName: + return strings.TrimSpace(r.name) != "" + case tasksByState: + // EVERY ROW HAS A STATE, which is the whole reason it is the first of the + // two columns and the reason this arm cannot answer false. + return r.state < tasksSectionCount + case tasksByFiles: + return r.files > 0 + case tasksByCost: + return r.cost > 0 + } + return !r.at.IsZero() +} + +// less is the ONE comparison this page sorts by, at every level of the tree. +func (k tasksSortKey) less(a, b tasksRank, back bool) bool { + ak, bk := k.known(a), k.known(b) + if ak != bk { + // THE EMPTINESS LAW'S OWN ANSWER SINKS, WHICHEVER WAY THE COLUMN POINTS. + // A blank cell is a row with nothing to say about this key, and reversing + // the order must never promote silence to the top of the page. + return ak + } + if ak { + switch { + case k.ahead(a, b): + return !back + case k.ahead(b, a): + return back + } + } + // A TIE IN THE KEY IS BROKEN BY THIS PAGE'S OWN DEFAULT ORDER AND THEN BY THE + // NAME, so one reading drawn twice is the same list twice. A sort that left + // ties to the order rows happened to arrive in would reshuffle under somebody + // reading it every time a node landed. + if !a.at.Equal(b.at) { + return a.at.After(b.at) + } + return strings.ToLower(a.name) < strings.ToLower(b.name) +} + +// ahead is the key's own order with no direction applied: which of the two a +// person who asked for this column would expect to read first. +func (k tasksSortKey) ahead(a, b tasksRank) bool { + switch k { + case tasksByName: + return strings.ToLower(a.name) < strings.ToLower(b.name) + case tasksByState: + // MOST URGENT FIRST, which is the order the sections themselves are in + // ([tasksSectionOrder]) rather than a second ranking of the same words. + return a.state < b.state + case tasksByFiles: + return a.files > b.files + case tasksByCost: + return a.cost > b.cost + } + // NEWEST FIRST. Every section here is named by time, and the age is what a + // person scans down. + return a.at.After(b.at) +} + +// tasksKeyField is the second column's cell for one row, in the spellings the +// fitter chooses between ([rowSay]). +// +// IT IS THE COLUMN'S VALUE AND NEVER THE KEY'S. Sorting by name or by state puts +// the AGE in this cell ([tasksSortKey.column] says why), so the cell is asked of +// the column rather than of the key somebody pressed. +func tasksKeyField(k tasksSortKey, r tasksRank, now time.Time) rowField { + switch k.column() { + case tasksByFiles: + if r.files <= 0 { + return rowSay() + } + return rowSay(itoa(r.files)+plural(" file", r.files), itoa(r.files)) + case tasksByCost: + if r.cost <= 0 { + return rowSay() + } + return rowSay(dollars(r.cost)) + } + return rowSay(sinceAt(r.at, now)) +} + +// tasksKeyInk is the hue that cell is said in. Money is the only fact on this +// page with an ink of its own and it keeps it here, because the ink is part of +// the fact (placeprose.go's [placeMoneyInk]). +func tasksKeyInk(k tasksSortKey, lit bool, pal palette) func(string) string { + if k.column() == tasksByCost { + return placeMoneyInk(pal) + } + return placeFactInk(lit, pal) +} From d07cef5f1279ad6157dd1072010807010de8b83b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:58:46 -0400 Subject: [PATCH 03/20] wip: the row is a table row, the control row is laid out, folds open shut Co-Authored-By: Claude Opus 5 --- internal/tui3/roomfacts.go | 46 ++++- internal/tui3/tasksplace.go | 346 ++++++------------------------------ internal/tui3/taskstable.go | 73 +++++--- 3 files changed, 147 insertions(+), 318 deletions(-) diff --git a/internal/tui3/roomfacts.go b/internal/tui3/roomfacts.go index edc8e8b1c2..3d250c8cbe 100644 --- a/internal/tui3/roomfacts.go +++ b/internal/tui3/roomfacts.go @@ -93,8 +93,52 @@ func (a *app) roomSetupInk(text string, node *taskNode) string { return a.pal.dim(text) } model := strings.TrimSpace(node.model) - return tasksPaintTail(text, []tasksFact{ + return roomPaintTail(text, []roomFact{ {field: rowSay(a.roomSpend(node)), ink: a.pal.ink}, {field: rowSay(model, modelBase(model)), ink: a.pal.narr}, }, a.pal.dim, a.pal) } + +// roomFact is one fact of a room's head line: what it can say, in the spellings +// the fitter chooses between, and the ink it is painted in when it survives. +// +// IT LIVES HERE NOW AND IT USED TO BE THE TASKS PLACE'S. That page drew a ranked +// tail of six facts and this was its shape; the page is a TABLE now +// (taskstable.go), with fixed columns and nothing to paint fact by fact, and a +// `tasks`-named type that only the room used would be a name pointing at a page +// that no longer has one. +type roomFact struct { + field rowField + // ink is nil for everything the surface draws dim, which is nearly all of it. + // A fact with an ink of its own has it because the ink is part of the fact. + ink func(string) string +} + +// roomPaintTail paints a fitted tail fact by fact. +// +// IT PAINTS THE SEPARATORS ITSELF because a hue nested inside a hue ends at the +// inner one's reset (room.go's [app.roomHeadWord] states the same rule), so a +// tail carrying a money figure may not be painted whole. Anything the fitter +// spelled that no fact answers to — the halves of a sentence that had a ` · ` of +// its own inside it — is the dim every other fact wears. +func roomPaintTail(tail string, facts []roomFact, rest func(string) string, pal palette) string { + if tail == "" { + return "" + } + ink := func(said string) string { + for _, fact := range facts { + if fact.ink == nil || said == "" { + continue + } + if said == fact.field.full || said == fact.field.short || said == fact.field.tiny { + return fact.ink(said) + } + } + return rest(said) + } + said := strings.Split(tail, rowSep) + for i := range said { + said[i] = ink(said[i]) + } + return strings.Join(said, pal.dim(rowSep)) +} diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 6dbf76dd1c..f3fb737c21 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -214,6 +214,11 @@ type tasksReading struct { // the folder a person is already in tells one row from no other. folder string tilde string + // query is what has been typed into the filter box, for the control row to + // draw. The FILTERING is done before the reading is handed over + // ([tasksPlace.filtered]); what is kept here is the text itself, because the + // box is a row of the list now and a row cannot ask the surface anything. + query string // order is the column this page is sorted by and which way, handed in by the // place the way the folds are ([tasksReading.open] states the same law): a // snapshot is replaced whole every time a node lands, and an order that lived @@ -472,6 +477,12 @@ const ( // tasksLineTail is the second line of a phone card — the same row continued // (taskphone.go), never a row of its own. tasksLineTail + // tasksLineControl is the one line under the head: the filter box on the left + // and the column labels on the right ([tasksControlRow]). It is a line of the + // LAYOUT rather than chrome around it, because its labels stand over the + // columns the rows under it draw — two answers to where the `cost` column is + // is a click that sorts by the wrong thing. + tasksLineControl // tasksLineChat is a MAIN CONVERSATION standing over the work it asked for. // It is deliberately its own kind and not a task line wearing a made-up // entry: a conversation has no id in the record, nothing can stop it and @@ -557,6 +568,13 @@ func (r tasksReading) lay(width int) []tasksLine { } add(tasksLineWord, r.headLine(width)) phone := layoutTier(width) == tierPhone + // THE CONTROL ROW IS THE FIRST ROW OF THE LIST AND NOT A SECOND HEAD. It + // stands where the rows stand, because its labels are the rows' own columns + // named — and it is absent on a phone, where there are no columns to label + // and the box at the foot is the way in to the filter (taskphone.go). + if !phone { + add(tasksLineControl, "") + } // work draws one piece of work and, while its fold is open, everything under // it — to whatever depth the record goes. @@ -570,7 +588,8 @@ func (r tasksReading) lay(width int) []tasksLine { key := tasksKeyOf(item.entry) kids := tree.kids[key] own := len(lines) - line := tasksLine{kind: tasksLineTask, item: item, owner: own, under: named || nested} + line := tasksLine{kind: tasksLineTask, item: item, owner: own, under: named || nested, + rank: tree.rank[key]} mark := "" switch { case len(kids) > 0: @@ -627,7 +646,7 @@ func (r tasksReading) lay(width int) []tasksLine { line := tasksLine{ kind: tasksLineChat, chat: g.chat, owner: len(lines), folds: len(g.roots) > 0, family: g.chat.key, kids: g.chat.kids, - open: r.opens(g.chat.key), + open: r.opens(g.chat.key), rank: g.chat.rank, } mark := tasksKinPad if line.folds { @@ -1280,6 +1299,8 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo switch line.kind { case tasksLineAir: return "" + case tasksLineControl: + return lead + tasksControlRow(r.query, r.order, room, pal) case tasksLineWord: if i == 0 { // THE HEAD LINE IS THE WINDOW'S CONTROL TOO (SCREEN 3d), drawn by the @@ -1296,7 +1317,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, r.folder, r.tilde, pal, lit) + return lead + kin + tasksChatRow(line, room, r.now, r.folder, r.tilde, r.order, pal, lit) case tasksLineTail: indent := strings.Repeat(" ", taskSheetPhoneIndent) tail := room - taskSheetPhoneIndent - ansi.StringWidth(line.kin) @@ -1316,7 +1337,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo if layoutTier(width) == tierPhone { return lead + kin + tasksCardHead(line.item, room, pal, lit) } - return lead + kin + tasksRow(line, room, r.now, pal, lit) + return lead + kin + tasksRow(line, room, r.now, r.order, pal, lit) } // tasksUnderWord is what a shut fold says about the work it is holding. @@ -1598,261 +1619,54 @@ func tasksLabel(entry session.TaskIndexEntry) string { return strings.TrimSpace(entry.Title) } -// tasksChatRow is one MAIN CONVERSATION on the page: what it is called, and — -// while its fold is shut — how much work is under it. It obeys the same law -// every row here does: the name keeps every cell it asks for before a fact gets -// one (rowfit.go, law 1). +// tasksChatRow is one MAIN CONVERSATION on the page: what it is called, what +// opening it would put on the page, and its aggregate under the sorted column. // -// IT WEARS NO EXTRA GLYPH. The fold mark already identifies a conversation -// root, while the section and its children describe the work's state. +// IT WEARS NO MARK. The fold mark already identifies a conversation root, and its +// name runs all the way to the state column because there is no state glyph in +// front of it to make room for. // -// 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, folder, tilde string, pal palette, lit bool) string { +// AND IT NAMES ITS PROJECT ONLY WHERE THAT IS NEWS ([chatProjectWord], +// projecttag.go). The project used to be a fact at the right of this row, which +// is where the sorted column now lives; on a table there is nowhere to put it +// that is not a column, so it joins the NAME — the one thing on the row that +// flexes, and the one thing the project was ever qualifying. +func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string, by tasksSort, pal palette, lit bool) string { chat := line.chat - facts := make([]rowField, 0, 3) - if !line.open && chat.kids > 0 { - // A MARK WITH NO COUNT is a mark a person has to open to find out whether - // it was worth opening — the same sentence a shut family says. - facts = append(facts, rowSay(tasksUnderWord(chat.kids))) - } - facts = append(facts, rowSay(sinceAt(chat.at, now))) - // 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. + name := 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} - label, tail := plan.fit(width) - if tail == "" { - return fit(placeSubject(label, lit, pal), width) + name += rowSep + project } - pad := width - ansi.StringWidth(label) - ansi.StringWidth(tail) - if pad < 1 { - pad = 1 - } - return fit(placeSubject(label, lit, pal)+strings.Repeat(" ", pad)+placeFactInk(lit, pal)(tail), width) + return tasksTableRow(name, tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), + tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit) } -// tasksRow is one piece of work on a wide frame, laid out by the law rowfit.go -// holds and every other list on this surface already obeys. -// -// WHAT IT USED TO DO, AND WHY IT WAS THE LAW INVERTED. The facts were measured -// first and the NAME was handed whatever remained, with a drop loop that only -// fired once the facts had taken more than the row had — so the name was -// guaranteed eight cells and the facts were guaranteed everything. At a hundred -// and sixty columns, the most room anybody has, three rows in four still drew -// `✓ Put the…` beside a ninety-character sentence of detail at its full width. -// A list exists to let a person MATCH NAMES; a name they cannot match is a row -// they have to open to read, and every scan of the page became a sequence of -// opens. +// tasksRow is one piece of work on a wide frame, and it is A ROW OF A TABLE: the +// mark, the name in whatever the columns leave, then the two columns. +// taskstable.go holds the arithmetic and says why this is a table at all. // -// So: the name keeps every cell it asks for before a fact gets one (law 1), the -// facts behind it are a RANKED PREFIX that degrades by spelling rather than -// vanishing (laws 2 and 3), and they are joined by ` · ` — the separator every -// other list here joins facts with, including this task's own page one keypress -// away. `The Certificate Rotation incomplete now` was three facts a reader had -// to re-parse into three. +// IT TAKES THE LAID-OUT LINE AND NOT THE WORK ALONE, because three of the things +// on the row are facts about where it SITS rather than about what it did — the +// fold it is holding shut, the count behind that fold, and the aggregate it +// stands for — and the layout is where all three were decided +// ([tasksReading.lay]); the paint may not re-derive any of them. // -// IT FITS AGAINST THE ROOM AND NOT THE FRAME, which is why it reaches -// [rowPlan.fit] rather than [rowHalves]: the caller has already spent the -// cursor's lead and the family column, and the two-line phone lane is -// [tasksCardHead]'s ([tasksReading.paint] routes to it before this is called), -// so the tier question is settled before we are here. -// -// It takes the LAID-OUT LINE and not the work alone, because two of the things -// on the row are facts about where it sits rather than about what it did — the -// fold it is holding shut, and the root above it that has already named their -// conversation — and the layout is where both were decided -// ([tasksReading.lay]); the paint may not re-derive either. -func tasksRow(line tasksLine, width int, now time.Time, pal palette, lit bool) string { +// IT FITS AGAINST THE ROOM AND NOT THE FRAME: the caller has already spent the +// place's left edge and the family column, and the two-line phone lane is +// [tasksCardHead]'s ([tasksReading.paint] routes to it before this is called), so +// the tier question is settled before we are here. +func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palette, lit bool) string { item := line.item glyph, glyphInk := tasksGlyph(item, pal) lead := glyph + " " - facts := tasksFacts(line, now, pal) room := width - ansi.StringWidth(lead) - // THE SPEND ONLY EVER FILLS SPACE THE OTHER FACTS DID NOT WANT. - // - // Being LAST in the rank is not enough, and this row is where that showed. - // The fitter degrades a fact before it drops it ([rowTail], law 2), so a - // hundred-and-twenty-column frame cut `Annual is the default and the monthly - // price stays visible beside it.` down to `2 files` — and then spent the cells - // that bought on `$0.27`. The person lost the sentence saying what the work - // came to and kept the one figure they did not open this page for, which is - // the same inversion the reorder was meant to end, arriving through the - // spelling ladder instead of through the order. - // - // So money is asked a question no other fact is asked: is every fact ahead of - // it being said WHOLE? If any of them had to be shortened, the row has already - // run out of room for what it is about, and the figure is not drawn at all. - if !tasksSpendEarnsCells(tasksLabel(item.entry), facts, room) { - facts = facts[:len(facts)-1] - } - plan := rowPlan{primary: tasksLabel(item.entry), fields: tasksFields(facts)} - label, tail := plan.fit(room) - left := glyphInk(glyph) + " " + placeSubject(label, lit, pal) - if tail == "" { - return fit(left, width) - } - pad := width - ansi.StringWidth(lead+label) - ansi.StringWidth(tail) - if pad < 1 { - pad = 1 - } - return fit(left+strings.Repeat(" ", pad)+tasksPaintTail(tail, facts, placeFactInk(lit, pal), pal), width) -} - -// tasksFact is one fact on a row: what it can say, in the spellings the fitter -// chooses between, and the ink it is painted in when it survives. -type tasksFact struct { - field rowField - // ink is nil for everything the surface draws dim, which is nearly all of - // it. A fact with an ink of its own has it because the ink is part of the - // fact — money is the only one on this row. - ink func(string) string -} - -// tasksFacts is the ranked prefix: what a person scanning this list reads, in -// the order they read it. -// -// THE ORDER IS THE ARGUMENT, AND THE TAIL IS SPENT FROM THE END, so this is also -// what a narrow frame gives up and in which order. -// -// 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 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` -// was the loudest thing on a row whose state word had been cut off to make room -// for it. Nobody opens this page to find out what work cost; the spend has a -// place of its own, and this row's job is to say what the work is and what it -// needs. -// -// THE KIND IS GONE ENTIRELY. It spelled `adaptive` next to `18 of 40` on a -// running row — an implementation word competing with the progress a person was -// actually reading, describing a setting somebody chose before the work started -// that changes nothing they can do now. A capability that changes no action is -// not a fact worth a cell. -func tasksFacts(line tasksLine, now time.Time, pal palette) []tasksFact { - item := line.item - entry := item.entry - source := strings.TrimSpace(item.row.Title) - if source == "" { - source = strings.TrimSpace(item.row.Project) - } - // A ROW DOES NOT REPEAT A CONVERSATION THAT IS ALREADY NAMED ABOVE IT. The - // page drew the same title four times down a family, and once per row down a - // whole conversation, spending twenty-odd cells a row to restate a fact the - // row above had already stated — on the rows whose names were being cut to - // make room for it. What is left saying it is a row this page could not place - // under any conversation, which is the one row where it is news. - if line.under { - source = "" - } - fold := rowSay() - if line.folds && !line.open && line.kids > 0 { - // A MARK WITH NO COUNT is a mark a person has to open to find out - // whether it was worth opening. - fold = rowSay(tasksUnderWord(line.kids)) - } - money := rowSay() - if entry.Cost > 0 { - money = rowSay(dollars(entry.Cost)) - } - return []tasksFact{ - {field: fold}, - {field: rowSay(tasksNote(item))}, - {field: tasksMiddleField(item)}, - {field: tasksAgeField(item, now)}, - {field: rowSay(source)}, - {field: money, ink: placeMoneyInk(pal)}, - } -} - -// tasksSpendEarnsCells reports whether the money fact ([tasksFacts] puts it -// last) has earned the cells it would take: every fact ahead of it fits at its -// LONGEST spelling in the room the name leaves behind. -// -// IT MEASURES AND DOES NOT DRAW. The answer is handed back to [tasksRow], which -// drops the fact before fitting, so there is still exactly one fitter deciding -// what a row says — this only decides what is offered to it. -func tasksSpendEarnsCells(name string, facts []tasksFact, room int) bool { - if len(facts) == 0 { - return true - } - money := facts[len(facts)-1].field - if !money.known() { - // Nothing to weigh: a row that cost nothing says nothing about cost. - return true - } - plan := rowPlan{primary: name} - left := room - ansi.StringWidth(plan.label(room)) - rowGutter - if left <= 0 { - return false - } - whole := rowAll(tasksFields(facts[:len(facts)-1])) - return ansi.StringWidth(whole) <= left -} - -// tasksFields is the facts as the fitter takes them. -func tasksFields(facts []tasksFact) []rowField { - fields := make([]rowField, 0, len(facts)) - for _, fact := range facts { - fields = append(fields, fact.field) - } - return fields -} - -// tasksPaintTail paints a fitted tail fact by fact. -// -// IT PAINTS THE SEPARATORS ITSELF because a hue nested inside a hue ends at the -// inner one's reset (room.go's [app.roomHeadWord] states the same rule), so a -// tail carrying a money figure may not be painted whole. Anything the fitter -// spelled that no fact answers to — the halves of a detail sentence that had a -// ` · ` of its own inside it — is the dim every other fact wears. -func tasksPaintTail(tail string, facts []tasksFact, rest func(string) string, pal palette) string { - if tail == "" { - return "" - } - ink := func(said string) string { - for _, fact := range facts { - if fact.ink == nil || said == "" { - continue - } - if said == fact.field.full || said == fact.field.short || said == fact.field.tiny { - return fact.ink(said) - } - } - return rest(said) - } - said := strings.Split(tail, rowSep) - for i := range said { - said[i] = ink(said[i]) + if room < 1 { + room = 1 } - return strings.Join(said, pal.dim(rowSep)) + return glyphInk(glyph) + " " + tasksTableRow(tasksLabel(item.entry), + tasksStateField(line), tasksKeyField(by.key, line.rank, now), + tasksKeyInk(by.key, lit, pal), room, by.key, pal, lit) } // tasksAgeField is HOW LONG AGO, and it is the fact this page's own headings @@ -1901,50 +1715,6 @@ func tasksEntryStamp(item tasksItem, now time.Time) time.Time { return item.entry.EndedAt } -// 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 — 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() - } - // 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()) - // 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(rungs...) -} - // tasksCardHead is the first line of a phone card: the state and the name, in // the same hue the wide row paints them. func tasksCardHead(item tasksItem, width int, pal palette, lit bool) string { diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index fa7a4db6d4..ac8a3fa1ab 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -218,16 +218,16 @@ const ( // ([tasksSortKey.column]). Sorting by name puts the arrow on neither, because // neither column is the name — and that is honest rather than a gap: the control // row's left half is showing the filter, and the foot names the key. -func tasksControlLabels(key tasksSortKey, back bool) (state, second string) { +func tasksControlLabels(by tasksSort) (state, second string) { arrow := tasksSortDown - if back { + if by.back { arrow = tasksSortUp } - state, second = tasksByState.word(), key.column().word() - if key == tasksByState { + state, second = tasksByState.word(), by.key.column().word() + switch { + case by.key == tasksByState: return state + " " + arrow, second - } - if key == key.column() { + case by.key == by.key.column(): return state, second + " " + arrow } return state, second @@ -235,33 +235,25 @@ func tasksControlLabels(key tasksSortKey, back bool) (state, second string) { // tasksControlRow is that line: the mark, then what has been typed or the dim // invitation to type it, and at the right the two labels. -func tasksControlRow(query string, key tasksSortKey, back bool, room int, pal palette) string { - stateLabel, secondLabel := tasksControlLabels(key, back) +func tasksControlRow(query string, by tasksSort, room int, pal palette) string { + stateLabel, secondLabel := tasksControlLabels(by) mark := pal.glyph(tokens.GFilter) - lead := pal.dim(mark) + " " - box := pal.dim(tasksTypeWord) - if query != "" { - box = pal.ink(query) - } - // THE LABELS ARE FITTED TO THEIR OWN COLUMNS AND THE BOX TAKES THE REST, which - // is the row under it laid out with a label where its name goes — so the two - // lines cannot drift apart as the frame moves. - stateCells, secondCells, nameCells := tasksColumns(room, key) - boxCells := nameCells - ansi.StringWidth(mark) - 1 - if boxCells < 1 { - boxCells = 1 - } - said := fit(ansi.Strip(query), boxCells) - if query == "" { - said = fit(tasksTypeWord, boxCells) - } - _ = box - out := lead + pal.dim(said) + pad(boxCells-ansi.StringWidth(said)) + // THE BOX IS LAID OUT WHERE THE NAMES ARE AND THE LABELS OVER THEIR OWN + // COLUMNS, both out of [tasksColumns] — so the label a person clicks and the + // cells it stands over are the same cells at every width, and the two lines + // cannot drift apart as the frame moves. + stateCells, secondCells, nameCells := tasksColumns(room, by.key) + boxCells := max(nameCells-ansi.StringWidth(mark)-1, 1) + // WHAT IS TYPED IS IN THE READING INK AND THE INVITATION IS DIM. A person has + // to be able to tell the words they typed from the words the box came with. + said, ink := fit(tasksTypeWord, boxCells), pal.dim if query != "" { - out = lead + pal.ink(said) + pad(boxCells-ansi.StringWidth(said)) + said, ink = fit(query, boxCells), pal.ink } + out := pal.dim(mark) + " " + ink(said) + pad(boxCells-ansi.StringWidth(said)) if stateCells > 0 { - out += pal.dim(fit(stateLabel, stateCells)) + pad(stateCells-ansi.StringWidth(fit(stateLabel, stateCells))) + label := fit(stateLabel, stateCells) + out += pal.dim(label) + pad(stateCells-ansi.StringWidth(label)) } if secondCells > 0 { label := fit(secondLabel, secondCells) @@ -269,3 +261,26 @@ func tasksControlRow(query string, key tasksSortKey, back bool, room int, pal pa } return out + pad(tasksColumnAir) } + +// tasksControlHit is which label one cell of the control row is under, and +// whether it is under one at all. +// +// IT IS THE SAME ARITHMETIC THE PAINT USES ([tasksColumns]), asked from the other +// end. The pointer resolving a press against its own idea of where a column sits +// is exactly how a click comes to sort by the wrong thing, which is the argument +// the frame and the hit map are one function for (place_tasks.go). +// +// `x` is the cell inside the ROW — the place's left edge already spent. +func tasksControlHit(x, room int, by tasksSort) (tasksSortKey, bool) { + stateCells, secondCells, nameCells := tasksColumns(room, by.key) + switch { + case stateCells > 0 && x >= nameCells && x < nameCells+stateCells: + return tasksByState, true + case secondCells > 0 && x >= nameCells+stateCells && x < nameCells+stateCells+secondCells: + // THE SECOND LABEL NAMES THE COLUMN AND NOT THE KEY. Sorting by name puts + // the age in that column, so a click on it asks for the age — which is what + // the word under the pointer says. + return by.key.column(), true + } + return 0, false +} From 2f15924308b296999e19dd0a018664aa80e839de Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:01:07 -0400 Subject: [PATCH 04/20] wip: clickable labels, the grown reason line, the foot, and the test call sites Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 99 ++++++++++++++--- internal/tui3/stateux_qa_test.go | 4 +- internal/tui3/tallyrows_test.go | 16 +-- internal/tui3/taskrecord_ancestry_test.go | 2 +- internal/tui3/tasksconversation_bench_test.go | 2 +- .../tui3/tasksconversation_review_test.go | 6 +- internal/tui3/tasksconversation_test.go | 14 +-- internal/tui3/tasksection_attention_test.go | 12 +-- internal/tui3/tasksparked_test.go | 2 +- internal/tui3/tasksplace_test.go | 48 ++++----- internal/tui3/tasksplacewords_test.go | 8 +- internal/tui3/taskstable.go | 101 ++++++++++++++++++ internal/tui3/taskstamp_test.go | 2 +- internal/tui3/taskview.go | 5 + 14 files changed, 247 insertions(+), 74 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 7cfea6acbf..0e568eeeae 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -306,6 +306,10 @@ func (p *tasksPlace) filtered(a *app) tasksReading { // asked for). r.order = p.order needle := a.taskSheetFilter() + // AND SO IS WHAT IS IN THE BOX, because the box is a ROW of the list now + // ([tasksControlRow]) and a row cannot ask the surface anything. It is the + // untrimmed text, so a person who has typed a space sees the caret move. + r.query = p.query.String() if needle == "" { return r } @@ -742,6 +746,30 @@ func (a *app) taskSheetFold(open bool) bool { return true } +// taskSheetSortBy re-orders the page by one column, and it is THE ONE DOOR ONTO +// THAT — the chord and the click both come here, so a label pressed twice and a +// key cycled round to itself behave identically ([tasksSort.on] holds the rule). +// +// THE CURSOR STAYS ON THE ROW IT IS ON. Re-ordering moves rows under a person +// who is reading one of them, and the cursor is a LINE; it is remembered by the +// pair that identifies the work, exactly as it is across a rebuild +// ([tasksPlace.regroup] states the law and the failure it exists to stop). +func (a *app) taskSheetSortBy(key tasksSortKey) { + was, held := a.taskSheet.rowAt(a, a.taskSheet.cursor) + a.taskSheet.order = a.taskSheet.order.on(key) + a.taskSheet.top = 0 + if held { + if line, found := a.taskSheet.lineOf(a, was); found { + a.taskSheet.cursor = a.tasksSettle(line) + a.taskSheet.top = 0 + a.touch() + return + } + } + a.taskSheet.cursor = a.tasksSettle(0) + a.touch() +} + // taskSheetTyped is what every edit of the filter ends with: the list has // changed under the cursor, so the cursor goes back to the first row of it and // the window with it. A cursor left at row forty of a list that now has three is @@ -849,6 +877,12 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.taskSheetMove(len(a.taskSheet.stops(a))) case "enter": return a.taskSheetEnter(), true + case tasksSortKeyChord: + a.taskSheetSortBy(a.taskSheet.order.key.next()) + case tasksSortBackChord: + // THE SAME KEY AGAIN REVERSES ([tasksSort.on]), which is what a person + // means by this chord: not "the previous key" but "the other way round". + a.taskSheetSortBy(a.taskSheet.order.key) // ── the filter's own edits, in the settings panel's spelling ────────────── case "backspace": @@ -1099,6 +1133,16 @@ func (a *app) taskSheetPress(x, y int) tea.Cmd { a.taskSheetBarPress(x) return nil } + // A COLUMN LABEL IS PRESSED WHERE IT IS DRAWN. The control row resolves its + // own press against the SAME arithmetic the paint used ([tasksColumns], asked + // through [tasksControlHit]) — a pointer with its own idea of where a column + // sits is exactly how a click comes to sort by the wrong thing. + if hits[y].kind == taskSheetHitControl { + if key, ok := tasksControlHit(x-len(tasksBareLead), width-len(tasksBareLead), a.taskSheet.order); ok { + a.taskSheetSortBy(key) + } + return nil + } if hits[y].kind != taskSheetHitRow { return nil } @@ -1248,6 +1292,18 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { return rows } p.cursor = a.tasksSettle(p.cursor) + // THE CURSOR'S ROW GROWS A LINE WHERE THERE IS NO PANE, so the window has one + // row less to put the list in ([tasksReasonShowing] says why it asks the whole + // frame). It is reserved BEFORE the window is placed rather than squeezed in + // after: a line added afterwards would push the last row of the list off the + // frame, and the row it pushes off is sometimes the cursor's own. + grown := "" + if item, ok := r.at(lines, p.cursor); ok && tasksReasonShowing(a) { + grown = tasksReasonLine(item, width-len(tasksBareLead)-taskSheetPhoneIndent, a.pal) + } + if grown != "" && room > 1 { + room-- + } p.top = tasksTop(lines, p.cursor, p.top, room) rows := make([]placeRow, 0, room) @@ -1265,6 +1321,9 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { break } hit, lit := taskSheetHit{}, false + if lines[at].kind == tasksLineControl { + hit = taskSheetHit{kind: taskSheetHitControl} + } if owner := lines[at].owner; owner >= 0 { if r.picks(lines, owner) { hit = taskSheetHit{kind: taskSheetHitRow, index: owner} @@ -1282,6 +1341,17 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { } rows = append(rows, placeRow{text: text, hit: hit}) bare = append(bare, !lit) + if grown == "" || at != p.cursor { + continue + } + // AND THE GROWN LINE BELONGS TO THE ROW ABOVE IT. It answers to the same + // press and it is never faded, because it is part of the row the cursor is + // standing on rather than a row of its own. + rows = append(rows, placeRow{ + text: tasksBareLead + strings.Repeat(" ", taskSheetPhoneIndent) + grown, + hit: hit, + }) + bare = append(bare, false) } for i := range bare { if !bare[i] { @@ -1350,26 +1420,18 @@ func (p *tasksPlace) note(a *app, width int) []string { if tally := r.tally(); tally != "" { note = append(note, " "+a.pal.dim(fit(tally, width-2))) } - if a.taskSheetFiltering() { - // WHAT WAS TYPED HAS TO BE ON SCREEN. A list that has lost rows for a - // reason a reader cannot see is a list that has lost them for no reason - // at all. - note = append(note, " "+a.pal.dim(fit(taskSheetFilterLine(a.taskSheetFilter(), len(r.items)+len(r.chats)), width-2))) + // THE FILTER IS NO LONGER SAID BACK HERE. This line used to carry `filter · + // zzz` because the box a person was typing into was invisible, so the only + // place their own words could appear was UNDER the rows those words had just + // removed — a correction printed below the thing it was correcting. The box is + // the first row of the list now ([tasksControlRow]). What survives is the half + // the box cannot say: that the query has emptied the place. + if a.taskSheetFiltering() && len(r.items)+len(r.chats) == 0 { + note = append(note, " "+a.pal.dim(fit(taskSheetFilterNone, width-2))) } return note } -// taskSheetFilterLine is what was typed, said back where a person is already -// reading the tally — and, when the query has emptied the place, the one clause -// that stops a blank list reading as a page that broke. -func taskSheetFilterLine(needle string, kept int) string { - line := taskSheetFilterWord + needle - if kept == 0 { - line += taskSheetFilterNone - } - return line -} - // hint is SCREEN 1e's foot, assembled from the clauses that are TRUE of the row // under the cursor (taskview.go spells every word of it and says why the design's // one static line is built rather than quoted). @@ -1450,6 +1512,11 @@ func (p *tasksPlace) hint(a *app) string { if a.taskSheetFiltering() { parts = append(parts, tasksClearFilterWord) } + // AND THE SORT, LAST, because it is the one clause that is true of the PAGE + // rather than of the row under the cursor — and because it names the key the + // page is on, which is the only thing about the order the control row does not + // already draw ([tasksControlRow] wears the arrow). + parts = append(parts, tasksSortHint(a.taskSheet.order)) return strings.Join(parts, railSep) } diff --git a/internal/tui3/stateux_qa_test.go b/internal/tui3/stateux_qa_test.go index ad93f08343..48f4da2d4b 100644 --- a/internal/tui3/stateux_qa_test.go +++ b/internal/tui3/stateux_qa_test.go @@ -25,7 +25,7 @@ func TestTaskListKeepsLiveDesignApprovalWhenTheIndexOnlySaysRunning(t *testing.T a, _ := awaitingDesign(t) a.file = "/tmp/status-qa/conversation/session.jsonl" a.comp.tasks = []session.TaskIndexEntry{{ID: "4", SessionID: "conversation", Label: "harness · flake triage", Status: string(session.TaskRunning), Kind: session.TaskKindHarness}} - reading := readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 7), time.Time{}, a.now()) + reading := readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 7), tasksSort{}, time.Time{}, a.now()) if len(reading.items) != 1 { t.Fatalf("want one task, got %d", len(reading.items)) } @@ -33,7 +33,7 @@ func TestTaskListKeepsLiveDesignApprovalWhenTheIndexOnlySaysRunning(t *testing.T t.Fatalf("live design approval disappeared behind the index: %+v", item) } a.tasks[4].doing = "designing" - reading = readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 7), time.Time{}, a.now()) + reading = readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 7), tasksSort{}, time.Time{}, a.now()) if reading.items[0].section != tasksRunning { t.Fatal("resumed design still asks for approval") } diff --git a/internal/tui3/tallyrows_test.go b/internal/tui3/tallyrows_test.go index 72ca5ed1e8..1f536fa759 100644 --- a/internal/tui3/tallyrows_test.go +++ b/internal/tui3/tallyrows_test.go @@ -50,7 +50,7 @@ func tasksHeadingRow(lines []tasksLine, word string) string { // rows the page is withholding, and opening the fold leaves it nothing to say. func TestTheTasksSectionHeadNamesOnlyWhatTheFoldHolds(t *testing.T) { world, win, now := tasksFamilyFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) held := len(reading.section(tasksToday)) lines := reading.lay(120) @@ -90,7 +90,7 @@ func TestTheTasksSectionHeadNamesOnlyWhatTheFoldHolds(t *testing.T) { func TestTheSectionHeadCountsTheRowsItActuallyWithholds(t *testing.T) { world, win, now := tasksFamilyFixture() for _, open := range []bool{false, true} { - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) if open { reading.open = map[tasksKey]bool{{session: "room-a", id: "1"}: true} } @@ -116,7 +116,7 @@ func TestTheSectionHeadCountsTheRowsItActuallyWithholds(t *testing.T) { // its word alone or the exact count of work its folds are withholding. func TestAMixedPageHeadsSectionsOnlyWithTheirFoldedRows(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) lines := reading.lay(120) tree := reading.tree() frame := strings.Join(reading.rows(120, palette{}), "\n") @@ -151,7 +151,7 @@ func TestAMixedPageHeadsSectionsOnlyWithTheirFoldedRows(t *testing.T) { // A PAGE THAT FOLDS NOTHING SAYS NOTHING EXTRA, on the heading or on the foot. func TestASectionWithNothingFoldedAwaySaysNothingExtra(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) lines := reading.lay(120) for _, line := range lines { if line.kind == tasksLineWord && strings.Contains(line.text, " shown") { @@ -172,7 +172,7 @@ func TestASectionWithNothingFoldedAwaySaysNothingExtra(t *testing.T) { // `… · 5 done today, 2 s…`, a figure with its end cut off. func TestTheTasksFootFitsTheNarrowestFrameWholeWithAFoldOnThePage(t *testing.T) { world, win, now := tasksFamilyFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) said := reading.tally() for _, width := range []int{60, 80, 120, 160} { if got := fit(said, width-2); got != said { @@ -187,7 +187,7 @@ func TestTheTasksFootFitsTheNarrowestFrameWholeWithAFoldOnThePage(t *testing.T) func TestTheTasksFootStillCountsEveryPieceByItsActualState(t *testing.T) { now := time.Date(2026, time.September, 6, 13, 11, 0, 0, time.UTC) world, win := tasksAskingWorld(now, true) - reading := readTasks(world, tasksMine{}, win, now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) want := strings.Join([]string{ "1 " + tasksSectionWord(tasksNeeds), "3 " + tasksSectionWord(tasksRunning), @@ -223,7 +223,7 @@ func TestTheManualQuotesBothTasksOpeningHeadingsExactly(t *testing.T) { }) } chats[0].Tasks.Rows[0].Cost = 2.98 - conversationReading := readTasks(session.World{Projects: []session.Project{{Sessions: chats}}}, tasksMine{}, win, time.Time{}, now) + conversationReading := readTasks(session.World{Projects: []session.Project{{Sessions: chats}}}, tasksMine{}, win, tasksSort{}, time.Time{}, now) flat := session.SessionRow{ID: "flat", Title: "flat work", At: now} for i := 0; i < 148; i++ { @@ -233,7 +233,7 @@ func TestTheManualQuotesBothTasksOpeningHeadingsExactly(t *testing.T) { }) } flat.Tasks.Rows[0].Cost = 34.10 - flatReading := readTasks(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{flat}}}}, tasksMine{}, win, time.Time{}, now) + flatReading := readTasks(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{flat}}}}, tasksMine{}, win, tasksSort{}, time.Time{}, now) for _, heading := range []string{conversationReading.head(200, false), flatReading.head(200, true)} { if !manual.Chat().Mentions(heading) { diff --git a/internal/tui3/taskrecord_ancestry_test.go b/internal/tui3/taskrecord_ancestry_test.go index ce0141f67e..b650ac5f83 100644 --- a/internal/tui3/taskrecord_ancestry_test.go +++ b/internal/tui3/taskrecord_ancestry_test.go @@ -21,7 +21,7 @@ func TestTaskRecordReturnRevealsItsAncestors(t *testing.T) { } world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}} a.taskSheet = tasksPlace{ - reading: readTasks(world, tasksMine{}, session.LastDays(now, 14), time.Time{}, now), + reading: readTasks(world, tasksMine{}, session.LastDays(now, 14), tasksSort{}, time.Time{}, now), awayAt: a.elsewhere().Read, mineAt: a.railStamp, } for _, want := range row.Tasks.Rows[1:] { diff --git a/internal/tui3/tasksconversation_bench_test.go b/internal/tui3/tasksconversation_bench_test.go index 8f39d12680..a6c74b87a4 100644 --- a/internal/tui3/tasksconversation_bench_test.go +++ b/internal/tui3/tasksconversation_bench_test.go @@ -34,7 +34,7 @@ func BenchmarkTasksConversationHistory(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) _ = reading.lay(120) } } diff --git a/internal/tui3/tasksconversation_review_test.go b/internal/tui3/tasksconversation_review_test.go index 38df68e322..9fa2a322ba 100644 --- a/internal/tui3/tasksconversation_review_test.go +++ b/internal/tui3/tasksconversation_review_test.go @@ -12,7 +12,7 @@ func TestTasksIncludesMainChatsBeforeTheyDelegateWork(t *testing.T) { now := time.Now() row := session.SessionRow{ID: "main-chat", Title: "Investigate parser failures", Transcript: "/chat/main/transcript.jsonl", At: now} world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}} - r := readTasks(world, tasksMine{}, session.LastDays(now, 14), time.Time{}, now) + r := readTasks(world, tasksMine{}, session.LastDays(now, 14), tasksSort{}, time.Time{}, now) line := tasksLineOf(t, r.lay(80), row.Title) if line.kind != tasksLineChat || line.folds || len(r.items) != 0 { t.Fatalf("a main chat with no children became a worker or a dead fold: %+v", line) @@ -43,7 +43,7 @@ func TestTasksDeepAncestrySurvivesFilteringWithoutACutoff(t *testing.T) { } world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}} a := tasksChatApp(t) - a.taskSheet.reading = readTasks(world, tasksMine{}, session.LastDays(now, 14), time.Time{}, now) + a.taskSheet.reading = readTasks(world, tasksMine{}, session.LastDays(now, 14), tasksSort{}, time.Time{}, now) a.taskSheet.query.setText("130") r := a.tasksFiltered() if len(r.items) != 130 { @@ -108,7 +108,7 @@ func TestAwayPresenceKeepsItsKnownChatAndAncestry(t *testing.T) { world, win, now := tasksChatFixture() mine := tasksMine{away: []session.ElsewhereTask{{SessionID: "room-a", Session: "another window", Task: session.PresenceTask{ID: "4", Title: "port the token table", State: string(session.TaskRunning)}}}} - r := readTasks(world, mine, win, time.Time{}, now) + r := readTasks(world, mine, win, tasksSort{}, time.Time{}, now) key := tasksKey{session: "room-a", id: "4"} tree := r.tree() if tree.up[key].id != "3" { diff --git a/internal/tui3/tasksconversation_test.go b/internal/tui3/tasksconversation_test.go index cc719b31f5..ff6bc244fc 100644 --- a/internal/tui3/tasksconversation_test.go +++ b/internal/tui3/tasksconversation_test.go @@ -72,7 +72,7 @@ func tasksChatFixture() (session.World, session.UsageWindow, time.Time) { func tasksChatReading() tasksReading { world, win, now := tasksChatFixture() - return readTasks(world, tasksMine{}, win, time.Time{}, now) + return readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) } // tasksChatApp is that fixture on a surface, with the freshness stamps pinned so @@ -85,7 +85,7 @@ func tasksChatApp(t *testing.T) *app { a.clock = func() time.Time { return now } a.raisePlace(pageTasks) a.taskSheet.world = world - a.taskSheet.reading = readTasks(world, tasksMine{}, win, time.Time{}, now) + a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) a.taskSheet.awayAt, a.taskSheet.mineAt = a.elsewhere().Read, a.railStamp a.taskSheet.cursor = a.tasksSettle(0) return a @@ -341,7 +341,7 @@ func TestAConversationFoldRemembersBeingShut(t *testing.T) { } // AND IT IS STILL SHUT AFTER THE PAGE REBUILDS UNDER IT. world, win, now := tasksChatFixture() - a.taskSheet.reading = readTasks(world, tasksMine{}, win, time.Time{}, now) + a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) if strings.Contains(tasksPage(a.tasksFiltered(), width), "rotate the certificate") { t.Fatal("a rebuild opened a conversation somebody had shut") } @@ -373,7 +373,7 @@ func TestTheCursorKeepsAConversationAcrossARebuild(t *testing.T) { SessionID: "room-a", ID: "9", Label: "size the corpus", Status: string(session.TaskRunning), }) - a.taskSheet.reading = readTasks(world, tasksMine{}, win, time.Time{}, now) + a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) line, found := a.taskSheet.lineOf(a, was) if !found { @@ -437,7 +437,7 @@ func TestACircularRecordStillDrawsEveryRowOnce(t *testing.T) { world := session.World{Projects: []session.Project{ {Name: "aforge", Sessions: []session.SessionRow{row}}, }, Read: now} - r := readTasks(world, tasksMine{}, session.LastDays(now, 7), time.Time{}, now) + r := readTasks(world, tasksMine{}, session.LastDays(now, 7), tasksSort{}, time.Time{}, now) r.unfolded = true if len(r.items) != 4 { @@ -463,7 +463,7 @@ func TestAnOrphanedChildIsStillDrawn(t *testing.T) { world := session.World{Projects: []session.Project{ {Name: "aforge", Sessions: []session.SessionRow{row}}, }, Read: now} - r := readTasks(world, tasksMine{}, session.LastDays(now, 7), time.Time{}, now) + r := readTasks(world, tasksMine{}, session.LastDays(now, 7), tasksSort{}, time.Time{}, now) page := tasksPage(r, 120) if !strings.Contains(page, "the worker whose run is gone") { @@ -507,7 +507,7 @@ func TestWorkOutOfAnUnnamedConversationIsDrawnWithNoRowOverIt(t *testing.T) { world := session.World{Projects: []session.Project{ {Name: "aforge", Sessions: []session.SessionRow{row}}, }, Read: now} - r := readTasks(world, tasksMine{}, session.LastDays(now, 7), time.Time{}, now) + r := readTasks(world, tasksMine{}, session.LastDays(now, 7), tasksSort{}, time.Time{}, now) lines := r.lay(120) for _, line := range lines { diff --git a/internal/tui3/tasksection_attention_test.go b/internal/tui3/tasksection_attention_test.go index 161b41e78b..0e0c68207d 100644 --- a/internal/tui3/tasksection_attention_test.go +++ b/internal/tui3/tasksection_attention_test.go @@ -97,7 +97,7 @@ func tasksSame(a, b []string) bool { func TestAConversationsQuestionDoesNotRefileItsRunningTasks(t *testing.T) { now := time.Date(2026, time.September, 6, 13, 11, 0, 0, time.UTC) world, win := tasksAskingWorld(now, true) - reading := readTasks(world, tasksMine{}, win, now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) needs := tasksSectionLabels(reading, tasksNeeds) if !tasksSame(needs, []string{"rotate the certificate"}) { @@ -135,7 +135,7 @@ func TestAConversationsQuestionDoesNotRefileItsRunningTasks(t *testing.T) { func TestTheRunningCountSurvivesAConversationsQuestion(t *testing.T) { now := time.Date(2026, time.September, 6, 13, 11, 0, 0, time.UTC) world, win := tasksAskingWorld(now, true) - reading := readTasks(world, tasksMine{}, win, now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) want := strings.Join([]string{ "1 " + tasksSectionWord(tasksNeeds), @@ -178,8 +178,8 @@ func TestTheGroupingIsTheSameWhetherOrNotTheConversationIsAsking(t *testing.T) { now := time.Date(2026, time.September, 6, 13, 11, 0, 0, time.UTC) calmWorld, win := tasksAskingWorld(now, false) askingWorld, _ := tasksAskingWorld(now, true) - calm := readTasks(calmWorld, tasksMine{}, win, now.Add(-time.Hour), now) - asking := readTasks(askingWorld, tasksMine{}, win, now.Add(-time.Hour), now) + calm := readTasks(calmWorld, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) + asking := readTasks(askingWorld, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) for _, section := range tasksSectionOrder { want := tasksSectionLabels(calm, section) @@ -213,7 +213,7 @@ func TestWorkNobodyCouldCheckStillNeedsYourLook(t *testing.T) { Projects: []session.Project{{Name: "media", Sessions: []session.SessionRow{quiet}}}, Read: now, } - reading := readTasks(world, tasksMine{}, session.LastDays(now, 7), now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, session.LastDays(now, 7), tasksSort{}, now.Add(-time.Hour), now) if got := tasksSectionLabels(reading, tasksNeeds); !tasksSame(got, []string{"verify the pro model's pricing"}) { t.Fatalf("work nobody could check is not under %q: %v", tasksSectionWord(tasksNeeds), got) } @@ -245,7 +245,7 @@ func TestTheNeedsSectionIsTheWorksOwnReading(t *testing.T) { world.Projects = append(world.Projects, session.Project{ Name: "aforge", Sessions: []session.SessionRow{gone}, }) - reading := readTasks(world, tasksMine{}, win, now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) if len(reading.items) == 0 { t.Fatalf("the reading drew nothing to judge (asking=%v)", asking) } diff --git a/internal/tui3/tasksparked_test.go b/internal/tui3/tasksparked_test.go index b7cd977bf1..8f051a42d2 100644 --- a/internal/tui3/tasksparked_test.go +++ b/internal/tui3/tasksparked_test.go @@ -43,7 +43,7 @@ func tasksParkedFixture() (session.World, session.UsageWindow, time.Time) { // the count in the foot, and the age beside it. func TestWorkParkedOnAPersonIsNotFiledUnderRunning(t *testing.T) { world, win, now := tasksParkedFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) sections := map[string]tasksSection{} items := map[string]tasksItem{} diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index 2bd67cbb11..60004f042d 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -58,7 +58,7 @@ func tasksFixture() (session.World, session.UsageWindow, time.Time) { func TestTheTasksPageGroupsByWhatYouDoNext(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, now.Add(-time.Hour), now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) rows := reading.rows(120, newPalette(tokens.NoColor, false)) page := strings.Join(rows, "\n") wants := []string{tierYourCallWord, "waiting"} @@ -139,7 +139,7 @@ func TestNoTasksRowIsHiddenBehindAFold(t *testing.T) { } } reading := readTasks(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, - tasksMine{}, session.LastDays(now, 10), time.Time{}, now) + tasksMine{}, session.LastDays(now, 10), tasksSort{}, time.Time{}, now) text := strings.Join(reading.rows(120, newPalette(tokens.NoColor, false)), "\n") if strings.Contains(text, tokens.GlyphCollapsed) { t.Fatalf("a section folded rows away behind a glyph no key opens:\n%s", text) @@ -171,7 +171,7 @@ func TestTheTasksPageDrawsNoEmptySection(t *testing.T) { world, win, now := tasksFixture() world.Projects[0].Sessions[0].Tasks.Rows = world.Projects[0].Sessions[0].Tasks.Rows[:1] world.Projects = world.Projects[:1] - page := strings.Join(readTasks(world, tasksMine{}, win, time.Time{}, now).rows(100, newPalette(tokens.NoColor, false)), "\n") + page := strings.Join(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now).rows(100, newPalette(tokens.NoColor, false)), "\n") if strings.Contains(page, "\nrunning\n") || strings.Contains(page, "\nfinished today\n") || strings.Contains(page, "\nearlier\n") { t.Fatalf("an empty section drew a heading:\n%s", page) } @@ -179,7 +179,7 @@ func TestTheTasksPageDrawsNoEmptySection(t *testing.T) { func TestEveryTasksRowKeepsInsideItsCells(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) for _, width := range tasksWidths { for i, row := range reading.rows(width, newPalette(tokens.TrueColor, false)) { if got := ansi.StringWidth(row); got > width { @@ -194,7 +194,7 @@ func TestEveryTasksRowKeepsInsideItsCells(t *testing.T) { // already keeps between its own two sections (margin.go). func TestTheTasksSectionsAreSeparatedByABlankLineAndNothingElse(t *testing.T) { world, win, now := tasksFixture() - lines := readTasks(world, tasksMine{}, win, time.Time{}, now).lay(120) + lines := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now).lay(120) if len(lines) == 0 { t.Fatal("the fixture laid out nothing") } @@ -223,7 +223,7 @@ func TestTheTasksSectionsAreSeparatedByABlankLineAndNothingElse(t *testing.T) { func TestTheTasksCursorOnlyOpensTaskRows(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) pal := newPalette(tokens.NoColor, false) lines := reading.lay(120) rows := reading.rows(120, pal) @@ -272,7 +272,7 @@ func TestOnePieceOfWorkIsDrawnOnceAcrossEveryAuthority(t *testing.T) { Task: session.PresenceTask{ID: "3", Title: "Port the parser", State: string(session.TaskRunning)}, }}, } - reading := readTasks(world, mine, session.LastDays(now, 10), time.Time{}, now) + reading := readTasks(world, mine, session.LastDays(now, 10), tasksSort{}, time.Time{}, now) if len(reading.items) != 1 { t.Fatalf("three authorities produced %d rows, want 1: %+v", len(reading.items), reading.items) } @@ -311,7 +311,7 @@ func TestARowNobodyIsRunningSaysItIsIncomplete(t *testing.T) { Status: string(session.TaskRunning), SessionID: "a-window-that-went", } reading := readTasks(session.World{}, tasksMine{rows: []tasksMineRow{{entry: stalled}}}, - session.LastDays(now, 10), time.Time{}, now) + session.LastDays(now, 10), tasksSort{}, time.Time{}, now) if len(reading.items) != 1 || reading.items[0].section != tasksEarlier { t.Fatalf("a claim nobody is behind is filed as %+v", reading.items) } @@ -346,7 +346,7 @@ func TestTheEmptyTasksPlaceTeachesWithoutInventingRows(t *testing.T) { if got := placeWhisperLines(pageTasks, 120, pal); len(got) != 2 || !strings.Contains(got[1], "/task") { t.Fatalf("whisper rows = %#v", got) } - empty := readTasks(session.World{}, tasksMine{}, session.UsageWindow{}, time.Time{}, time.Time{}) + empty := readTasks(session.World{}, tasksMine{}, session.UsageWindow{}, tasksSort{}, time.Time{}, time.Time{}) for _, width := range tasksWidths { if rows := empty.rows(width, pal); len(rows) != 0 { t.Fatalf("an empty reading drew %#v at %d columns", rows, width) @@ -402,7 +402,7 @@ func tasksFamilyFixture() (session.World, session.UsageWindow, time.Time) { // workers used to arrive as eight peers of everything else this machine ran. func TestTheTasksPageFoldsAFamilyShutAndOpensItOnDemand(t *testing.T) { world, win, now := tasksFamilyFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) shut := reading.lay(120) work := func(lines []tasksLine) []tasksLine { @@ -468,7 +468,7 @@ func TestAnUnattributedPageWithNoFamiliesDrawsNoFamilyColumn(t *testing.T) { world.Projects[i].Sessions[j].Title = "" } } - for _, line := range readTasks(world, tasksMine{}, win, time.Time{}, now).lay(120) { + for _, line := range readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now).lay(120) { if line.kind == tasksLineTask && line.kin != "" { t.Fatalf("a page with no families drew the column: %q on %q", line.kin, line.item.entry.Label) } @@ -487,7 +487,7 @@ func TestARunningParentKeepsItsRefusedChildUnderIt(t *testing.T) { rows[1].Outcome = "incomplete — the corrected diff stayed on the child branch" world.Projects[0].Sessions[0].Open = true - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) for _, item := range reading.items { if tasksFamilyOf(item.entry) == tasksFamilyOf(rows[0]) && reading.tree().filed[tasksKeyOf(item.entry)] != tasksRunning { t.Fatalf("family member %q was filed under %q, want running", item.entry.Label, tasksSectionWord(item.section)) @@ -532,7 +532,7 @@ func TestVisibleChildrenKeepTheirOwnSectionsWhenTheRootIsAbsent(t *testing.T) { world.Projects[0].Sessions[0].Tasks.Rows[0].EndedAt = time.Time{} world.Projects[0].Sessions[0].Open = true - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) sections := map[string]tasksSection{} for _, item := range reading.items { sections[item.entry.ID] = item.section @@ -613,7 +613,7 @@ func tasksDrawnRow(page, name string) string { // three rows in four still cut the name. func TestTheTaskNameIsWholeBeforeAnyFactGetsACell(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) for _, width := range []int{60, 80, 120, 160} { page := tasksPage(reading, width) for _, item := range reading.items { @@ -627,7 +627,7 @@ func TestTheTaskNameIsWholeBeforeAnyFactGetsACell(t *testing.T) { // row, because a row that has already spent the one thing it was drawn to // say may not spend cells on a figure as well. item := reading.items[0] - narrow := plain(tasksRow(tasksLine{kind: tasksLineTask, item: item}, 30, now, newPalette(tokens.NoColor, false), false)) + narrow := plain(tasksRow(tasksLine{kind: tasksLineTask, item: item}, 30, now, tasksSort{}, newPalette(tokens.NoColor, false), false)) if strings.Contains(narrow, "$") || strings.Contains(narrow, rowSep) { t.Fatalf("a frame too narrow for the name alone drew\n %s\nwant the cut name and no facts beside it", narrow) } @@ -639,7 +639,7 @@ func TestTheTaskNameIsWholeBeforeAnyFactGetsACell(t *testing.T) { // so the only separator on the row meant two different things. func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, 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): what // the work came to is `2 files · done` at its longest and `done` at its @@ -676,7 +676,7 @@ func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { // which is a fact about conversations and not about work. func TestEachTasksSectionReadsNewestFirst(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) want := []string{ "Port the picker onto the new list", "Write the morning sweep down", @@ -695,7 +695,7 @@ func TestEachTasksSectionReadsNewestFirst(t *testing.T) { // AND A FAMILY MOVES AS ONE, placed by its root's own stamp with the workers // under it in their own order. fam, famWin, famNow := tasksFamilyFixture() - family := readTasks(fam, tasksMine{}, famWin, time.Time{}, famNow) + family := readTasks(fam, tasksMine{}, famWin, tasksSort{}, time.Time{}, famNow) family.open = map[tasksKey]bool{{session: "room-a", id: "1"}: true} var kin []string for _, line := range family.lay(120) { @@ -717,7 +717,7 @@ func TestAFilterThatMatchesNothingStillCountsThePlace(t *testing.T) { a.clock = func() time.Time { return now } a.raisePlace(pageTasks) a.taskSheet.world = world - a.taskSheet.reading = readTasks(world, tasksMine{}, win, time.Time{}, now) + a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) a.taskSheet.query.setText("zzz") r := a.tasksFiltered() @@ -736,7 +736,7 @@ func TestAFilterThatMatchesNothingStillCountsThePlace(t *testing.T) { // with a preposition for a noun. func TestTheTasksHeadAndItsFoldsSayTheirFiguresInWords(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) if got, want := reading.head(120, false), "tasks · 3 pieces of work · $0.72"; got != want { t.Fatalf("the head reads\n %s\nwant\n %s", got, want) } @@ -766,7 +766,7 @@ func TestTheTasksHeadAndItsFoldsSayTheirFiguresInWords(t *testing.T) { // `3 files holds 3 more` ran two claims together with a bare space, at the // far right of the row, eleven cells from the edge where the eye does not go. fam, famWin, famNow := tasksFamilyFixture() - shut := readTasks(fam, tasksMine{}, famWin, time.Time{}, famNow) + shut := readTasks(fam, tasksMine{}, famWin, tasksSort{}, time.Time{}, famNow) row := tasksDrawnRow(tasksPage(shut, 120), "port the parser") if !strings.Contains(row, "holds 3 more · ") { t.Fatalf("the shut family's row reads\n %s\nand its count must lead the tail: `… holds 3 more · `", row) @@ -784,7 +784,7 @@ func TestARowNobodyIsRunningIsNotDatedNow(t *testing.T) { Status: string(session.TaskRunning), SessionID: "a-window-that-went", } reading := readTasks(session.World{}, tasksMine{rows: []tasksMineRow{{entry: stalled}}}, - session.LastDays(now, 10), time.Time{}, now) + session.LastDays(now, 10), tasksSort{}, time.Time{}, now) row := tasksDrawnRow(tasksPage(reading, 120), "Rotate the wildcard certificate") if !strings.HasSuffix(row, taskRecordStoppedWord) { t.Fatalf("a row nobody is behind reads\n %s\nand it should end on\n … %s, with no age after it", row, taskRecordStoppedWord) @@ -797,7 +797,7 @@ func TestARowNobodyIsRunningIsNotDatedNow(t *testing.T) { // being cut to make room for it. func TestAnOpenedFamilyNamesItsConversationOnce(t *testing.T) { world, win, now := tasksFamilyFixture() - reading := readTasks(world, tasksMine{}, win, time.Time{}, now) + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) reading.open = map[tasksKey]bool{{session: "room-a", id: "1"}: true} page := tasksPage(reading, 120) if got := strings.Count(page, "the split"); got != 1 { @@ -880,7 +880,7 @@ func TestARunningUndatedRunStaysAtTheTopOfTodaysTasks(t *testing.T) { } reading := readTasks(session.World{}, tasksMine{rows: []tasksMineRow{ {entry: landed}, {entry: running, runs: true}, - }}, window, time.Time{}, now) + }}, window, tasksSort{}, time.Time{}, now) if len(reading.items) != 2 || reading.items[0].entry.ID != running.ID { t.Fatalf("today's tasks are ordered %+v, want the live run before the older landing", reading.items) } diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go index 4e0915d7a8..89c2a589a1 100644 --- a/internal/tui3/tasksplacewords_test.go +++ b/internal/tui3/tasksplacewords_test.go @@ -41,7 +41,7 @@ func TestARowSaysItsStateWordOnce(t *testing.T) { 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)) + row := plain(tasksRow(tasksLine{kind: tasksLineTask, item: item}, 120, now, tasksSort{}, 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) @@ -79,7 +79,7 @@ func TestAConversationRootWearsAFolderTagOnlyWhereItIsNews(t *testing.T) { 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)) + return plain(tasksChatRow(line, 100, now, folder, "/home/pat", tasksSort{}, pal, false)) } root := func(title, project, dir string) tasksChat { return tasksChat{ @@ -119,7 +119,7 @@ func TestTheReadingKnowsTheFolderThisWindowIsSittingIn(t *testing.T) { 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) + r := readTasks(world, mine, session.LastDays(now, 14), tasksSort{}, 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) } @@ -170,7 +170,7 @@ func TestTheStateWordIsTheLastThingARowGivesUp(t *testing.T) { }} { 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)) + row := plain(tasksRow(tasksLine{kind: tasksLineTask, item: tc.item}, width, now, tasksSort{}, 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) diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index ac8a3fa1ab..0134b05ac0 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -284,3 +284,104 @@ func tasksControlHit(x, room int, by tasksSort) (tasksSortKey, bool) { } return 0, false } + +// ── the keys ──────────────────────────────────────────────────────────────── + +// THE SORT KEYS ARE CHORDS AND NOT BARE LETTERS, and that is a deliberate +// departure from the spec's own `s` / `S`. +// +// EVERY PRINTABLE KEY ON THIS PAGE IS THE FILTER (place_tasks.go's +// [app.taskSheetKeyPress] says why: the frame is the page, so there is no draft +// underneath for a keystroke to reach, and a record of four hundred tasks is +// found by remembering a word of a title). A bare `s` would take the filter's +// commonest letter away from it — `sweep`, `stop`, `site`, `session` — and the +// list a person was trying to narrow would re-sort instead. The two cannot both +// be bare, the filter box is the thing this wave put ON SCREEN, and the spec's +// own frame draws the invitation to type into it. +// +// So the chord is alt, which is what this surface already spends on a place's +// own verbs (`alt+.` the map, `alt+t` the roster, `alt+enter` a task), and the +// pointer keeps the gesture the spec leads with: the column labels are pressed. +const ( + tasksSortKeyChord = "alt+s" + tasksSortBackChord = "alt+shift+s" +) + +// tasksSortHint is how the foot names them, in the hint slot's own grammar: the +// key, then what it does. +func tasksSortHint(by tasksSort) string { + return tasksSortKeyChord + " sort: " + by.key.word() +} + +// ── the reason, off the row and under the cursor ──────────────────────────── + +// THE REASON LEFT THE ROW AND IT HAS TO LAND SOMEWHERE. +// +// A table's rows are a name and two facts; why one piece of work ended as it did +// is the record's, and the record is a pane beside the list on a frame wide +// enough for one. Under that width there is no pane — and the task-states law +// says a row may never read a bare `your call`, because the whole point of that +// word is that somebody has to do something and the page owes them what. +// +// So the CURSOR'S row, and only the cursor's row, grows one dim line. + +// tasksReasonLine is that line: the row's state with its reason behind it, and — +// for a landing that wrote one — the first sentence of what it came to. +// +// IT IS ONE LINE AND IT RETURNS A STRING. A wrapped answer would make the row's +// HEIGHT depend on the length of its reason, so every row under the cursor would +// move as the cursor walked, and the window that keeps a cursor's block whole +// ([tasksTop]) would be chasing a number that changed with the row it was +// measuring. +func tasksReasonLine(item tasksItem, width int, pal palette) string { + said := strings.TrimSpace(item.status().RowWord()) + if outcome := taskFirstSentence(item.entry.Outcome); outcome != "" && outcome != said { + said += rowSep + outcome + } + if said == "" { + return "" + } + return pal.dim(fit(said, width)) +} + +// taskFirstSentence is the opening sentence of what a landing wrote down, which +// is as much of a report as a single line can honestly carry. A report with no +// sentence end in it is taken whole and left to the fitter. +func taskFirstSentence(report string) string { + report = strings.TrimSpace(strings.SplitN(strings.TrimSpace(report), "\n", 2)[0]) + for at, r := range report { + if r != '.' && r != '!' && r != '?' { + continue + } + // A FULL STOP INSIDE A FILENAME IS NOT A SENTENCE END, which is the same + // judgement the step caption makes about the same characters: the stop has + // to be followed by a space or by nothing at all. + if at+1 >= len(report) { + break + } + if report[at+1] == ' ' { + return strings.TrimSpace(report[:at+1]) + } + } + return report +} + +// tasksReasonShowing reports whether the list should grow that line at all, +// AND IT ASKS THE WHOLE FRAME RATHER THAN THE LIST'S OWN WIDTH. +// +// The difference is the whole of the trap. Once the record stands in a pane +// beside the list, the list is drawn in the cells the pane leaves — 72 of 122 — +// which is already under the width at which a pane appears. A predicate that +// asked its own width would decide there is no pane on exactly the frames that +// have one, and draw this line underneath the pane that already says it. +func tasksReasonShowing(a *app) bool { + width, _ := a.size() + return layoutTier(width) != tierPhone && width < tasksPaneFloor +} + +// tasksPaneFloor is the frame at which the record moves off the cursor's row and +// into a pane of its own beside the list (#884). It is named here because this +// is the file that has to know the answer today; the pane's own lane replaces +// the body of [tasksReasonShowing] with its predicate and this constant goes +// with it. +const tasksPaneFloor = 110 diff --git a/internal/tui3/taskstamp_test.go b/internal/tui3/taskstamp_test.go index a973a12773..953999d13f 100644 --- a/internal/tui3/taskstamp_test.go +++ b/internal/tui3/taskstamp_test.go @@ -70,7 +70,7 @@ func TestARestoredTaskIsNeverDatedInTheFuture(t *testing.T) { } win := session.LastDays(a.now(), taskSheetDays) - reading := readTasks(session.World{}, a.taskSheetMine(), win, time.Time{}, a.now()) + reading := readTasks(session.World{}, a.taskSheetMine(), win, tasksSort{}, time.Time{}, a.now()) on := false for _, item := range reading.items { if item.entry.ID == "11" { diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index b113c1d22a..1bb07b501c 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -609,6 +609,11 @@ type taskSheetHitKind uint8 const ( taskSheetHitNone taskSheetHitKind = iota taskSheetHitRow + // taskSheetHitControl is the list's first row — the filter box and the two + // column labels ([tasksControlRow]). It is its own kind because a press on it + // is answered by WHICH CELL it landed in and not by a row index, which is the + // one gesture on this page that needs the column as well as the line. + taskSheetHitControl // phone lane: taskSheetHitBar is the foot at [tierPhone], where the key // legend becomes a `‹ back` band a thumb leaves by (taskphone.go). taskSheetHitBar From 10f7526244939b13cbd62454fded5cd245848f97 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:14:47 -0400 Subject: [PATCH 05/20] wip: the filter box moves onto the list, and the foot names both keys Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 26 +++++++++++++++++++---- internal/tui3/place_tasks_test.go | 35 +++++++++++++++++++++++++++++++ internal/tui3/tasksplace_test.go | 22 ++++++++++++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 0e568eeeae..df6efac5f1 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1516,7 +1516,7 @@ func (p *tasksPlace) hint(a *app) string { // rather than of the row under the cursor — and because it names the key the // page is on, which is the only thing about the order the control row does not // already draw ([tasksControlRow] wears the arrow). - parts = append(parts, tasksSortHint(a.taskSheet.order)) + parts = append(parts, tasksSortHint(a.taskSheet.order), tasksFilterHint) return strings.Join(parts, railSep) } @@ -1723,9 +1723,21 @@ func (placeTasks) note(a *app, width int) []string { return a.taskSheet.note func (placeTasks) hint(a *app) string { return a.taskSheet.hint(a) } func (placeTasks) changed(a *app, since time.Time) int { return a.taskSheet.changed(a, since) } -// box is the type-to-filter box: every printable key is the filter, which is the -// one thing this page can do with a letter. -func (placeTasks) box(a *app) *editor { return &a.taskSheet.query } +// box is NOTHING ON THIS PLACE, AND THAT IS THE FILTER BOX MOVING RATHER THAN +// GOING. +// +// The composer at the foot WAS this page's filter: every printable key went to +// [tasksPlace.query], and the router drew that editor's letters two rows under +// the list they were narrowing. The table put the box where the typing is about +// — the first row of the list, under the head and over the rows +// ([tasksControlRow]) — and a box drawn in both places would be one person's +// letters on screen twice, which is the defect this page's own title row was +// removed for. +// +// SO THE FOOT KEEPS THE INVITATION AND LOSES THE ECHO ([placeTasks.resting]): +// `› type to filter this list` stands there whatever is typed, and the letters +// appear once, in the row that is narrowing. +func (placeTasks) box(a *app) *editor { return nil } // resting is what that box says when nothing is typed in it, and it is THIS // PLACE'S sentence rather than the router's (pages.go's [place.resting]). @@ -1737,6 +1749,12 @@ func (placeTasks) box(a *app) *editor { return &a.taskSheet.query } // when they wonder what typing here will do. func (placeTasks) resting(a *app) string { return tasksTypeWord } +// tasksFilterHint is that same invitation on the FOOT'S KEY LINE, which the +// ruling of 2026-09-11 asks for beside the sort chord: a chord nobody can find +// is a chord that does not exist, and the filter is the other half of what the +// keyboard does here. +const tasksFilterHint = "type to filter" + // bar is the phone lane's foot: the key legend becomes a `‹ back` band a thumb // leaves by (taskphone.go). The count above it stays — a bar is the way out, and // the tally is what the place is holding. diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index 534f7afad7..d6e2387535 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -255,8 +255,43 @@ func TestSOnTheTasksStripStopsThatTaskThroughTheEnginesDoor(t *testing.T) { // still need, and it is asked here once rather than in forty places. func openTaskPlaceWithRows(a *app) bool { a.showPage(pageTasks) + // AND IT OPENS THE FOLDS, because these tests are about the ROWS. + // + // The place itself opens every conversation and every family SHUT (the owner's + // ruling, 2026-09-11 — [tasksReading.opens] states it and says why), so a test + // written to look at a piece of work would be looking at the root standing + // over it instead. A person reaches the same page with `→`; a test says so by + // calling this. The tests that are about the FOLDS ask for the place without + // it ([TestEveryConversationOpensShut] and its neighbours). + openTaskFolds(a) if len(a.taskSheet.reading.items) > 0 { a.taskSheetPointAt(a.taskSheet.reading.items[0].entry) } return a.at(pageTasks) && len(a.taskSheet.reading.items) > 0 } + +// openTaskFolds opens every fold on the page, the way pressing `→` down the list +// would — and it is remembered in the PLACE's own fold map rather than on the +// reading, because a reading is replaced whole every few seconds +// ([tasksPlace.regroup]) and a flag set on one would be gone by the next frame. +func openTaskFolds(a *app) { + if a.taskSheet.opened == nil { + a.taskSheet.opened = map[tasksKey]bool{} + } + width, _ := a.size() + // Families nest, so opening one can reveal another; this walks until a pass + // finds nothing left shut. The depth is the record's own and is small. + for i := 0; i < tasksKinLevels+2; i++ { + r := a.tasksFiltered() + shut := false + for _, line := range r.lay(width) { + if line.folds && !line.open { + a.taskSheet.opened[line.family] = true + shut = true + } + } + if !shut { + return + } + } +} diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index 60004f042d..0b5502bd49 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -585,8 +585,28 @@ func tasksPolishFixture() (session.World, session.UsageWindow, time.Time) { return world, session.LastDays(now, 14), now } -// tasksPage is the whole page as a person reads it, with no colour on it. +// tasksOpen is one reading with every fold open, for the tests that are about +// something other than folding. +// +// EVERY CONVERSATION AND EVERY FAMILY OPENS SHUT (the owner's ruling — see +// [tasksReading.opens]). A test written to look at a piece of work would be +// looking at the root standing over it instead, so it says which it means by +// asking for this. The tests that are ABOUT the folds do not call it. +func tasksOpen(r tasksReading) tasksReading { + r.unfolded = true + return r +} + +// tasksPage is the whole page as a person reads it, with no colour on it AND +// WITH EVERY FOLD OPEN — which is the page after `→`, and the page nearly every +// test through here was written against ([tasksOpen] says why it has to be +// asked for now). A test about the folds themselves lays the reading out itself. func tasksPage(r tasksReading, width int) string { + return tasksPageFolded(tasksOpen(r), width) +} + +// tasksPageFolded is that page exactly as the place would draw it, folds and all. +func tasksPageFolded(r tasksReading, width int) string { rows := r.rows(width, newPalette(tokens.NoColor, false)) for i := range rows { rows[i] = plain(rows[i]) From c538cedd2ea5343589247bf962757fca236ea666 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:29:15 -0400 Subject: [PATCH 06/20] =?UTF-8?q?wip:=20the=20key=20column=20holds=20its?= =?UTF-8?q?=20own=20label,=20and=20the=20fixtures=20ask=20for=20the=20page?= =?UTF-8?q?=20after=20=E2=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 9 ++- internal/tui3/tasksconversation_test.go | 59 +++++++++++---- internal/tui3/tasksection_attention_test.go | 2 +- internal/tui3/tasksplace_test.go | 81 ++++++++++++--------- internal/tui3/taskstable.go | 15 +++- internal/tui3/tassort.go | 26 ++++--- 6 files changed, 125 insertions(+), 67 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index df6efac5f1..398c5e1f04 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1749,10 +1749,11 @@ func (placeTasks) box(a *app) *editor { return nil } // when they wonder what typing here will do. func (placeTasks) resting(a *app) string { return tasksTypeWord } -// tasksFilterHint is that same invitation on the FOOT'S KEY LINE, which the -// ruling of 2026-09-11 asks for beside the sort chord: a chord nobody can find -// is a chord that does not exist, and the filter is the other half of what the -// keyboard does here. +// tasksFilterHint is the short spelling of that invitation. It is the control +// row's own placeholder ([tasksControlRow]) and it is repeated on the FOOT'S KEY +// LINE, which the ruling of 2026-09-11 asks for beside the sort chord: a chord +// nobody can find is a chord that does not exist, and the filter is the other +// half of what the keyboard does here. const tasksFilterHint = "type to filter" // bar is the phone lane's foot: the key legend becomes a `‹ back` band a thumb diff --git a/internal/tui3/tasksconversation_test.go b/internal/tui3/tasksconversation_test.go index ff6bc244fc..1c885ebc34 100644 --- a/internal/tui3/tasksconversation_test.go +++ b/internal/tui3/tasksconversation_test.go @@ -110,13 +110,29 @@ func tasksLineOf(t *testing.T, lines []tasksLine, name string) tasksLine { return tasksLine{} } -// tasksPointAt parks the cursor on the line one name is drawn on. +// tasksPointAt parks the cursor on the line one name is drawn on, OPENING +// WHATEVER FOLD IS OVER IT — which is what a person does with `→` on the way to +// the row they came for, now that every conversation and every family opens shut +// ([tasksReading.opens]). A test that is ABOUT a fold looks at the layout itself +// rather than asking for a cursor. func tasksPointAt(t *testing.T, a *app, name string) int { t.Helper() - r := a.tasksFiltered() + if at, found := tasksCursorOn(a, name); found { + return at + } + openTaskFolds(a) + if at, found := tasksCursorOn(a, name); found { + return at + } + width, _ := a.size() + t.Fatalf("no row on the page is called %q:\n%s", name, tasksPage(a.tasksFiltered(), width)) + return -1 +} + +// tasksCursorOn is that search over the page exactly as it stands. +func tasksCursorOn(a *app, name string) (int, bool) { width, _ := a.size() - lines := r.lay(width) - for at, line := range lines { + for at, line := range a.tasksFiltered().lay(width) { named := "" switch line.kind { case tasksLineChat: @@ -128,24 +144,29 @@ func tasksPointAt(t *testing.T, a *app, name string) int { } if named == name { a.taskSheet.cursor = at - return at + return at, true } } - t.Fatalf("no row on the page is called %q:\n%s", name, tasksPage(r, width)) - return -1 + return 0, false } // THE WORK HANGS UNDER THE CHAT THAT ASKED FOR IT, and the chat is a row. func TestEveryPieceOfWorkIsDrawnUnderItsOwnConversation(t *testing.T) { r := tasksChatReading() - lines := r.lay(120) + // EVERY CONVERSATION OPENS SHUT (the owner's ruling, 2026-09-11) and what this + // test is about is the SHAPE behind that fold, so it reads the page after `→`. + if shut := tasksLineOf(t, r.lay(120), "shipping the gate"); !shut.folds || shut.open { + t.Fatalf("a conversation came out folds=%v open=%v, and every fold on this page opens shut", + shut.folds, shut.open) + } + lines := tasksOpen(r).lay(120) chat := tasksLineOf(t, lines, "shipping the gate") if chat.kind != tasksLineChat { t.Fatalf("the conversation came out as a line of kind %v", chat.kind) } if !chat.folds || !chat.open { - t.Fatalf("the conversation came out as folds=%v open=%v, and a page of chat titles with the work behind them is not this place", + t.Fatalf("an opened conversation came out as folds=%v open=%v, and a page of chat titles with the work behind them is not this place", chat.folds, chat.open) } // TWO ROOTS AND NOT FOUR: the unchecked row and the parser, with the parser's @@ -226,7 +247,9 @@ func TestAConversationRowIsNeitherRunnableNorCancellable(t *testing.T) { // person is being asked for. func TestAConversationStandsUnderItsMostUrgentWorkWithoutRefilingIt(t *testing.T) { r := tasksChatReading() - lines := r.lay(120) + // The page after `→`: where a conversation is FILED is the claim here, and + // its work has to be on the page to say the second half of it. + lines := tasksOpen(r).lay(120) head, seen := "", map[string]string{} for _, line := range lines { @@ -319,6 +342,9 @@ func TestWorkNestsToWhateverDepthTheRecordCarries(t *testing.T) { // over a conversation is a key that visibly does nothing. func TestAConversationFoldRemembersBeingShut(t *testing.T) { a := tasksChatApp(t) + // The conversation opens shut, so this starts from the page after `→` — the + // claim is that `←` is remembered, and a fold already shut cannot say it. + openTaskFolds(a) tasksPointAt(t, a, "shipping the gate") if !a.taskSheetFold(false) { @@ -326,7 +352,7 @@ func TestAConversationFoldRemembersBeingShut(t *testing.T) { } r := a.tasksFiltered() width, _ := a.size() - page := tasksPage(r, width) + page := tasksPageFolded(r, width) if !strings.Contains(page, "shipping the gate") { t.Fatalf("shutting a conversation took its own row off the page:\n%s", page) } @@ -342,13 +368,13 @@ func TestAConversationFoldRemembersBeingShut(t *testing.T) { // AND IT IS STILL SHUT AFTER THE PAGE REBUILDS UNDER IT. world, win, now := tasksChatFixture() a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) - if strings.Contains(tasksPage(a.tasksFiltered(), width), "rotate the certificate") { + if strings.Contains(tasksPageFolded(a.tasksFiltered(), width), "rotate the certificate") { t.Fatal("a rebuild opened a conversation somebody had shut") } if !a.taskSheetFold(true) { t.Fatal("`→` did nothing over a shut conversation") } - if !strings.Contains(tasksPage(a.tasksFiltered(), width), "rotate the certificate") { + if !strings.Contains(tasksPageFolded(a.tasksFiltered(), width), "rotate the certificate") { t.Fatal("`→` over a shut conversation did not put its work back") } } @@ -480,9 +506,10 @@ func TestAnOrphanedChildIsStillDrawn(t *testing.T) { // different task in every one of them. func TestTwoConversationsWearingTheSameIdKeepTheirOwnFolds(t *testing.T) { r := tasksChatReading() - // Both conversations in the fixture have a task numbered 1. - r.open = map[tasksKey]bool{tasksChatKey("room-b"): false} - page := tasksPage(r, 120) + // Both conversations in the fixture have a task numbered 1, and both open + // shut — so one is opened by hand and the other left where it started. + r.open = map[tasksKey]bool{tasksChatKey("room-a"): true, tasksChatKey("room-b"): false} + page := tasksPageFolded(r, 120) if !strings.Contains(page, "rotate the certificate") { t.Fatalf("shutting one conversation shut another's work:\n%s", page) } diff --git a/internal/tui3/tasksection_attention_test.go b/internal/tui3/tasksection_attention_test.go index 0e0c68207d..d1491a3b4b 100644 --- a/internal/tui3/tasksection_attention_test.go +++ b/internal/tui3/tasksection_attention_test.go @@ -152,7 +152,7 @@ func TestTheRunningCountSurvivesAConversationsQuestion(t *testing.T) { // The mark on a working row is the working one. A row filed under `needs your // look` wears the steer mark ([tasksGlyph]), which is the half of this defect // a person actually sees. - rows := reading.rows(120, newPalette(tokens.NoColor, false)) + rows := tasksOpen(reading).rows(120, newPalette(tokens.NoColor, false)) line := "" for _, drawn := range rows { if strings.Contains(drawn, "read 40 filings") { diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index 0b5502bd49..5b498d91f4 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -58,7 +58,7 @@ func tasksFixture() (session.World, session.UsageWindow, time.Time) { func TestTheTasksPageGroupsByWhatYouDoNext(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now) + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, now.Add(-time.Hour), now)) rows := reading.rows(120, newPalette(tokens.NoColor, false)) page := strings.Join(rows, "\n") wants := []string{tierYourCallWord, "waiting"} @@ -96,17 +96,23 @@ func TestTheTasksPageGroupsByWhatYouDoNext(t *testing.T) { if strings.Contains(page, "adaptive") || !strings.Contains(page, "read 40 filings") { t.Fatalf("the row still spells its kind:\n%s", page) } - // AND WHAT THE WORK IS DOING OUTRANKS WHAT IT COST. The progress and the state - // are what a person acts on; the spend has a place of its own, and it used to - // be the loudest thing on the row because it is the only fact with an ink. + // AND WHAT THE WORK IS DOING IS ON THE ROW WHILE WHAT IT COST IS NOT. The + // spend used to be the loudest thing on the row because it is the only fact + // with an ink of its own; it is now a COLUMN, drawn only where a person has + // asked the list to be sorted by it (spec.md §2, [tasksKeyField]), and this + // reading is sorted by age. The progress stays, because it is the one figure + // on a running row that changes while somebody watches it. running := "" for _, line := range rows { if strings.Contains(line, "read 40 filings") { running = line } } - if at, money := strings.Index(running, "18 of 40"), strings.Index(running, "$0.92"); at < 0 || money < 0 || money < at { - t.Fatalf("the running row puts its cost ahead of its progress:\n\t%s", running) + if !strings.Contains(running, "18 of 40") { + t.Fatalf("the running row does not say where it has got to:\n\t%s", running) + } + if strings.Contains(page, "$0.92") { + t.Fatalf("a list sorted by age draws one row's money:\n%s", page) } // THE WINDOW'S EDGE IS SAID ONCE, in the sentence the page opens on. It used // to be repeated on a fold at the foot of every section, which is one number @@ -138,8 +144,8 @@ func TestNoTasksRowIsHiddenBehindAFold(t *testing.T) { }) } } - reading := readTasks(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, - tasksMine{}, session.LastDays(now, 10), tasksSort{}, time.Time{}, now) + reading := tasksOpen(readTasks(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, + tasksMine{}, session.LastDays(now, 10), tasksSort{}, time.Time{}, now)) text := strings.Join(reading.rows(120, newPalette(tokens.NoColor, false)), "\n") if strings.Contains(text, tokens.GlyphCollapsed) { t.Fatalf("a section folded rows away behind a glyph no key opens:\n%s", text) @@ -223,7 +229,7 @@ func TestTheTasksSectionsAreSeparatedByABlankLineAndNothingElse(t *testing.T) { func TestTheTasksCursorOnlyOpensTaskRows(t *testing.T) { world, win, now := tasksFixture() - reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) pal := newPalette(tokens.NoColor, false) lines := reading.lay(120) rows := reading.rows(120, pal) @@ -653,35 +659,42 @@ func TestTheTaskNameIsWholeBeforeAnyFactGetsACell(t *testing.T) { } } -// THE FACTS ARE JOINED BY ` · ` AND RANKED. `The Certificate Rotation -// incomplete now` was three separate facts a reader had to re-parse into three, -// and one row mixed a real ` · ` INSIDE a fact with bare spaces BETWEEN facts, -// so the only separator on the row meant two different things. -func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { +// A ROW IS A NAME AND TWO COLUMNS, and the columns are the same cells on every +// row of the page. +// +// IT WAS A TAIL FOR AS LONG AS THIS PAGE EXISTED — `2 files · done · 5h · $0.27`, +// a ranked prefix that gave up a fact at a time as the frame narrowed, so the +// figure under a person's eye moved sideways on every row and meant something +// different on each one. Now the state has a column and the sort key has a +// column, and everything else that used to be in that tail is in the pane or on +// the cursor's own line ([tasksReasonLine]). +// +// The ` · ` survives INSIDE a cell — `working · 18 of 40`, `done · holds 3` — +// where it joins one fact to its own qualifier, which is the only thing it ever +// meant. +func TestATaskRowIsANameAndItsTwoColumns(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, 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): 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 - // second, in front of the conversation the work came out of and in front of - // 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, "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) + if !strings.HasSuffix(wide, "done 5h") { + t.Fatalf("at 160 columns the row reads\n %s\nand it should end in the state column and the age", 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) + // THE SPEND IS NOT ON IT AT ALL. Money draws in one place on this page — the + // sort-key column, when somebody has asked to be sorted by it — and this + // reading is sorted by age. + if strings.Contains(wide, "$0.27") || strings.Contains(wide, "2 files") { + t.Fatalf("a row sorted by age drew a column nobody asked for:\n %s", wide) } - // 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) + // AND THE STATE COLUMN IS THE LAST THING THE ROW GIVES UP. Under ninety cells + // it goes and the key column stays; the name has the rest. + narrow := tasksDrawnRow(tasksPage(reading, 89), name) + if strings.Contains(narrow, "done") { + t.Fatalf("at 89 columns the state column is still drawn:\n %s", narrow) + } + if !strings.HasSuffix(narrow, "5h") { + t.Fatalf("at 89 columns the row lost the column it is sorted by:\n %s", narrow) } for _, width := range []int{120, 80} { if !strings.Contains(plain(tasksPage(reading, width)), "The Annual Toggle") { @@ -696,7 +709,7 @@ func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { // which is a fact about conversations and not about work. func TestEachTasksSectionReadsNewestFirst(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) want := []string{ "Port the picker onto the new list", "Write the morning sweep down", diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 0134b05ac0..adc0577f3a 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -105,6 +105,13 @@ func tasksColumns(room int, key tasksSortKey) (state, second, name int) { // disagree is worse than a row missing a fact ([tasksNote]). // - A SHUT FOLD SAYS WHAT IT IS HOLDING after it. A mark with no count is a // mark a person has to open to find out whether it was worth opening. +// - WORK THAT IS RUNNING RIGHT NOW SAYS WHERE IT HAS GOT TO after it +// ([session.TaskIndexEntry.Activity], `working · 18 of 40`). That figure is +// the one thing on a running row that CHANGES while somebody watches it, and +// a person watching a long run is watching for it and nothing else. It is a +// rung and not the cell: where twenty cells cannot hold both, the state word +// stays and the figure goes (rowfit.go law 2), because a row that has stopped +// saying what it IS has stopped being a row of this list. // // THE REASON IS NOT HERE. A row is a name and two facts now; WHY the work ended // as it did is the record's, and it is read in the pane beside the list or on the @@ -123,6 +130,9 @@ func tasksStateField(line tasksLine) rowField { // record's own row knows less than the node does. word = item.live.Word } + if doing := strings.TrimSpace(item.entry.Activity); doing != "" && !item.status().Settled() { + return rowSay(word+rowSep+doing, word) + } if !line.folds || line.open || line.kids <= 0 { return rowSay(word) } @@ -246,7 +256,10 @@ func tasksControlRow(query string, by tasksSort, room int, pal palette) string { boxCells := max(nameCells-ansi.StringWidth(mark)-1, 1) // WHAT IS TYPED IS IN THE READING INK AND THE INVITATION IS DIM. A person has // to be able to tell the words they typed from the words the box came with. - said, ink := fit(tasksTypeWord, boxCells), pal.dim + // The invitation is [tasksFilterHint] and not the foot's longer sentence + // because the box is narrow at every width and a placeholder with its end cut + // off reads as a bug in the box rather than as words the box came with. + said, ink := fit(tasksFilterHint, boxCells), pal.dim if query != "" { said, ink = fit(query, boxCells), pal.ink } diff --git a/internal/tui3/tassort.go b/internal/tui3/tassort.go index 71796c18d0..f2ab42745f 100644 --- a/internal/tui3/tassort.go +++ b/internal/tui3/tassort.go @@ -73,21 +73,25 @@ func (k tasksSortKey) column() tasksSortKey { return tasksByAge } -// cells is how wide that column has to be to say its key's longest answer WHOLE. +// tasksKeyCells is the sort-key column's width (spec.md §2), and it is the width +// of the widest money this page draws — `$60.11`. +const tasksKeyCells = 6 + +// cells is how wide that column is: the rule's six, WIDENED WHERE SIX WOULD CUT +// SOMETHING. // -// IT IS SIZED BY WHAT IT HOLDS AND NOT BY ONE NUMBER THAT SUITS THREE OF THE -// FIVE. A figure with its end cut off is a wrong number, which is the one thing -// rowfit.go's law forbids anywhere on this surface: the widest money this page -// draws needs six cells and `12 files` needs eight, so a single width would -// either cut a price or leave four cells of air down every frame sorted by age. +// Two things stand in these cells and both have to fit whole. The figure itself — +// and a figure with its end cut off is a wrong number, which rowfit.go's law +// forbids anywhere on this surface, so `12 files` takes the eight cells it needs +// rather than becoming `12 fil…`. And the COLUMN'S OWN LABEL on the control row +// one line above it ([tasksControlRow]), which wears the sort arrow: `files ↓` is +// seven cells and `age ↓` is five, so six is the floor that keeps the narrowest +// label and its arrow over the column they name. func (k tasksSortKey) cells() int { - switch k.column() { - case tasksByFiles: + if k.column() == tasksByFiles { return len("12 files") - case tasksByCost: - return len("$60.11") } - return len("11h") + return tasksKeyCells } // tasksRank is ONE ROW'S ANSWER TO EVERY SORT KEY AT ONCE, gathered once while From 58700c72c2e7c7e21abb23c7b51943fe3ad9daa0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:35:42 -0400 Subject: [PATCH 07/20] the table's columns stand in the same cells on every row Co-Authored-By: Claude Opus 5 --- internal/tui3/tasksplace.go | 42 +++++++++++++++----------------- internal/tui3/tasksplace_test.go | 25 +++++++++++++------ internal/tui3/taskstable.go | 29 ++++++++++++++-------- 3 files changed, 55 insertions(+), 41 deletions(-) diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index f3fb737c21..eb81dd87c9 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -1312,12 +1312,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo } return placeLead + placeHeading(fit(line.text, width-len(placeLead)), pal) case tasksLineChat: - kin := pal.dim(line.kin) - room -= ansi.StringWidth(line.kin) - if room < 1 { - room = 1 - } - return lead + kin + tasksChatRow(line, room, r.now, r.folder, r.tilde, r.order, pal, lit) + return lead + tasksChatRow(line, room, r.now, r.folder, r.tilde, r.order, pal, lit) case tasksLineTail: indent := strings.Repeat(" ", taskSheetPhoneIndent) tail := room - taskSheetPhoneIndent - ansi.StringWidth(line.kin) @@ -1326,18 +1321,18 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo } return lead + pal.dim(line.kin) + indent + placeFactInk(lit, pal)(fit(tasksCardTail(line.item, r.now), tail)) } - // THE FAMILY COLUMN IS PAINTED HERE AND CHOSEN IN THE LAYOUT. It is dim + // THE FAMILY COLUMN IS CHOSEN IN THE LAYOUT AND PAINTED BY THE ROW. It is dim // everywhere — a connector is the surface's own furniture, not the row's - // words — and the room the row gets is what is left after it. - kin := pal.dim(line.kin) - room -= ansi.StringWidth(line.kin) - if room < 1 { - room = 1 - } + // words. The TABLE spends it out of the name so its columns stand still + // ([tasksTableRow]); the phone card has no columns and takes what is left. if layoutTier(width) == tierPhone { - return lead + kin + tasksCardHead(line.item, room, pal, lit) + card := room - ansi.StringWidth(line.kin) + if card < 1 { + card = 1 + } + return lead + pal.dim(line.kin) + tasksCardHead(line.item, card, pal, lit) } - return lead + kin + tasksRow(line, room, r.now, r.order, pal, lit) + return lead + tasksRow(line, room, r.now, r.order, pal, lit) } // tasksUnderWord is what a shut fold says about the work it is holding. @@ -1638,7 +1633,11 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string if project := chatProjectWord(folderOf, folder, tilde); project != "" && project != chat.title { name += rowSep + project } - return tasksTableRow(name, tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), + // A ROOT HAS NO MARK and its name starts where the marks are, which is the + // rule (spec.md §2): a conversation is not a piece of work and has no state + // of its own to wear one for. + return tasksTableRow(pal.dim(line.kin), ansi.StringWidth(line.kin), name, + tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit) } @@ -1659,14 +1658,11 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palette, lit bool) string { item := line.item glyph, glyphInk := tasksGlyph(item, pal) - lead := glyph + " " - room := width - ansi.StringWidth(lead) - if room < 1 { - room = 1 - } - return glyphInk(glyph) + " " + tasksTableRow(tasksLabel(item.entry), + lead := pal.dim(line.kin) + glyphInk(glyph) + " " + cells := ansi.StringWidth(line.kin) + ansi.StringWidth(glyph) + 1 + return tasksTableRow(lead, cells, tasksLabel(item.entry), tasksStateField(line), tasksKeyField(by.key, line.rank, now), - tasksKeyInk(by.key, lit, pal), room, by.key, pal, lit) + tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit) } // tasksAgeField is HOW LONG AGO, and it is the fact this page's own headings diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index 5b498d91f4..e1dc787a2d 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -409,6 +409,10 @@ func tasksFamilyFixture() (session.World, session.UsageWindow, time.Time) { func TestTheTasksPageFoldsAFamilyShutAndOpensItOnDemand(t *testing.T) { world, win, now := tasksFamilyFixture() reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + // The conversation over the family is opened by hand and left that way: this + // is about the FAMILY's fold, and the root above it would otherwise hide the + // whole page ([tasksReading.opens]). + reading.open = map[tasksKey]bool{tasksChatKey("room-a"): true} shut := reading.lay(120) work := func(lines []tasksLine) []tasksLine { @@ -448,7 +452,7 @@ func TestTheTasksPageFoldsAFamilyShutAndOpensItOnDemand(t *testing.T) { // OPENED, the three workers are under it, connected, and the last one closes // the family. - reading.open = map[tasksKey]bool{tasksFamilyOf(root.item.entry): true} + reading.open = map[tasksKey]bool{tasksChatKey("room-a"): true, tasksFamilyOf(root.item.entry): true} rows = work(reading.lay(120)) if len(rows) != 5 { t.Fatalf("an open family drew %d rows of work", len(rows)) @@ -729,7 +733,9 @@ func TestEachTasksSectionReadsNewestFirst(t *testing.T) { // under it in their own order. fam, famWin, famNow := tasksFamilyFixture() family := readTasks(fam, tasksMine{}, famWin, tasksSort{}, time.Time{}, famNow) - family.open = map[tasksKey]bool{{session: "room-a", id: "1"}: true} + // The conversation and the family both open shut, and this is about the order + // INSIDE the family, so both are opened by hand. + family.open = map[tasksKey]bool{tasksChatKey("room-a"): true, {session: "room-a", id: "1"}: true} var kin []string for _, line := range family.lay(120) { if line.kind == tasksLineTask { @@ -795,14 +801,17 @@ func TestTheTasksHeadAndItsFoldsSayTheirFiguresInWords(t *testing.T) { if got, want := tasksUnderWord(1), "holds 1 more"; got != want { t.Fatalf("a fold holding one says %q, want %q", got, want) } - // AND IT IS THE FIRST THING THE TAIL SAYS, joined like every other fact. - // `3 files holds 3 more` ran two claims together with a bare space, at the - // far right of the row, eleven cells from the edge where the eye does not go. + // AND IT IS SAID IN THE STATE COLUMN, AFTER THE STATE, joined like every other + // fact. `3 files holds 3 more` ran two claims together with a bare space, at + // the far right of the row, eleven cells from the edge where the eye does not + // go; it is now `done · holds 3 more`, in the one cell that is about what the + // fold is standing over. fam, famWin, famNow := tasksFamilyFixture() shut := readTasks(fam, tasksMine{}, famWin, tasksSort{}, time.Time{}, famNow) - row := tasksDrawnRow(tasksPage(shut, 120), "port the parser") - if !strings.Contains(row, "holds 3 more · ") { - t.Fatalf("the shut family's row reads\n %s\nand its count must lead the tail: `… holds 3 more · `", row) + shut.open = map[tasksKey]bool{tasksChatKey("room-a"): true} + row := tasksDrawnRow(tasksPageFolded(shut, 120), "port the parser") + if !strings.Contains(row, "done"+rowSep+tasksUnderWord(3)) { + t.Fatalf("the shut family's row reads\n %s\nand its state cell must say what it is holding: `done · holds 3 more`", row) } } diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index adc0577f3a..3ba4fe787f 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -161,19 +161,28 @@ func tasksChatStateField(chat tasksChat) rowField { // ── the row ───────────────────────────────────────────────────────────────── -// tasksTableRow lays one row of the table out: the name in what the columns -// leave, then the two columns, then one cell of air. +// tasksTableRow lays one row of the table out: the row's lead — its family +// connectors and its mark — then the name in what the columns leave, then the +// two columns, then one cell of air. // -// EVERY ROW OF ONE FRAME ANSWERS THE SAME TWO QUESTIONS. That is the whole -// difference from the tail it replaces — the columns are in the same cells on -// every row, so the eye reads DOWN a column instead of re-parsing each row's own -// ranked prefix, and a row with nothing to say in a column draws nothing there -// rather than pulling the next fact leftwards into the hole. -func tasksTableRow(name string, state, second rowField, secondInk func(string) string, - room int, key tasksSortKey, pal palette, lit bool) string { +// EVERY ROW OF ONE FRAME ANSWERS THE SAME TWO QUESTIONS IN THE SAME CELLS. That +// is the whole difference from the tail it replaces — the eye reads DOWN a +// column instead of re-parsing each row's own ranked prefix, and a row with +// nothing to say in a column draws nothing there rather than pulling the next +// fact leftwards into the hole. +// +// WHICH IS WHY THE LEAD IS SPENT OUT OF THE NAME AND NOT OUT OF THE ROOM. The +// connectors are two cells wider three levels down a family and a root has no +// mark at all, so a row that measured its columns from what its own lead left +// would put them in a different place on every line of the page — a table whose +// columns move is a tail with extra steps. Only the NAME flexes (rowfit.go law +// 1), and the lead eats into the name. +func tasksTableRow(lead string, leadCells int, name string, state, second rowField, + secondInk func(string) string, room int, key tasksSortKey, pal palette, lit bool) string { stateCells, secondCells, nameCells := tasksColumns(room, key) + nameCells = max(nameCells-leadCells, 1) said := fit(name, nameCells) - out := placeSubject(said, lit, pal) + pad(nameCells-ansi.StringWidth(said)) + out := lead + placeSubject(said, lit, pal) + pad(nameCells-ansi.StringWidth(said)) if stateCells > 0 { word := rowTail([]rowField{state}, stateCells) out += placeFactInk(lit, pal)(word) + pad(stateCells-ansi.StringWidth(word)) From a626957f42899293ffd793ca62a39a64908a1725 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:39:37 -0400 Subject: [PATCH 08/20] wip: a root counts every row it hides, and the foot names both page keys Co-Authored-By: Claude Opus 5 --- internal/tui3/home_test.go | 13 ++++++++----- internal/tui3/place_tasks.go | 31 ++++++++++++++++++------------ internal/tui3/place_tasks_test.go | 21 +++++++++++++------- internal/tui3/placehost_test.go | 1 + internal/tui3/tallyrows_test.go | 6 ++++-- internal/tui3/taskcardhost_test.go | 4 ++++ internal/tui3/tasksplace.go | 15 ++++++++++++--- internal/tui3/taskstable.go | 4 ++-- 8 files changed, 64 insertions(+), 31 deletions(-) diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index dc9c899a4c..a492aa4426 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -3530,10 +3530,11 @@ func TestSpaceInTheTaskRoomPagesTheCardAndDoesNotOpenHome(t *testing.T) { ID: "1", Name: "port-the-thing", Label: "Port the thing", Title: "Port the thing", Status: string(session.TaskDone), SessionID: "aaaa000000000001", }) - a, box := driveToPlace(t, lab, pageTasks) - if box == nil { - t.Fatal("the tasks place has no box to type into") - } + a, _ := driveToPlace(t, lab, pageTasks) + // THE FILTER IS NOT THE COMPOSER ANY MORE. [placeTasks.box] returns nil and + // the letters draw on the control row at the top of the list, so what a + // person typed is read off the place's own editor. + box := &a.taskSheet.query // THE DOOR HAS TO EXIST FOR THIS TEST TO MEAN ANYTHING, the same insistence // [TestTheDoorStaysShutOverTheMemoryCardEditor] makes and for its reason: a // fixture whose door went dark would pass this however wrong the fix was. @@ -3547,7 +3548,9 @@ func TestSpaceInTheTaskRoomPagesTheCardAndDoesNotOpenHome(t *testing.T) { if got := box.String(); got != " " { t.Fatalf("the first space did not land in the filter: %q", got) } - drive(t, a, key("down")) // the child task below its main chat + // `→` opens the conversation the work is under — every fold on this page + // opens shut now ([tasksReading.opens]) — and the cursor walks to the work. + tasksPointAt(t, a, "Port the thing") drive(t, a, key("enter")) // into the room, over the roster if !a.taskSheet.detailOn { t.Fatalf("the record did not open; frame:\n%s", plain(frame(a))) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 398c5e1f04..3b580a1e3b 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1502,21 +1502,28 @@ func (p *tasksPlace) hint(a *app) string { } parts = append(parts, tasksVerbsWord+strings.Join(words, tasksVerbGap)) } - // AND THE FILTER IS NAMED ONCE, IN THE SLOT IT IS ABOUT. The foot used to - // carry `type to filter this list` at rest, because the box two rows under it - // was saying `say what you want done` and somebody had to correct it. The box - // says the true sentence itself now ([placeTasks.resting]), so repeating it - // here would be the frame naming one thing twice on one screen — the defect - // this page's own title row was removed for. What the foot keeps is the fact - // the box CANNOT show: that while a filter is on, esc means the filter. + // AND LAST, THE TWO THINGS THE KEYBOARD DOES TO THE WHOLE PAGE. They are last + // because every clause before them is about the row under the cursor and + // these two are about the page; they are here AT ALL because of the ruling of + // 2026-09-11, which is worth stating in full. + // + // THE FILTER OWNS EVERY PRINTABLE KEY ON THIS PAGE, so sorting cannot be `s` + // — a bare `s` would cost `sweep`, `stop` and `site` — and it is `alt+s`. A + // chord nobody can find is a chord that does not exist, so the foot names it + // and names the key it is on. And the other half has to be named beside it: + // the control row draws the box and the arrow, but nothing on the frame says + // that a letter goes INTO that box rather than to the page's own keys. + // + // WHILE A FILTER IS ON, THE SECOND CLAUSE IS THE ONE THAT MOVED — that esc + // now means the filter and not the page, which is the one fact the box + // itself cannot show — and inviting somebody to type a filter they have + // already typed would be the frame naming one thing twice on one screen. + parts = append(parts, tasksSortHint(a.taskSheet.order)) if a.taskSheetFiltering() { parts = append(parts, tasksClearFilterWord) + } else { + parts = append(parts, tasksFilterHint) } - // AND THE SORT, LAST, because it is the one clause that is true of the PAGE - // rather than of the row under the cursor — and because it names the key the - // page is on, which is the only thing about the order the control row does not - // already draw ([tasksControlRow] wears the arrow). - parts = append(parts, tasksSortHint(a.taskSheet.order), tasksFilterHint) return strings.Join(parts, railSep) } diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index d6e2387535..56984f07b9 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -49,11 +49,12 @@ func TestTheTasksFootIsScreenOneEWordForWord(t *testing.T) { if !ok || item.entry.Label != "Fix the nil-map crash" { t.Fatalf("the cursor is not on this window's running task: %+v", item.entry) } - // AND THE FILTER CLAUSE IS NOT ON IT ANY MORE. It was here to correct the box - // two rows below, which said `say what you want done` over a slot that only - // ever filtered; the box says the true sentence itself now - // ([placeTasks.resting]), and one screen may not name one thing twice. - const want = "enter open its room · → verbs: stop it" + // AND THE LAST TWO CLAUSES ARE THE PAGE'S OWN KEYS, which the ruling of + // 2026-09-11 asks for by name: the filter owns every printable key here, so + // sorting is a chord, and a chord nobody can find is a chord that does not + // exist. The filter is named beside it because nothing else on the frame says + // that a letter goes into the box on the control row rather than to the page. + const want = "enter open its room · → verbs: stop it · alt+s sort: age · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } @@ -103,7 +104,7 @@ func TestTheTasksFootSaysOnlyWhatIsTrueOfTheRowUnderIt(t *testing.T) { if !ok || item.entry.Title != "Port the parser" { t.Fatalf("the walk did not reach the earlier conversation's row: %+v", item) } - const want = "enter go inside it" + const want = "enter go inside it · alt+s sort: age · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("over work another conversation ran the foot reads\n %q\nwant\n %q", got, want) } @@ -209,10 +210,16 @@ func TestTheTasksFootNamesNoVerbWithoutTheEnginesDoor(t *testing.T) { if verbs := a.taskSheet.verbs(a); len(verbs) != 0 { t.Fatalf("a session with no cancel door offered %+v", verbs) } - const want = "enter open its room · → what ran under it" + // The cursor's row is a family, and [openTaskPlaceWithRows] has opened it — + // so the fold clause is the `←` half. What this test is about is what is NOT + // here: no verb at all, on a session with no door onto stopping. + const want = "enter open its room · ← fold it back up · alt+s sort: age · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } + if strings.Contains(a.taskSheetKeysLine(), tasksVerbsWord) { + t.Fatalf("a session with no cancel door named a verb: %q", a.taskSheetKeysLine()) + } } // `s` ON THE STRIP ENDS THE WORK, through the door the card uses and with the diff --git a/internal/tui3/placehost_test.go b/internal/tui3/placehost_test.go index b0d62be700..985b156df7 100644 --- a/internal/tui3/placehost_test.go +++ b/internal/tui3/placehost_test.go @@ -152,6 +152,7 @@ func TestTheTasksPlaceOverHostDrawsTheFarMachinesWork(t *testing.T) { }, true } a.showPage(pageTasks) + openTaskFolds(a) text := placeText(a) if !strings.Contains(text, "widening the pipe") { t.Fatalf("the tasks place did not draw the far machine's work:\n%s", text) diff --git a/internal/tui3/tallyrows_test.go b/internal/tui3/tallyrows_test.go index 1f536fa759..b0dbd909ea 100644 --- a/internal/tui3/tallyrows_test.go +++ b/internal/tui3/tallyrows_test.go @@ -72,7 +72,7 @@ func TestTheTasksSectionHeadNamesOnlyWhatTheFoldHolds(t *testing.T) { // Open the fold: the rows catch up with the count and the clause goes, // because there is nothing left for it to say. - reading.open = map[tasksKey]bool{{session: "room-a", id: "1"}: true} + reading.open = map[tasksKey]bool{tasksChatKey("room-a"): true, {session: "room-a", id: "1"}: true} lines = reading.lay(120) frame = strings.Join(reading.rows(120, palette{}), "\n") if drawn := tasksWorkRows(lines); drawn != held { @@ -151,7 +151,9 @@ func TestAMixedPageHeadsSectionsOnlyWithTheirFoldedRows(t *testing.T) { // A PAGE THAT FOLDS NOTHING SAYS NOTHING EXTRA, on the heading or on the foot. func TestASectionWithNothingFoldedAwaySaysNothingExtra(t *testing.T) { world, win, now := tasksPolishFixture() - reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + // The page after `→`: what this is about is a page with NOTHING held back, + // and every conversation opens shut ([tasksReading.opens]). + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) lines := reading.lay(120) for _, line := range lines { if line.kind == tasksLineWord && strings.Contains(line.text, " shown") { diff --git a/internal/tui3/taskcardhost_test.go b/internal/tui3/taskcardhost_test.go index 82943e795c..db16f54693 100644 --- a/internal/tui3/taskcardhost_test.go +++ b/internal/tui3/taskcardhost_test.go @@ -72,6 +72,9 @@ func farCardLab(t *testing.T, answer func(uri string) (session.TaskRecord, error // delivers whatever the reading answered. func pressFarCard(t *testing.T, a *app) { t.Helper() + // The work is behind its conversation's fold, which opens shut; a person gets + // there with `→` and so does this ([openTaskFolds]). + openTaskFolds(a) width, height := a.size() _, hits, _, _ := a.taskSheetFrame(width, height) row := -1 @@ -316,6 +319,7 @@ func TestAHostedTaskCardSaysItIsStillReading(t *testing.T) { a, _ := farCardLab(t, func(string) (session.TaskRecord, error) { return session.TaskRecord{}, nil }) + openTaskFolds(a) width, height := a.size() _, hits, _, _ := a.taskSheetFrame(width, height) for y, hit := range hits { diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index eb81dd87c9..1a3af97921 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -842,9 +842,18 @@ type tasksChat struct { row session.SessionRow title string // kids is how many rows OPENING THIS ONE puts on the page, which is what the - // shut fold says out loud. Work nested under those is behind their own folds - // and is counted by the section's heading instead ([tasksSectionHead]). + // fold arithmetic is about: the rows immediately under it, each of which may + // be holding a family behind its own fold. kids int + // whole is how many pieces of work are under it ALTOGETHER, at every depth, + // and it is what the row SAYS ([tasksChatStateField]). + // + // IT IS THE COUNT AND NOT THE KIDS BECAUSE IT IS A CLAIM ABOUT WHAT IS + // HIDDEN, not about what one key press reveals. A root over one task with + // four workers under it saying `1 done` beside a heading that says `5 folded + // away` is two numbers about the same rows, and the smaller one is the one a + // person reads as the size of the thing they are deciding whether to open. + whole int // at is the newest thing in it THAT ANYBODY CAN DATE, and the zero time where // nothing can be. It is deliberately not the stamp the conversation SORTS by: // live work is dated `now` for ranking and says nothing at all about when @@ -1122,7 +1131,7 @@ func tasksTreeOf(items []tasksItem, now time.Time, order tasksSort, chats ...ses sort.SliceStable(g.roots, func(a, b int) bool { return t.sort.key.less(t.rank[tasksKeyOf(g.roots[a].entry)], t.rank[tasksKeyOf(g.roots[b].entry)], t.sort.back) }) - g.chat.kids = len(g.roots) + g.chat.kids, g.chat.whole = len(g.roots), g.held for _, root := range g.roots { t.under(root, func(item tasksItem, _ int) { t.filed[tasksKeyOf(item.entry)] = g.section }) } diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 3ba4fe787f..33f00554e1 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -153,10 +153,10 @@ func tasksHoldsShort(kids int) string { return "holds " + itoa(kids) } // raises. `5 your call` is five rows and at least one of them wants somebody; // `9 done` is nine rows and nothing to do. func tasksChatStateField(chat tasksChat) rowField { - if chat.kids <= 0 || strings.TrimSpace(chat.word) == "" { + if chat.whole <= 0 || strings.TrimSpace(chat.word) == "" { return rowSay() } - return rowSay(itoa(chat.kids)+" "+chat.word, itoa(chat.kids)) + return rowSay(itoa(chat.whole)+" "+chat.word, itoa(chat.whole)) } // ── the row ───────────────────────────────────────────────────────────────── From 4a4a80d747600b37b2ab49c58ab96d68a0fd8f72 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:45:13 -0400 Subject: [PATCH 09/20] wip: the grown line costs the list a row and the frame none; fixtures reach the work Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 15 +++++++++++---- internal/tui3/tasknavigation_qa_test.go | 4 +++- internal/tui3/taskpagelive_test.go | 15 +++++++++------ internal/tui3/tasksplacewords_test.go | 14 +++++++++++++- internal/tui3/taskview_test.go | 16 ++++++---------- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 3b580a1e3b..e790178c4a 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1301,11 +1301,17 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { if item, ok := r.at(lines, p.cursor); ok && tasksReasonShowing(a) { grown = tasksReasonLine(item, width-len(tasksBareLead)-taskSheetPhoneIndent, a.pal) } - if grown != "" && room > 1 { - room-- + // The grown line costs the LIST a row and costs the FRAME nothing: the window + // holds one line less of the record, and the row it gives up is spent on the + // line under the cursor. Counting it against one budget twice returned a + // short body and left a blank line under the foot. + listRoom := room + if grown != "" && listRoom > 1 { + listRoom-- } - p.top = tasksTop(lines, p.cursor, p.top, room) + p.top = tasksTop(lines, p.cursor, p.top, listRoom) + drawn := 0 rows := make([]placeRow, 0, room) // bare records, per drawn line, whether that line is wearing neither the // cursor's band nor the pointer's — which is the one thing the depth fade @@ -1316,10 +1322,11 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { var bare []bool more := false for at := p.top; at < len(lines); at++ { - if len(rows) >= room { + if drawn >= listRoom { more = true break } + drawn++ hit, lit := taskSheetHit{}, false if lines[at].kind == tasksLineControl { hit = taskSheetHit{kind: taskSheetHitControl} diff --git a/internal/tui3/tasknavigation_qa_test.go b/internal/tui3/tasknavigation_qa_test.go index 9fe4cfd9b5..cc50cd9f2d 100644 --- a/internal/tui3/tasknavigation_qa_test.go +++ b/internal/tui3/tasknavigation_qa_test.go @@ -30,7 +30,9 @@ func TestTaskShortcutRemainsClickableWhenTheLastWorkerNeedsAttention(t *testing. if !a.at(pageTasks) { t.Fatal("phone shortcut did not open Tasks") } - drive(t, a, key("down"), key("enter")) + // `→` opens the conversation the work is under — every fold on + // this page opens shut now — and `enter` goes into the row. + drive(t, a, key("right"), key("down"), key("enter")) } if !a.roomOpen() || a.room.id != 7 || !strings.Contains(roomText(a), "Checking the parser now") { t.Fatal("click did not reach the task transcript") diff --git a/internal/tui3/taskpagelive_test.go b/internal/tui3/taskpagelive_test.go index 9f5cf8015b..93ecd92f01 100644 --- a/internal/tui3/taskpagelive_test.go +++ b/internal/tui3/taskpagelive_test.go @@ -94,22 +94,25 @@ func TestTheTaskPageCursorFollowsItsRowWhenAnotherTaskLands(t *testing.T) { a.taskSheet.regroup(a) a.taskSheet.cursor = a.tasksSettle(0) for i := 0; i < 20; i++ { - if item, ok := a.taskSheetCurrent(); ok && item.entry.Label == "reading the gauge" { + if item, ok := a.taskSheetCurrent(); ok && item.entry.Label == "widening the sluice" { break } a.taskSheetMove(1) } item, ok := a.taskSheetCurrent() - if !ok || item.entry.Label != "reading the gauge" { + if !ok || item.entry.Label != "widening the sluice" { t.Fatalf("the walk never reached the second running row: %+v", item.entry) } was := a.taskSheet.cursor - // The row ABOVE it lands, which re-files it into another section. + // The row ABOVE it lands, which re-files it into another section. WHICH ROW IS + // ABOVE IS THE SORT'S ANSWER and not the order they arrived in: the list is + // sorted by age, newest first, so the gauge — asked for second — is the row + // over the sluice (tassort.go). a.taskUpdate(session.Event{ Kind: session.EventTaskUpdate, Tool: "propose_task", - Task: &session.TaskNotice{ID: 41, Title: "widening the sluice", State: session.TaskDone}, + Task: &session.TaskNotice{ID: 42, Title: "reading the gauge", State: session.TaskDone}, }) a.taskSheet.regroup(a) @@ -117,9 +120,9 @@ func TestTheTaskPageCursorFollowsItsRowWhenAnotherTaskLands(t *testing.T) { if !ok { t.Fatalf("the cursor came off the page entirely:\n%s", pageRows(t, a)) } - if now.entry.Label != "reading the gauge" { + if now.entry.Label != "widening the sluice" { t.Fatalf("the cursor moved from %q to %q while a different task landed:\n%s", - "reading the gauge", now.entry.Label, pageRows(t, a)) + "widening the sluice", now.entry.Label, pageRows(t, a)) } // AND IT REALLY MOVED, so the assertion above is about the row being followed // and not about a list that happened to stay still. diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go index 89c2a589a1..ef7c06c92d 100644 --- a/internal/tui3/tasksplacewords_test.go +++ b/internal/tui3/tasksplacewords_test.go @@ -169,12 +169,24 @@ func TestTheStateWordIsTheLastThingARowGivesUp(t *testing.T) { }, }} { word := taskStateWord(tc.item.entry, tc.item.runs) - for _, width := range []int{120, 100, 90, 85, 80} { + // DOWN TO THE FLOOR THE COLUMN HAS, and no further. The state is a COLUMN + // now (spec.md §2) and a column is all of the page or none of it: at + // ninety cells it is drawn on every row, and under ninety it goes from + // every row at once and the sort key keeps its own cells. What a narrow + // frame gives a person instead is the CURSOR's row, which grows the state + // and the reason under it ([tasksReasonLine]) — one row said whole rather + // than twenty rows each missing the same word. + for _, width := range []int{120, 100, tasksStateFloor} { row := plain(tasksRow(tasksLine{kind: tasksLineTask, item: tc.item}, width, now, tasksSort{}, 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) } } + under := plain(tasksReasonLine(tc.item, tasksStateFloor-1, pal)) + if !strings.Contains(under, word) { + t.Fatalf("under %d columns %s says %q on no line at all, and the cursor's own line is where it goes:\n %s", + tasksStateFloor, tc.why, word, under) + } } } diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index f0a047fb9c..dfa0c904f1 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -266,11 +266,9 @@ func TestTheTaskPageDrawsThisWindowsWorkBesideEveryOtherConversations(t *testing if !openTaskPlaceWithRows(a) { t.Fatal("the page refused to open on a session with work in it") } - // Content assertions inspect expanded families; opening the page keeps them folded. - a.taskSheet.opened = make(map[tasksKey]bool) - for _, item := range a.taskSheet.reading.items { - a.taskSheet.opened[tasksKeyOf(item.entry)] = true - } + // Content assertions inspect expanded families AND the conversations standing + // over them; every fold on this page opens shut ([openTaskFolds]). + openTaskFolds(a) text := taskSheetText(a) // THIS WINDOW'S OWN LIVE WORK IS ON THE PAGE. An ordinary task writes no row @@ -365,11 +363,9 @@ func TestTheTaskPageDoesNotRepeatWorkTheTreeIsAlreadyShowing(t *testing.T) { if !openTaskPlaceWithRows(a) { t.Fatal("the page refused to open") } - // Content assertions inspect expanded families; opening the page keeps them folded. - a.taskSheet.opened = make(map[tasksKey]bool) - for _, item := range a.taskSheet.reading.items { - a.taskSheet.opened[tasksKeyOf(item.entry)] = true - } + // Content assertions inspect expanded families AND the conversations standing + // over them; every fold on this page opens shut ([openTaskFolds]). + openTaskFolds(a) text := taskSheetText(a) if n := strings.Count(text, "Write the tree"); n != 1 { t.Fatalf("the running node is drawn %d times, want once:\n%s", n, text) From dc1e2fb66017fcb038811ddacdd312a31a69bd2f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:48:02 -0400 Subject: [PATCH 10/20] wip: the filter's words are on the control row and the note line is only its refusal Co-Authored-By: Claude Opus 5 --- internal/tui3/taskview.go | 15 ++++++++++----- internal/tui3/taskview_test.go | 5 ++++- internal/tui3/verbstrip_test.go | 3 +++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 1bb07b501c..99e51a9a5e 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -281,11 +281,16 @@ const ( // and closes the place second ([app.taskSheetKeyPress]) — and the clause it // replaces would be teaching a person to do the thing they are already doing. tasksClearFilterWord = "esc clear the filter" - // taskSheetFilterWord opens the line that says what was typed, and - // taskSheetFilterNone is what that line adds when the query has taken every - // row off the page. A filtered page with nothing on it and nothing said is a - // page a person reads as broken. - taskSheetFilterWord = "filter · " + // taskSheetFilterNone is what the note line says when the query has taken + // every row off the page. A filtered page with nothing on it and nothing said + // is a page a person reads as broken. + // + // IT IS ALL THAT LINE SAYS NOW. It used to open `filter · `, + // because the box a person typed into was two rows below it and said nothing + // about narrowing anything — so their own words were echoed back UNDER the + // rows their keystrokes had just changed. The words are on the control row at + // the top of the list now ([tasksControlRow]), where the typing lands, and an + // echo under the list would be the frame saying one thing twice. taskSheetFilterNone = " · nothing matches" // taskSheetMoreHint is the line at the bottom of the ROSTER'S COLUMN that // reaches this page (task.go's [app.railFootRows]). It is shaped like the two diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index dfa0c904f1..446b8adbb9 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/Agent-Field/aforge-v2/internal/session" + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" ) // ── THE TASKS PLACE ───────────────────────────────────────────────────────── @@ -865,7 +866,9 @@ func TestTypingOnTheTaskPageFiltersBothSections(t *testing.T) { for _, want := range []string{ taskSheetNowHead, "Ship the port", "finished today", "Port the parser", - taskSheetFilterWord + "port", + // AND THE WORDS ARE ON THE CONTROL ROW, at the top of the list, where the + // typing lands — not echoed on a note line under the rows they changed. + a.pal.glyph(tokens.GFilter) + " port", } { if !strings.Contains(text, want) { t.Fatalf("the filtered page is missing %q:\n%s", want, text) diff --git a/internal/tui3/verbstrip_test.go b/internal/tui3/verbstrip_test.go index f232153d74..001d459025 100644 --- a/internal/tui3/verbstrip_test.go +++ b/internal/tui3/verbstrip_test.go @@ -31,6 +31,9 @@ func stripWalkLab(t *testing.T) (*app, *stopFake) { drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "Count the tabs", session.TaskRunning, session.TaskNotice{})}) a.showPage(pageTasks) + // The two nodes above landed after the place was opened, so they are behind + // their conversation's fold — which opens shut ([openTaskFolds]). + openTaskFolds(a) stops := a.taskSheet.stops(a) if len(stops) < 3 { t.Fatalf("the lab drew %d rows, want three that can be stopped", len(stops)) From f04669d319ee74c669bc853080392d6ccce61de9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:52:38 -0400 Subject: [PATCH 11/20] the table's own tests, and alt+s bound where the router can reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sort chord was written into the place's key switch, which the router's alt+ class swallows before the place is asked — so it was dead in the product and the test that pressed it is what found that. Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 20 +- internal/tui3/taskstable_test.go | 439 +++++++++++++++++++++++++++++++ internal/tui3/tui3_test.go | 6 + 3 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 internal/tui3/taskstable_test.go diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index e790178c4a..bae0b684cd 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -877,8 +877,6 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { a.taskSheetMove(len(a.taskSheet.stops(a))) case "enter": return a.taskSheetEnter(), true - case tasksSortKeyChord: - a.taskSheetSortBy(a.taskSheet.order.key.next()) case tasksSortBackChord: // THE SAME KEY AGAIN REVERSES ([tasksSort.on]), which is what a person // means by this chord: not "the previous key" but "the other way round". @@ -1761,6 +1759,24 @@ func (placeTasks) box(a *app) *editor { return nil } // instruction, over a box that cannot take one. The words are the FOOT's, moved // into the slot they are about: one sentence, in the place a person is looking // when they wonder what typing here will do. +// alt is `alt+s`: WHICH COLUMN THIS LIST IS ORDERED BY, one key at a time. +// +// IT IS HERE AND NOT IN [app.taskSheetKeyPress] BECAUSE THE ROUTER OWNS THE +// CLASS. `alt+` means "change how THIS place is shown" on every place +// (placekeys.go), and it SWALLOWS an undeclared letter rather than passing it +// down — so an arm written in this place's own key switch would never be +// reached. Which is also the argument for the binding: a sort IS a view, the +// memory place already walks its shelves with the same chord, and the ruling of +// 2026-09-11 settled that it cannot be a bare `s` because the filter here owns +// every printable key. +func (placeTasks) alt(a *app, letter rune) bool { + if letter != 's' { + return false + } + a.taskSheetSortBy(a.taskSheet.order.key.next()) + return true +} + func (placeTasks) resting(a *app) string { return tasksTypeWord } // tasksFilterHint is the short spelling of that invitation. It is the control diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go new file mode 100644 index 0000000000..a4a2bb200c --- /dev/null +++ b/internal/tui3/taskstable_test.go @@ -0,0 +1,439 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/session" + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// tasksTableFixture is a record with something in every column: three +// conversations, a family, money that ranges over two orders of magnitude, a row +// that cost nothing anybody recorded, and one piece of work nobody could check. +// +// IT IS BUILT FOR THE SORT. Two of its conversations stand in the SAME section +// so that the order of the roots is a claim about their totals rather than about +// their headings, and the rows inside one of them have three different prices so +// that descending is distinguishable from arriving. +func tasksTableFixture() (session.World, session.UsageWindow, time.Time) { + loc := time.FixedZone("fixture", -4*60*60) + now := time.Date(2026, time.September, 11, 12, 0, 0, 0, loc) + ago := func(d time.Duration) time.Time { return now.Add(-d) } + work := func(room, id, label, status string, ended time.Time, files int, cost float64) session.TaskIndexEntry { + return session.TaskIndexEntry{ + ID: id, Label: label, Title: label, SessionID: room, Status: status, + EndedAt: ended, FilesChanged: files, Cost: cost, + } + } + first := session.SessionRow{ID: "room-a", Title: "the pricing page", Project: "aforge", Open: true} + first.Tasks.Rows = []session.TaskIndexEntry{ + work("room-a", "1", "put the annual toggle up", string(session.TaskDone), ago(time.Hour), 2, 1.50), + work("room-a", "2", "pages one two", string(session.TaskDone), ago(2*time.Hour), 1, .25), + work("room-a", "3", "check the copy", string(session.TaskUnverified), ago(3*time.Hour), 0, 0), + } + second := session.SessionRow{ID: "room-b", Title: "thor clips", Project: "media", Open: true} + second.Tasks.Rows = []session.TaskIndexEntry{ + work("room-b", "1", "render the fight clip", string(session.TaskDone), ago(5*time.Hour), 12, .10), + } + third := session.SessionRow{ID: "room-c", Title: "the corpus sweep", Project: "aforge", Open: true} + third.Tasks.Rows = []session.TaskIndexEntry{ + work("room-c", "1", "sweep the corpus", string(session.TaskDone), ago(4*time.Hour), 3, 4.20), + work("room-c", "2", "count the tokens", string(session.TaskDone), ago(6*time.Hour), 1, .60), + } + third.Tasks.Rows[1].Parent = "1" + world := session.World{Projects: []session.Project{ + {Name: "aforge", Sessions: []session.SessionRow{first, third}}, + {Name: "media", Sessions: []session.SessionRow{second}}, + }, Read: now} + return world, session.LastDays(now, 7), now +} + +// tasksTableApp is that record on the page, with the folds where the place puts +// them and the cursor settled. +func tasksTableApp(t *testing.T) *app { + t.Helper() + world, win, now := tasksTableFixture() + a := &app{pal: newPalette(tokens.NoColor, false)} + a.width, a.height = 122, 40 + a.clock = func() time.Time { return now } + a.raisePlace(pageTasks) + a.taskSheet.world = world + a.taskSheet.reading = readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + a.taskSheet.awayAt, a.taskSheet.mineAt = a.elsewhere().Read, a.railStamp + a.taskSheet.cursor = a.tasksSettle(0) + return a +} + +// tasksWorkLines is every row of WORK on the laid-out page, in the order it is +// drawn — which is the order the sort is a claim about. +func tasksWorkLines(lines []tasksLine) []tasksLine { + var out []tasksLine + for _, line := range lines { + if line.kind == tasksLineTask { + out = append(out, line) + } + } + return out +} + +// ── the columns ───────────────────────────────────────────────────────────── + +// THE COLUMNS ARE FIXED AT NINETY CELLS AND OVER, AND THE STATE GOES FIRST UNDER +// IT. spec.md §2: the fold, the mark, the name, `state` at twenty cells, the +// sort key's own cells right-aligned, and one cell of air. Only the name flexes +// (rowfit.go law 1), so the two figures a person is comparing down the page are +// in the same cells on every row of one frame. +func TestTheTasksTableHoldsItsColumnsToTheCellAtEveryWidth(t *testing.T) { + world, win, now := tasksTableFixture() + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) + for _, width := range []int{122, 100, 90} { + state, second, name := tasksColumns(width, tasksByAge) + if state != tasksStateCells || second != tasksKeyCells { + t.Fatalf("at %d cells the columns came out state=%d key=%d, want %d and %d", + width, state, second, tasksStateCells, tasksKeyCells) + } + if want := width - state - second - tasksColumnAir; name != want { + t.Fatalf("at %d cells the name took %d and the columns left %d", width, name, want) + } + // AND THE PAGE REALLY DRAWS THEM: every row of work says its state word. + for _, line := range tasksWorkLines(reading.lay(width)) { + row := plain(tasksRow(line, width, now, tasksSort{}, newPalette(tokens.NoColor, false), false)) + if word := taskStateWord(line.item.entry, line.item.runs); !strings.Contains(row, word) { + t.Fatalf("at %d cells a row gave up its state:\n %s", width, strings.TrimRight(row, " ")) + } + } + } + // UNDER NINETY THE STATE COLUMN GOES AND THE KEY COLUMN STAYS. The state is + // the wider of the two and the one a narrow frame can put somewhere else — + // the cursor's own line grows it ([tasksReasonLine]) — while a figure has + // nowhere else on the page to be. + for _, width := range []int{89, 60} { + state, second, name := tasksColumns(width, tasksByAge) + if state != 0 { + t.Fatalf("at %d cells the state column is still %d cells wide", width, state) + } + if second != tasksKeyCells { + t.Fatalf("at %d cells the sort key's column came out %d cells", width, second) + } + if name != width-second-tasksColumnAir { + t.Fatalf("at %d cells the name took %d of the %d left to it", width, name, width-second-tasksColumnAir) + } + } +} + +// THE STATE CELL IS NEVER BLANK ON A ROW OF WORK. It is the one thing this list +// is read for, and a hole in that column is a row a person has to open to find +// out whether it wants them. +func TestEveryRowOfWorkSaysWhatItIs(t *testing.T) { + world, win, now := tasksTableFixture() + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) + for _, line := range tasksWorkLines(reading.lay(122)) { + if got := rowTail([]rowField{tasksStateField(line)}, tasksStateCells); strings.TrimSpace(got) == "" { + t.Fatalf("%q drew nothing in the state column", tasksLabel(line.item.entry)) + } + } + // AND A SHUT ROOT'S CELL IS ITS COUNT, which is the question a shut fold + // raises: how much is behind this, and does any of it want me. + for _, line := range reading.lay(122) { + if line.kind != tasksLineChat { + continue + } + got := rowTail([]rowField{tasksChatStateField(line.chat)}, tasksStateCells) + if strings.TrimSpace(got) == "" { + t.Fatalf("the conversation %q drew nothing in the state column", line.chat.title) + } + if !strings.HasPrefix(strings.TrimSpace(got), itoa(line.chat.whole)+" ") { + t.Fatalf("the conversation %q says %q and it is holding %d pieces of work", + line.chat.title, strings.TrimSpace(got), line.chat.whole) + } + } +} + +// A SHUT ROOT'S COUNT IS THE ROWS IT IS HIDING, at every depth of what is under +// it. A root over one task with a worker beneath it saying `1 done` beside a +// heading saying `2 folded away` is two numbers about the same rows. +func TestAShutRootCountsEveryRowItHides(t *testing.T) { + world, win, now := tasksTableFixture() + reading := readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now) + lines := reading.lay(122) + if work := tasksWorkLines(lines); len(work) != 0 { + t.Fatalf("a fresh reading drew %d rows of work, and every conversation opens shut", len(work)) + } + for _, line := range lines { + if line.kind != tasksLineChat { + continue + } + // Open this one alone and count what appeared. + one := reading + one.open = map[tasksKey]bool{line.family: true} + under := 0 + for _, kid := range one.lay(122) { + if kid.kind == tasksLineTask && kid.item.entry.SessionID == line.chat.row.ID { + under++ + } + } + // `→` opens one level; the families under it stay shut, and the count is + // still the whole of what the root is standing over. + deep := reading + deep.unfolded = true + whole := 0 + for _, kid := range deep.lay(122) { + if kid.kind == tasksLineTask && kid.item.entry.SessionID == line.chat.row.ID { + whole++ + } + } + if line.chat.whole != whole { + t.Fatalf("%q says it holds %d and opening it whole draws %d (one press draws %d)", + line.chat.title, line.chat.whole, whole, under) + } + } +} + +// ── the sort ──────────────────────────────────────────────────────────────── + +// SORTING BY COST ORDERS EVERY LEVEL OF THE TREE BY MONEY AND FLATTENS NONE OF +// IT: the work inside a conversation descends, the conversations descend by +// their totals inside their own section, a row nobody priced sinks to the bottom +// of its group, the second column shows the money, and the label wears the +// arrow. Asking for the same key again turns the whole thing round. +func TestSortingByCostOrdersEveryLevelAndSaysSo(t *testing.T) { + world, win, now := tasksTableFixture() + by := tasksSort{key: tasksByCost} + reading := tasksOpen(readTasks(world, tasksMine{}, win, by, time.Time{}, now)) + + // INSIDE ONE CONVERSATION, DEAREST FIRST, AND THE UNPRICED ROW LAST. + inside := []string{} + for _, line := range tasksWorkLines(reading.lay(122)) { + if line.item.entry.SessionID == "room-a" { + inside = append(inside, tasksLabel(line.item.entry)) + } + } + want := []string{"put the annual toggle up", "pages one two", "check the copy"} + if strings.Join(inside, " / ") != strings.Join(want, " / ") { + t.Fatalf("sorted by cost one conversation reads\n %s\nwant\n %s", + strings.Join(inside, " / "), strings.Join(want, " / ")) + } + + // AND THE CONVERSATIONS INSIDE ONE SECTION GO BY THEIR TOTALS. The sweep cost + // $4.80 altogether and the clips cost ten cents, and both finished today. + roots := []string{} + for _, line := range reading.lay(122) { + if line.kind == tasksLineChat && line.chat.state == tasksToday { + roots = append(roots, line.chat.title) + } + } + if strings.Join(roots, " / ") != "the corpus sweep / thor clips" { + t.Fatalf("the conversations of one section read %q, want the dearest first", strings.Join(roots, " / ")) + } + + // AND THE COLUMN SHOWS THE MONEY, in the money's own ink and nowhere else. + row := tasksDrawnRow(tasksPage(reading, 122), "put the annual toggle up") + if !strings.HasSuffix(row, "$1.50") { + t.Fatalf("a row sorted by cost ends on\n %s\nand the column it is sorted by is the money", row) + } + blank := tasksDrawnRow(tasksPage(reading, 122), "check the copy") + if strings.Contains(blank, "$") { + t.Fatalf("a row nobody priced drew a figure:\n %s", blank) + } + + // AND THE LABEL WEARS THE ARROW. + _, second := tasksControlLabels(by) + if second != "cost "+tasksSortDown { + t.Fatalf("the sorted column's label reads %q", second) + } + if _, back := tasksControlLabels(by.on(tasksByCost)); back != "cost "+tasksSortUp { + t.Fatalf("the same key again leaves the label reading %q", back) + } + + // AND THE SAME KEY AGAIN REVERSES THE WHOLE PAGE. + up := tasksOpen(readTasks(world, tasksMine{}, win, by.on(tasksByCost), time.Time{}, now)) + back := []string{} + for _, line := range tasksWorkLines(up.lay(122)) { + if line.item.entry.SessionID == "room-a" { + back = append(back, tasksLabel(line.item.entry)) + } + } + if strings.Join(back, " / ") != "pages one two / put the annual toggle up" && + back[len(back)-1] != "check the copy" { + t.Fatalf("reversed, the conversation reads %q", strings.Join(back, " / ")) + } + if back[len(back)-1] != "check the copy" { + t.Fatalf("reversed, the row nobody priced came off the bottom: %q", strings.Join(back, " / ")) + } +} + +// THE CHORD AND THE POINTER ARE ONE DOOR. `alt+s` walks the keys and a press on +// a label names one; both go through [app.taskSheetSortBy], so a column clicked +// twice and a key cycled round to itself behave the same way. +// +// IT IS A CHORD AND NOT `s` (the ruling of 2026-09-11). Every printable key on +// this page goes into the filter, so a bare `s` would cost a person `sweep`, +// `stop` and `site`. +func TestAPressOnTheCostLabelSortsTheListByCost(t *testing.T) { + a := tasksTableApp(t) + if a.taskSheet.order.key != tasksByAge { + t.Fatalf("the page opens sorted by %q", a.taskSheet.order.key.word()) + } + width, height := a.size() + lines, hits, _, _ := a.taskSheetFrame(width, height) + y := -1 + for at, hit := range hits { + if hit.kind == taskSheetHitControl { + y = at + break + } + } + if y < 0 { + t.Fatalf("the control row answers no press:\n%s", strings.Join(lines, "\n")) + } + // The cell the `age` label is drawn in, asked of the hit map the way the row + // itself is laid out. + room := width - len(tasksBareLead) + stateCells, secondCells, nameCells := tasksColumns(room, a.taskSheet.order.key) + x := len(tasksBareLead) + nameCells + stateCells + secondCells - 1 + a.taskSheetPress(x, y) + if a.taskSheet.order.key != tasksByAge { + t.Fatalf("a press on the age label sorted by %q", a.taskSheet.order.key.word()) + } + if !a.taskSheet.order.back { + t.Fatal("a press on the column already sorted did not turn it round") + } + // AND THE STATE LABEL BESIDE IT IS ITS OWN COLUMN. + a.taskSheetPress(len(tasksBareLead)+nameCells, y) + if a.taskSheet.order.key != tasksByState { + t.Fatalf("a press on the state label sorted by %q", a.taskSheet.order.key.word()) + } + // AND THE CHORD REACHES COST, which no label on an age-sorted page is + // standing over — the two doors are the same door. + for i := 0; i < int(tasksSortKeyCount)+1; i++ { + if a.taskSheet.order.key == tasksByCost { + break + } + drive(t, a, key(tasksSortKeyChord)) + } + if a.taskSheet.order.key != tasksByCost { + t.Fatalf("%q never reached cost: it is on %q", tasksSortKeyChord, a.taskSheet.order.key.word()) + } + if _, second := tasksControlLabels(a.taskSheet.order); second != "cost "+tasksSortDown { + t.Fatalf("sorted by cost the second label reads %q", second) + } + // AND THE OTHER CHORD TURNS THE COLUMN THE PAGE IS ON ROUND, rather than + // walking back a key: what a person means by shift here is "the other way", + // not "the previous column". + was := a.taskSheet.order + drive(t, a, key(tasksSortBackChord)) + if now := a.taskSheet.order; now.key != was.key || now.back == was.back { + t.Fatalf("%q left the page sorted by %q back=%v, want cost the other way round", + tasksSortBackChord, now.key.word(), now.back) + } +} + +// ── the folds ─────────────────────────────────────────────────────────────── + +// EVERY CONVERSATION AND EVERY FAMILY OPENS SHUT (owner, 2026-09-11), `→` opens +// one, and A TYPED FILTER OPENS EVERY CONVERSATION WITH A MATCHING ROW — then +// gives the person their own folds back when it clears. A row that matched and +// is sitting behind a fold is a row the query appears not to have found. +func TestTheTasksPageOpensFoldedAndAFilterOpensWhatItMatched(t *testing.T) { + a := tasksTableApp(t) + width, _ := a.size() + page := tasksPageFolded(a.tasksFiltered(), width) + for _, name := range []string{"the pricing page", "thor clips", "the corpus sweep"} { + if !strings.Contains(page, name) { + t.Fatalf("a fresh page is missing the conversation %q:\n%s", name, page) + } + } + if work := tasksWorkLines(a.tasksFiltered().lay(width)); len(work) != 0 { + t.Fatalf("a fresh page drew %d rows of work under its roots:\n%s", len(work), page) + } + + // `→` OPENS ONE, AND ONE ONLY. + tasksPointAtRoot(t, a, "the pricing page") + if !a.taskSheetFold(true) { + t.Fatal("`→` did nothing over a shut conversation") + } + page = tasksPageFolded(a.tasksFiltered(), width) + if !strings.Contains(page, "pages one two") { + t.Fatalf("`→` did not put the conversation's work on the page:\n%s", page) + } + if strings.Contains(page, "render the fight clip") { + t.Fatalf("`→` opened a conversation nobody was standing on:\n%s", page) + } + if !a.taskSheetFold(false) { + t.Fatal("`←` did nothing over the conversation it had just opened") + } + + // A TYPED FILTER OPENS WHAT IT MATCHED. + a.taskSheet.query.setText("pages") + a.taskSheetTyped() + page = tasksPageFolded(a.tasksFiltered(), width) + if !strings.Contains(page, "pages one two") { + t.Fatalf("the filter left its own match behind a fold:\n%s", page) + } + if strings.Contains(page, "render the fight clip") || strings.Contains(page, "sweep the corpus") { + t.Fatalf("the filter kept work nothing matched:\n%s", page) + } + + // AND CLEARING IT GIVES THE PERSON THEIR OWN FOLDS BACK. + a.taskSheet.query.setText("") + a.taskSheetTyped() + page = tasksPageFolded(a.tasksFiltered(), width) + if strings.Contains(page, "pages one two") { + t.Fatalf("the cleared filter left the conversation it opened standing open:\n%s", page) + } + if !strings.Contains(page, "the pricing page") { + t.Fatalf("the cleared filter took the conversation off the page:\n%s", page) + } +} + +// tasksPointAtRoot parks the cursor on a conversation's own row, WITHOUT opening +// anything — which is what the fold tests need and what [tasksPointAt] cannot +// promise, because reaching a piece of work means opening the fold over it. +func tasksPointAtRoot(t *testing.T, a *app, title string) int { + t.Helper() + width, _ := a.size() + for at, line := range a.tasksFiltered().lay(width) { + if line.kind == tasksLineChat && line.chat.title == title { + a.taskSheet.cursor = at + return at + } + } + t.Fatalf("no conversation on the page is called %q", title) + return -1 +} + +// ── the reason ────────────────────────────────────────────────────────────── + +// UNDER A HUNDRED AND TEN CELLS THE CURSOR'S ROW GROWS ONE DIM LINE, and the +// line is the state and the reason joined the one way this codebase joins them +// ([session.TaskStatus.RowWord]). The reason left the row when the row became a +// table; a frame too narrow for the pane beside the list is where it has to go +// instead, and it is one row of the page rather than twenty. +func TestUnderTheePaneFloorTheCursorsRowGrowsItsReason(t *testing.T) { + world, win, now := tasksTableFixture() + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) + var unchecked tasksItem + for _, item := range reading.items { + if tasksLabel(item.entry) == "check the copy" { + unchecked = item + } + } + pal := newPalette(tokens.NoColor, false) + got := plain(tasksReasonLine(unchecked, 100, pal)) + if want := unchecked.status().RowWord(); got != want { + t.Fatalf("the grown line reads\n %q\nwant\n %q", got, want) + } + if !strings.Contains(got, "your call") || !strings.Contains(got, "nobody could check it") { + t.Fatalf("the grown line over work nobody could check reads %q", got) + } + // AND IT IS DRAWN ONLY WHERE THERE IS NO PANE FOR IT. + a := tasksTableApp(t) + if a.width = tasksPaneFloor; tasksReasonShowing(a) { + t.Fatalf("at %d cells the list grows a line the pane beside it is already saying", a.width) + } + if a.width = tasksPaneFloor - 1; !tasksReasonShowing(a) { + t.Fatalf("at %d cells the reason is on no line of the frame at all", a.width) + } +} diff --git a/internal/tui3/tui3_test.go b/internal/tui3/tui3_test.go index bb45b4e39e..9b072b7024 100644 --- a/internal/tui3/tui3_test.go +++ b/internal/tui3/tui3_test.go @@ -927,6 +927,12 @@ func key(s string) tea.KeyPressMsg { // the ctrl fall-through builds single-rune chords with one modifier, and // this one carries two. return tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl | tea.ModShift} + case "alt+shift+s": + // The tasks place's `sort the other way round` (taskstable.go). It is + // spelled out because the alt fall-through above builds single-rune + // chords with ONE modifier, and a chord that silently became the zero key + // is a test pressing nothing — which is this helper's own stated law. + return tea.KeyPressMsg{Code: 's', Mod: tea.ModAlt | tea.ModShift} case "shift+left": return tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModShift} case "shift+right": From 99f3ffce6a55fdfbf710c9316ae0d7b7c3f7b2a8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 12:58:24 -0400 Subject: [PATCH 12/20] the manual knows the table, and another window's row says so in twenty cells Co-Authored-By: Claude Opus 5 --- internal/manual/chat/keys.md | 14 +-- internal/manual/chat/places.md | 4 +- internal/manual/chat/tasks.md | 177 ++++++++++++++++++++++++--------- internal/tui3/taskaway_test.go | 21 +++- internal/tui3/taskstable.go | 23 ++++- 5 files changed, 183 insertions(+), 56 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index c499daabe2..cd533b2c34 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1573,10 +1573,13 @@ first and last · `enter` opens the row · `backspace`, `ctrl+w` and `ctrl+u` ed drawing those two answers for it** — a frame at least 110 columns wide, over a task of this conversation's that is waiting on you; everywhere else a digit is typed · **every other printable key, the space included, types into the filter**, which narrows -every section at once and is shown at the foot as `filter · port`. `→` opens the row's +every section at once and is drawn on the **control row** at the top of the list, as +`⌕ port`. `alt+s` sorts by the next column — age, name, state, files, cost — and +`alt+shift+s` turns the column you are on round; a press on one of the two column labels at +the right of that control row sorts by it. `→` opens the row's verbs, and this place has one: `s stop it`, over a task this conversation is holding that is still queued or running. Its foot is assembled from what is true of the row under the -cursor — `enter open its room · → verbs: stop it · type to filter this list` over a task this +cursor — `enter open its room · → verbs: stop it · alt+s sort: age · type to filter` over a task this window is running, `enter go inside it` on a task another conversation ran, which has no room to open, and one of three clauses on a task another aforge **window** is running: `enter go to that conversation` when this terminal is holding that conversation, which @@ -1584,9 +1587,8 @@ switches to it standing in that task's room; `enter read it as it runs` when the running it and this window can join, which opens that task's own live transcript, read-only; and `enter where it is running` when nothing here can reach it, which opens the card that says which window has it — and -`esc clear the filter` in place of `type to filter this list` while you are typing one. The -clause names the list because the message box is on the same frame two rows above, and while -this page is up your keystrokes go to the filter and not to that box. On a machine that has run **nothing at all**, +`esc clear the filter` in place of `type to filter` while you are typing one — the one fact +the control row itself cannot show is that esc now means the filter and not the page. On a machine that has run **nothing at all**, where the body is teaching what tasks are, the foot drops to `tab next place · esc` alone: there is no row to filter, none to open and no verb to press. **Clicking a row moves the cursor to it and the pane follows; clicking the same row again opens it.** On a frame too narrow for @@ -2186,7 +2188,7 @@ on the `jobs` label toggles the section; `enter` on a job row opens that job's p **Under 60 columns the roster page is a thumb's, not a keyboard's.** Its rows are two-line cards a tap opens, its foot is a `‹ back` bar in place of the key legend -`enter open its room · type to filter this list`, and the strip that opens it is one full-width door +`enter open its room · alt+s sort: age · type to filter`, and the strip that opens it is one full-width door (`▸ 3 tasks · 1 running`) rather than a row of chips. Mouse motion is ignored — a tap opens in one gesture. The tasks page describes the phone flow in full. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index ffc64561f4..89519330f1 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -190,7 +190,9 @@ you can start something from. On the **tasks** place it reads `› type to filte because there is nothing to send from there — `enter` opens the row under the cursor and nothing else — so the shared prompt was inviting an instruction into a slot that could only ever narrow a list. The moment you type, the box is your text on every place alike, and a -click puts the caret where you clicked. +click puts the caret where you clicked — **except on tasks**, where the letters draw on the +control row at the top of the list instead, beside the `⌕` mark, so that what you typed is +above the rows it changed rather than two lines below them. ## The scope chip — what is the here ~/aforge-v2 thing next to the box diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 4a7c79ec0e..6e8708eedc 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2549,8 +2549,9 @@ The place's name, its count and its window are what the line is for. **The count is a claim about the PLACE and never about what you have typed.** With a filter on, the sentence goes on counting every row the window holds — a word that matches nothing -does not make the machine's history empty. What matched is said on the note line at the -bottom instead: `filter · zzz · nothing matches`. +does not make the machine's history empty. What matched is said on the line under the +list instead: `nothing matches`. The words you typed are on the control row at the top of +the list, where you typed them. **When the time window holds none of it, that line says `tasks · nothing since jul 29`** — in words, because a `0` there is the figure the emptiness law forbids, and with the date @@ -2873,23 +2874,21 @@ wisp · /Users/ada/code/wisp - Inside a task this is absent, like the rest of it: a task sees the pieces it handed out itself and nothing wider. -## Searching the task page: type to filter, find an old task by name, why does the tasks box say type to filter this list, my cursor jumped to another task while I was reading +## Searching the task page: type to filter, find an old task by name, where the words I type appear, my cursor jumped to another task while I was reading **Just type.** On the task page every printable key — letters, the space, and digits -everywhere they are not an answer — builds a filter, and both sections narrow against it as +everywhere they are not an answer — builds a filter, and every section narrows against it as you go. The two exceptions are `1` and `2` over a row the record pane beside the list is -drawing answers for, which answer it: see *Answer a task from the list*. The message box at the foot -of this place says so itself: it rests on `type to filter this list` rather than the -`say what you want done` every other place shows, because there is nothing to send from here -and a box inviting an instruction over a slot that only filters was the one thing on the -screen telling you the wrong story. +drawing answers for, which answer it: see *Answer a task from the list*. ``` -filter · parser +⌕ parser state age ↓ ``` -is the dim line above the keys at the foot, so a list that has lost rows never loses them -for a reason you cannot see. +is the **control row**, the first line of the list, and your letters land there in the +reading ink with the dim `type to filter` standing in the box until you type. It used to be +an echo on a note line UNDER the rows your keystrokes had just changed; it is at the top of +the list now, where the typing goes. - It matches a task's **title**, its **id** (typed exactly: `7` finds task 7 and nothing else), its **name** as the `@` list spells it, and its **outcome**. Letters in order are @@ -2904,7 +2903,11 @@ for a reason you cannot see. - `backspace` deletes a character, `ctrl+w` a word, `ctrl+u` all of it. - **`esc` clears the filter first and closes the page on the second press** — the same layering the settings panel's search has. `ctrl+.` closes the page from anywhere. -- With nothing matching, the foot reads `filter · zzz · nothing matches`. +- With nothing matching, the line under the list reads `nothing matches`, and the page keeps + its own heading and count — there is work here and your words are hiding it. +- **A filter opens every main chat it found something in**, because a row that matched and is + sitting behind a shut fold is a row the query appears to have missed. Clearing the filter + gives you your own folds back. - `↑`/`↓` and `enter` keep working over exactly the rows the filter left. ## A task I just started is not on the task page — the page while it is open @@ -2935,6 +2938,9 @@ too — see *I started a task over ssh and the sidebar stayed empty* above. | `enter` | open the main chat, task room, or record card named by this row | | `→` | open the family under this row, where it has one; a second `→` on an open family opens the row's verbs | | `←` | fold that conversation or family back up | +| `alt+s` | sort by the next column: age, name, state, files, cost | +| `alt+shift+s` | turn the column you are sorted on round | +| a press on a column label | sort by that column; press it again to turn it round | | any printable key | type into the filter — except `1` and `2` over a row the pane is offering those two answers for, which answer it | | `backspace` `ctrl+w` `ctrl+u` | edit the filter | | `esc` | clear the filter, or close the page when there is none | @@ -2971,6 +2977,12 @@ The **main chat is the parent** of the work it requested. Tasks hang beneath the conversation; a task's children hang beneath that task, including deeper levels. Chats in the selected time window appear even before they delegate any work. +**Everything opens shut**, so the page you arrive at is a page of main chats, each saying how +much is under it and how urgent the most urgent of it is (`5 your call`, `9 done`). `→` opens +one, `←` shuts it. A hundred conversations with a hundred and ninety subtasks under them is a +page nobody can scan, and the work you came for would be behind the ninety conversations you +did not want. + ``` your call ▾ Repair the parser aforge @@ -4907,40 +4919,56 @@ stop, one word, wherever you reach it from. Pressing `x` on a run that has already finished does nothing but say so. -## The tasks place — grouped work, folds, filtering and time-window keys, my cursor jumped to another task while I was reading +## The tasks place — the table, its two columns, folds, and the time-window keys 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. +**Every row is a row of a table, and the columns are in the same cells on every row.** Left +to right: the fold, the mark, the name, then `state`, then the column the list is **sorted +by**, right-aligned, then one cell of air. A main chat's row has no mark and its name runs +to the state column. Only the name flexes — the two columns are fixed at 90 cells and over. +Under 90 the `state` column goes and the sorted column stays, because a figure has nowhere +else on the page to be. Under 60 the rows are phone cards, two lines each, unchanged. + +**The `state` column is never blank on a row of work.** It is the task-states word — +`your call`, `running`, `waiting`, `done`, `incomplete`, `stopped` — and nothing else, except +for three additions that belong in that cell: + +- work **running right now** adds where it has got to: `working · 18 of 40`. That figure is + the one thing on the row that changes while you watch it, and it is the first thing given + up when the cell runs out of room. +- a **shut fold** adds what it is holding: `done · holds 3 more`. +- a **main chat's** row says its **count** instead: `5 your call`, `9 done`, `2 running` — + how much work is under it altogether and how urgent the most urgent of it is, which is the + question a shut fold raises. +- work **another window is running** says `another window` here instead. + **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. - -**Related work stays in a tree.** Folds keep a long run readable, and the list scrolls -through everything the time window holds. The window's edge is named once in the page -header or its arrow control. +**The reason is not on the row.** Why work ended as it did is on the task's record, one +keypress away through `enter`, and in the pane beside the list where the frame is wide enough +for one. On a frame under 110 cells there is no pane, so the row **under the cursor** grows +one dim line of its own: `your call · nobody could check it`, and the first sentence of a +landed task's report after it. One row said whole, rather than twenty rows each missing the +same word. The row's kind is not drawn, and neither is its cost unless you are sorted by it. + +**Everything opens shut.** Every main chat and every family of work opens folded, so a fresh +page is a page of main chats with a count on each. `→` opens the one under the cursor and +`←` shuts it again; the section's own heading says how many rows are behind the folds +(`your call · 4 folded away`), so the count on the row and the count on the heading are +about the same rows. Nothing is capped: the list scrolls through everything the time window +holds, and the window's edge is named once in the page header. Type to filter; every section narrows at once, and a section the query empties is not drawn. -**The message box at the foot of this place says `type to filter this list`**, not `say what -you want done` — on the tasks place there is no message to send and every printable key goes -to the filter, so the box says what typing into it actually does. The one exception is `1` and -`2` over a row the pane is offering those two answers for, which answer it. Every other place keeps the -shared prompt. `↑` and `↓` move among conversation and task rows and skip the head sentence, the blank lines +The one printable keys that are not the filter are `1` and `2` over a row the pane is offering +those two answers for, which answer it. `↑` and `↓` move among conversation and task rows and skip the head sentence, the blank lines and the section words. `enter` on a main chat opens that conversation. On a task it opens its **room** when this conversation is holding, and otherwise goes **inside** it — the record card. Rows another window is running take the cursor too, and what `enter` does with one is *Opening a task another window is @@ -4973,8 +5001,8 @@ list stays exactly where it was. ``` ▾ Clever Bet Prediction Model using Stochastic Processes 9h │ Upgraded model v2: vector skills, BOCPD - ? Upgraded model v2: vector skills, BOC… your call 1d │ ? your call · nobody could check it - ■ Fit and backtest OU skill model on Li… stopped 1d │ + ? Upgraded model v2: vector skills, BOCPD change-point detec… 1d │ ? your call · nobody could check it + ■ Fit and backtest OU skill model on Liverpool's season 1d │ │ 5 files · $0.47 · deepseek-v4-flash · 1d ago │ branch task/upgraded-model-v2 · in a worktree │ @@ -5015,9 +5043,15 @@ with the pane showing the same row. **On a conversation's own row the pane is the conversation**: its title, `3 pieces of work · $9.30`, the first few rows under it in the order the page files them, and `enter open the chat`. -**Under 110 columns there is no pane and no rule.** The place is the list alone, and each row -carries what it always carried at the right of its own line. Under 60 columns the rows are -two-line cards, as they have always been. +**The list's own `state` column goes while the pane is up**, and that is the table's rule +rather than a special case: the list is drawn in 72 of the frame's 122 cells, which is under +the 90 the two fixed columns need, so the name keeps what it can and the column the list is +sorted by stays. What the state column was saying is in the pane, said whole and with its +reason. + +**Under 110 columns there is no pane and no rule.** The place is the list alone, and the row +under the cursor grows one dim line of its own with `state · reason` on it. Under 60 columns +the rows are two-line cards, as they have always been. ## Answer a task from the list — accept or reject a finished task with 1 and 2 without opening it @@ -5039,6 +5073,55 @@ asking its question in *that* window, and this one cannot answer for it — so t `enter open` alone, and `1` and `2` are typed into the filter like any other character. That is also what happens on a frame too narrow for the pane: nothing on screen names the digits, so nothing takes them. +## sort the tasks list — alt+s, clicking a column label, and what age, name, state, files and cost each order + +**The sort key is the column you see.** There are five keys and the second column always +shows the one the list is ordered by: + +| key | what the column draws | +| --- | --- | +| `age` | how long ago — `7h`, `1d`, `now`. This is the default, newest first. | +| `name` | the age, because the name is already the widest thing on the row. | +| `state` | the age, because `state` is the column standing right beside it. | +| `files` | what the work touched — `2 files`, `12 files`. | +| `cost` | what it cost — `$1.50`, in the money's own ink. This is the **only** place money is drawn on this page. | + +**`alt+s` walks the keys** — age, name, state, files, cost, and round again — and +**`alt+shift+s` turns the column you are on round**. It is a chord rather than a bare `s` +because on this place every printable key goes into the filter: `s` alone would cost you +`sweep`, `stop` and `site`. + +**Or click a label.** The two column labels are drawn at the right of the control row at the +top of the list, over the columns they name, and pressing one sorts by it. Pressing the one +already sorted turns it round. The sorted label wears the arrow: `cost ↓` newest or dearest +first, `cost ↑` the other way. The foot names the key the list is on (`alt+s sort: cost`). + +**Sorting happens inside each level of the tree, and the tree never flattens.** The sections +keep their order — a sort may not move `your call` below `finished today` — main chats order +by their **aggregate** inside their section (their total cost, their newest row, their whole +file count), and the work under one chat orders among itself. A blank cell is a true answer, +not a missing one: a row nobody priced sinks to the bottom of its group whichever way the +column points. + +## filter the tasks list — type to filter, what it matches, and esc to clear it + +**Type, and the list narrows as you type.** The first line of the list is the control row: a +`⌕` mark, then what you have typed, and before you type anything the dim words `type to +filter`. That is where your letters land — there is no message to send from this place, so +every printable key goes to the filter. `backspace` takes one back, `ctrl+u` clears the box, +`ctrl+w` takes a word. + +The query is matched against the task's name, the main chat's title, the state word and the +file paths the work touched. Every section narrows at once, and a section the query empties +is not drawn at all. A query that matches nothing keeps the page's own heading and count and +says `nothing matches` under the list — there **is** work here, and your words are hiding it. + +**A filter opens every main chat with a match in it**, so a row that matched is never sitting +behind a fold looking as though the query missed it. When you clear the filter your own folds +come back exactly as you left them. + +**`esc` clears the filter first and closes the place second**, which is why the foot says +`esc clear the filter` while one is on. ## The foot of the tasks place, and the one verb on its row strip @@ -5047,7 +5130,7 @@ under the cursor**, and never from a fixed sentence. Over a task this window is reads ``` -enter open its room · → verbs: stop it · alt+. map · tab next place +enter open its room · → verbs: stop it · alt+s sort: age · type to filter · alt+. map · tab next place ``` The last two keys are on every place and the router adds them. What comes before them @@ -5064,13 +5147,17 @@ changes with the cursor: - `enter about that window` over work this machine cannot reach at all, which opens the card naming where it is. - `→ verbs: stop it` **only while the row has that verb** — see below. -- `esc clear the filter`, **only while a filter is on**, because that is the key whose - meaning just moved. +- `→ what ran under it` or `← fold it back up` over a fold, whichever the fold is not. + +The last two clauses are about the **page** rather than the row, and they are always there: -**The filter is not named on this line.** It used to be — `type to filter` sat here to -correct the message box two rows below, which was saying `say what you want done` over a slot -that could only ever narrow the list. The box says the true sentence itself now, so repeating -it on the foot would be one screen naming one thing twice. +- `alt+s sort: ` names the chord and the column the list is on. A chord nobody can find + is a chord that does not exist, and every printable key here belongs to the filter, so + sorting cannot be a bare letter. +- `type to filter`, because nothing else on the frame says that a letter goes into the box on + the control row rather than to the page's own keys. While a filter **is** on, that slot + says `esc clear the filter` instead — the one fact the box itself cannot show is that esc + now means the filter and not the page. **`→` opens the row's verbs, and the tasks place has exactly one: `s stop it`.** It is offered over a task **this conversation is holding** that is still `queued` or `running` — diff --git a/internal/tui3/taskaway_test.go b/internal/tui3/taskaway_test.go index 65d168d1da..7c7edeb59b 100644 --- a/internal/tui3/taskaway_test.go +++ b/internal/tui3/taskaway_test.go @@ -200,8 +200,12 @@ func TestTheHistoryPageShowsAnotherWindowsRunningWork(t *testing.T) { if !strings.Contains(row, taskAwayWord) { t.Fatalf("the row does not say it belongs to another window:\n%s", row) } - if !strings.Contains(row, "Fix the nil-map crash") { - t.Fatalf("the row does not say WHICH window:\n%s", row) + // WHICH WINDOW IS THE NOTE'S SECOND HALF, and it is said wherever there is + // room for it: the state column is twenty cells, so the row wears the words + // that correct its mark and the name goes on the cursor's own grown line and + // on the card `enter` opens ([tasksStateField] states the trade). + if note := tasksNote(awayItemWith(t, a, "Sweep the call sites")); !strings.Contains(note, "Fix the nil-map crash") { + t.Fatalf("the reading does not know WHICH window: %q", note) } // AND IT LEAVES WHEN THE WINDOW DOES. Nothing announces a window closing — @@ -212,6 +216,19 @@ func TestTheHistoryPageShowsAnotherWindowsRunningWork(t *testing.T) { } } +// awayItemWith is the row of work one name is on, taken from the reading rather +// than from the paint. +func awayItemWith(t *testing.T, a *app, name string) tasksItem { + t.Helper() + for _, item := range a.tasksFiltered().items { + if tasksLabel(item.entry) == name { + return item + } + } + t.Fatalf("no row of the reading is called %q", name) + return tasksItem{} +} + // A WINDOW WITH NO NAME IS STILL A PLACE. The row says where the work is and // stops there rather than putting a separator in front of nothing. func TestAnotherWindowWithNoNameSaysOnlyThatItIsAnotherWindow(t *testing.T) { diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 33f00554e1..4d7d43d7d7 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -121,7 +121,18 @@ func tasksColumns(room int, key tasksSortKey) (state, second, name int) { func tasksStateField(line tasksLine) rowField { item := line.item if note := tasksNote(item); note != "" { - return rowSay(note) + // THE WORDS FIRST AND THE WINDOW'S NAME AFTER THEM. `another window · + // Fix the nil-map crash` is thirty-eight cells and this column has twenty, + // and a field with only one spelling either fits or goes (rowfit.go law 4) + // — so offering the pair alone left the cell BLANK on exactly the rows + // whose mark most needs correcting. WHICH window is on the card `enter` + // opens and on the cursor's own grown line; THAT it is not this one is the + // fact the column exists for. + short := taskAwayWord + if item.here { + short = taskOpenHereWord + } + return rowSay(note, short) } word := taskStateWord(item.entry, item.runs) if item.live != nil { @@ -356,7 +367,15 @@ func tasksSortHint(by tasksSort) string { // ([tasksTop]) would be chasing a number that changed with the row it was // measuring. func tasksReasonLine(item tasksItem, width int, pal palette) string { - said := strings.TrimSpace(item.status().RowWord()) + // WORK IN ANOTHER WINDOW SAYS WHERE IT IS, WHOLE. The state column has twenty + // cells and keeps only the words that correct the mark ([tasksStateField]); + // this line has the width of the list, and WHICH window is exactly the half a + // person needs to act — it is the difference between a terminal they can + // switch to and one they have to go and find. + said := strings.TrimSpace(tasksNote(item)) + if said == "" { + said = strings.TrimSpace(item.status().RowWord()) + } if outcome := taskFirstSentence(item.entry.Outcome); outcome != "" && outcome != said { said += rowSep + outcome } From d02349b274f23bb720b3af6013b1364113f5b140 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:09:26 -0400 Subject: [PATCH 13/20] the filter box stays the place's box, and only its drawing moves Returning nil took the two-space door home with it: a place with no box has no door, which is the router's own law, and this place still types. Co-Authored-By: Claude Opus 5 --- internal/tui3/home_test.go | 9 ++++----- internal/tui3/pages.go | 13 ++++++++++++- internal/tui3/place_tasks.go | 30 ++++++++++++++++-------------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go index a492aa4426..76cb4be096 100644 --- a/internal/tui3/home_test.go +++ b/internal/tui3/home_test.go @@ -3530,11 +3530,10 @@ func TestSpaceInTheTaskRoomPagesTheCardAndDoesNotOpenHome(t *testing.T) { ID: "1", Name: "port-the-thing", Label: "Port the thing", Title: "Port the thing", Status: string(session.TaskDone), SessionID: "aaaa000000000001", }) - a, _ := driveToPlace(t, lab, pageTasks) - // THE FILTER IS NOT THE COMPOSER ANY MORE. [placeTasks.box] returns nil and - // the letters draw on the control row at the top of the list, so what a - // person typed is read off the place's own editor. - box := &a.taskSheet.query + a, box := driveToPlace(t, lab, pageTasks) + if box == nil { + t.Fatal("the tasks place has no box to type into") + } // THE DOOR HAS TO EXIST FOR THIS TEST TO MEAN ANYTHING, the same insistence // [TestTheDoorStaysShutOverTheMemoryCardEditor] makes and for its reason: a // fixture whose door went dark would pass this however wrong the fix was. diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index 4f8ce2ccb1..a69aa6d7f6 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -198,6 +198,10 @@ type place interface { window(a *app, key string) bool // box is the composer this place types into — the shared one by default. box(a *app) *editor + // boxOnBody reports that this place draws what is typed into its box in a row + // of its OWN body, so the foot draws the resting sentence and never the + // letters. It is false everywhere but tasks ([placeTasks.boxOnBody]). + boxOnBody() bool // resting is WHAT THAT BOX SAYS WITH NOTHING TYPED IN IT, and "" takes the // router's own sentence. // @@ -307,6 +311,7 @@ func (placeBase) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { // which is what makes a permanent bottom line a composer rather than seven boxes // that each forget ([app.compose]). func (placeBase) box(a *app) *editor { return &a.compose } +func (placeBase) boxOnBody() bool { return false } // ── THERE IS NO DEFAULT hint, AND THAT IS THE WHOLE POINT ─────────────────── // @@ -1178,7 +1183,7 @@ func placeFrameWithBar(a *app, width, height int, // picker's own hint stands in while nothing is typed. draftRows, draftCX, draftCY = draftBlock(&a.target.pick.filter, pal, width-2, 1, a.target.pick.hintAt(width-2-ansi.StringWidth(prompt)), "") - case box != nil && !box.empty(): + case box != nil && !box.empty() && !a.placeBoxOnBody(): draftRows, draftCX, draftCY = draftBlock(box, pal, width-2, homeDraftRows, "", "") } draftHeight := len(draftRows) @@ -1606,6 +1611,12 @@ const ( // goes to the filter ([placeTasks.box]), and the shared prompt was inviting an // instruction into a slot that could only ever narrow a list ([place.resting] // carries the whole of that story). +// placeBoxOnBody is that hook asked of whichever place is standing. +func (a *app) placeBoxOnBody() bool { + pl := a.showing() + return pl != nil && pl.boxOnBody() +} + func (a *app) placeRestWord() string { if pl := a.showing(); pl != nil { if said := strings.TrimSpace(pl.resting(a)); said != "" { diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index bae0b684cd..362870e552 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1735,21 +1735,23 @@ func (placeTasks) note(a *app, width int) []string { return a.taskSheet.note func (placeTasks) hint(a *app) string { return a.taskSheet.hint(a) } func (placeTasks) changed(a *app, since time.Time) int { return a.taskSheet.changed(a, since) } -// box is NOTHING ON THIS PLACE, AND THAT IS THE FILTER BOX MOVING RATHER THAN -// GOING. +// box is the filter, exactly as it has always been: every printable key on this +// place goes into it and the list narrows as it fills. // -// The composer at the foot WAS this page's filter: every printable key went to -// [tasksPlace.query], and the router drew that editor's letters two rows under -// the list they were narrowing. The table put the box where the typing is about -// — the first row of the list, under the head and over the rows -// ([tasksControlRow]) — and a box drawn in both places would be one person's -// letters on screen twice, which is the defect this page's own title row was -// removed for. -// -// SO THE FOOT KEEPS THE INVITATION AND LOSES THE ECHO ([placeTasks.resting]): -// `› type to filter this list` stands there whatever is typed, and the letters -// appear once, in the row that is narrowing. -func (placeTasks) box(a *app) *editor { return nil } +// WHAT MOVED IS WHERE IT IS DRAWN ([placeTasks.boxOnBody]). The router used to +// put this editor's letters two rows UNDER the list they were narrowing; they +// are on the first row of the list now, over the rows they changed +// ([tasksControlRow]). The editor itself stays the place's box because a box is +// more than a row of letters — the two-space door home is armed from it +// ([app.placeHomeGesture]), a press in the foot puts the caret in it, and a +// place that answered `nil` here would silently lose all of that. +func (placeTasks) box(a *app) *editor { return &a.taskSheet.query } + +// boxOnBody says THIS PLACE DRAWS WHAT IS TYPED INTO ITS BOX ITSELF, in a row of +// its own body, so the foot must not draw it a second time. The foot keeps the +// invitation ([placeTasks.resting]) and loses the echo; one person's letters on +// screen twice is the defect this page's own title row was removed for. +func (placeTasks) boxOnBody() bool { return true } // resting is what that box says when nothing is typed in it, and it is THIS // PLACE'S sentence rather than the router's (pages.go's [place.resting]). From 04322ce1f2c309fa61f85b32f56983d20bd3c530 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:13:50 -0400 Subject: [PATCH 14/20] rebase: the grown line asks the pane, and the pane's own lab opens the folds Co-Authored-By: Claude Opus 5 --- internal/tui3/taskpane_test.go | 3 +++ internal/tui3/taskstable.go | 24 +++++++++--------------- internal/tui3/taskstable_test.go | 15 +++++++++------ 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/tui3/taskpane_test.go b/internal/tui3/taskpane_test.go index d63480b3d3..0c26563732 100644 --- a/internal/tui3/taskpane_test.go +++ b/internal/tui3/taskpane_test.go @@ -84,6 +84,9 @@ func paneLandingLab(t *testing.T, width int) (*app, *roomQuestionFake) { // person's first `↓` lands. func paneCursorOnWork(t *testing.T, a *app) { t.Helper() + // The work is behind its conversation's fold, which opens shut; a person + // reaches it with `→` and so does this ([openTaskFolds]). + openTaskFolds(a) lines := a.tasksFiltered().lay(a.taskSheetListWidth()) for at := range lines { if lines[at].kind == tasksLineTask { diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 4d7d43d7d7..ad41b03144 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -407,22 +407,16 @@ func taskFirstSentence(report string) string { return report } -// tasksReasonShowing reports whether the list should grow that line at all, -// AND IT ASKS THE WHOLE FRAME RATHER THAN THE LIST'S OWN WIDTH. +// tasksReasonShowing reports whether the list should grow that line at all: it +// is drawn WHERE THERE IS NO PANE, and nowhere else. // -// The difference is the whole of the trap. Once the record stands in a pane -// beside the list, the list is drawn in the cells the pane leaves — 72 of 122 — -// which is already under the width at which a pane appears. A predicate that -// asked its own width would decide there is no pane on exactly the frames that -// have one, and draw this line underneath the pane that already says it. +// IT ASKS THE PANE ITSELF ([app.taskPaneShowing]) rather than measuring a floor +// of its own. Two answers to "is the record already beside the list" is how a +// frame ends up saying one thing twice, and the pane's predicate knows something +// this file cannot: it asks the WHOLE FRAME, where a floor asked of the list's +// own width would decide there is no pane on exactly the frames that have one — +// the list is drawn in 72 of 122 cells while the pane stands beside it. func tasksReasonShowing(a *app) bool { width, _ := a.size() - return layoutTier(width) != tierPhone && width < tasksPaneFloor + return layoutTier(width) != tierPhone && !a.taskPaneShowing() } - -// tasksPaneFloor is the frame at which the record moves off the cursor's row and -// into a pane of its own beside the list (#884). It is named here because this -// is the file that has to know the answer today; the pane's own lane replaces -// the body of [tasksReasonShowing] with its predicate and this constant goes -// with it. -const tasksPaneFloor = 110 diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go index a4a2bb200c..37451cc0b1 100644 --- a/internal/tui3/taskstable_test.go +++ b/internal/tui3/taskstable_test.go @@ -428,12 +428,15 @@ func TestUnderTheePaneFloorTheCursorsRowGrowsItsReason(t *testing.T) { if !strings.Contains(got, "your call") || !strings.Contains(got, "nobody could check it") { t.Fatalf("the grown line over work nobody could check reads %q", got) } - // AND IT IS DRAWN ONLY WHERE THERE IS NO PANE FOR IT. + // AND IT IS DRAWN ONLY WHERE THERE IS NO PANE FOR IT — asked of the pane + // itself, so the two can never disagree about one frame. a := tasksTableApp(t) - if a.width = tasksPaneFloor; tasksReasonShowing(a) { - t.Fatalf("at %d cells the list grows a line the pane beside it is already saying", a.width) - } - if a.width = tasksPaneFloor - 1; !tasksReasonShowing(a) { - t.Fatalf("at %d cells the reason is on no line of the frame at all", a.width) + for width := 60; width <= 200; width++ { + a.width = width + if grows, pane := tasksReasonShowing(a), a.taskPaneShowing(); grows && pane { + t.Fatalf("at %d cells the list grows a line the pane beside it is already saying", width) + } else if !grows && !pane && layoutTier(width) != tierPhone { + t.Fatalf("at %d cells the reason is on no line of the frame at all", width) + } } } From 224d45f7ca6bcbe2c88efd36780bb2479d9f76d0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:14:32 -0400 Subject: [PATCH 15/20] changes: the entry for #905 Co-Authored-By: Claude Opus 5 --- .../905-tasks-list-becomes-a-table.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/changes/unreleased/905-tasks-list-becomes-a-table.md diff --git a/docs/changes/unreleased/905-tasks-list-becomes-a-table.md b/docs/changes/unreleased/905-tasks-list-becomes-a-table.md new file mode 100644 index 0000000000..e2895c2e19 --- /dev/null +++ b/docs/changes/unreleased/905-tasks-list-becomes-a-table.md @@ -0,0 +1,86 @@ +--- +kind: changed +title: The tasks list becomes a table — two columns, a sort key you can see, and every fold shut +pr: 905 +surface: [chat] +invalidates: + - "A row of the tasks place was a name and a RANKED TAIL of up to six facts — + `2 files · done · 5h · $0.27` — which gave up a fact at a time as the frame + narrowed, so the figure under a person's eye moved sideways on every row and + meant something different on each one. A row is now a name and TWO COLUMNS in + the same cells on every row of one frame: `state` at twenty cells, and the + column the list is SORTED BY at six, right-aligned. Under 90 cells the state + column goes and the sorted column stays; under 60 the phone cards are + unchanged. `tasksFacts`, `tasksMiddleField`, `tasksSpendEarnsCells` and + `tasksPaintTail` are gone." + - "The tasks list had one order — newest first — and no way to ask for another. + It now sorts by age, name, state, files or cost, and THE SORT KEY IS THE + COLUMN YOU SEE: files and cost draw their own figure, name and state show the + age. The sort is applied INSIDE each level of the tree and the tree never + flattens — sections keep their order, conversations order by their aggregate + inside their section, and the work under one conversation orders among + itself. A blank cell is a true answer and sinks to the bottom of its group + whichever way the column points." + - "spec.md §2 and #884 both said the sort key was `s`. It is `alt+s` (walk the + keys) and `alt+shift+s` (turn the column round), by the ruling of 2026-09-11: + every printable key on this place goes into the filter, so a bare `s` would + cost a person `sweep`, `stop` and `site`. The chord is declared through the + router's `alt+` class — the class a VIEW belongs to — because that + class swallows an undeclared letter rather than passing it down. The two + column labels on the control row are clickable and are the same door." + - "Money was drawn on every row of the tasks list, last in the tail and first to + be given up. It draws in ONE place now — the sort-key column, while the list + is sorted by cost — so a list sorted by age carries no figure a person did + not ask for, and the one they did ask for is a column they can read down." + - "Every conversation on the tasks place opened SHOWING its work, and a family + opened shut. Everything opens shut now (the owner's ruling, 2026-09-11): a + hundred conversations with a hundred and ninety subtasks under them is a page + nobody can scan. `→` opens one, a shut root's state cell is its own count in + that section (`5 your call`, `9 done`), and a typed filter opens every + conversation it matched — then gives a person their own folds back when it + clears." + - "A shut conversation's row said how many rows ONE press would reveal. It says + how many pieces of work are under it ALTOGETHER, at every depth, because that + is the claim a shut fold makes: `1 done` beside a heading reading `2 folded + away` was two numbers about the same rows and the smaller one read as the + size of the thing you were deciding whether to open." + - "The filter's words were echoed on a dim `filter · port` note line UNDER the + rows the keystrokes had just changed. They are on the CONTROL ROW at the top + of the list now — `⌕ port`, with the dim `type to filter` standing in it while + nothing is typed — with the two column labels at its right. The note line + under the list keeps only `nothing matches`. The composer at the foot is still + the same editor and still rests on `type to filter this list`; what moved is + where its letters are drawn (`place.boxOnBody`)." + - "The tasks foot named neither the sort nor the filter. It now ends `alt+s + sort: · type to filter`, and `esc clear the filter` takes the second + slot while a filter is on — the ruling asks for both by name, because a chord + nobody can find is a chord that does not exist." + - "`tokens` has a `GFilter` slot: `⌕` plain, nf-fa-search in the nerd-font + repertoire, `/` in ASCII. It shares its plain glyph with `GSearch`, argued in + `TestOneGlyphOneMeaning`'s deliberate table — one lens means one thing, which + is finding, and the two places it is drawn are both a box you type a query + into." + - "`checkerStalled`'s duration and the row's raw outcome landed on #895 and are + unchanged here." +--- + +The list a person opens in the morning is a list they READ DOWN, and a ranked +tail cannot be read down: every row gave up a different fact, so the same cell +held a price on one line, an age on the next and nothing on the third. Two fixed +columns is the whole change, and everything else follows from it — the state has +somewhere to always be said, the sort has somewhere to show its answer, and the +reason, which was what made the right edge prose, moves to the pane beside the +list (#902) or to one dim line under the cursor's own row where there is no room +for a pane. + +The columns are measured from the LIST'S WIDTH and the row's lead — its family +connectors and its mark — is spent out of the NAME. A root has no mark and a +worker three levels down has four cells of connectors, so a row that measured its +columns from what its own lead left would put them in a different place on every +line: a table whose columns move is a tail with extra steps. + +Folded-by-default is the owner's ruling and it changes what the page IS: it opens +as an index of conversations, each saying how much is behind it and how urgent +the most urgent of it is, and a person opens the one they came for. A filter is +the exception, because a row that matched and is sitting behind a shut fold is a +row the query appears not to have found. From 006634ccd299805d26794203b146562696b5d29e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:16:28 -0400 Subject: [PATCH 16/20] the foot names the chord, not the column: five cells cost it both clauses at a hundred Co-Authored-By: Claude Opus 5 --- internal/manual/chat/keys.md | 4 ++-- internal/manual/chat/tasks.md | 11 ++++++----- internal/tui3/place_tasks_test.go | 6 +++--- internal/tui3/taskstable.go | 16 +++++++++++----- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index cd533b2c34..8c4c80d10a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1579,7 +1579,7 @@ every section at once and is drawn on the **control row** at the top of the list the right of that control row sorts by it. `→` opens the row's verbs, and this place has one: `s stop it`, over a task this conversation is holding that is still queued or running. Its foot is assembled from what is true of the row under the -cursor — `enter open its room · → verbs: stop it · alt+s sort: age · type to filter` over a task this +cursor — `enter open its room · → verbs: stop it · alt+s sort · type to filter` over a task this window is running, `enter go inside it` on a task another conversation ran, which has no room to open, and one of three clauses on a task another aforge **window** is running: `enter go to that conversation` when this terminal is holding that conversation, which @@ -2188,7 +2188,7 @@ on the `jobs` label toggles the section; `enter` on a job row opens that job's p **Under 60 columns the roster page is a thumb's, not a keyboard's.** Its rows are two-line cards a tap opens, its foot is a `‹ back` bar in place of the key legend -`enter open its room · alt+s sort: age · type to filter`, and the strip that opens it is one full-width door +`enter open its room · alt+s sort · type to filter`, and the strip that opens it is one full-width door (`▸ 3 tasks · 1 running`) rather than a row of chips. Mouse motion is ignored — a tap opens in one gesture. The tasks page describes the phone flow in full. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 6e8708eedc..5d5fc3dfb6 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5094,7 +5094,8 @@ because on this place every printable key goes into the filter: `s` alone would **Or click a label.** The two column labels are drawn at the right of the control row at the top of the list, over the columns they name, and pressing one sorts by it. Pressing the one already sorted turns it round. The sorted label wears the arrow: `cost ↓` newest or dearest -first, `cost ↑` the other way. The foot names the key the list is on (`alt+s sort: cost`). +first, `cost ↑` the other way. The foot names the chord (`alt+s sort`) and the label wears the +column. **Sorting happens inside each level of the tree, and the tree never flattens.** The sections keep their order — a sort may not move `your call` below `finished today` — main chats order @@ -5130,7 +5131,7 @@ under the cursor**, and never from a fixed sentence. Over a task this window is reads ``` -enter open its room · → verbs: stop it · alt+s sort: age · type to filter · alt+. map · tab next place +enter open its room · → verbs: stop it · alt+s sort · type to filter · alt+. map · tab next place ``` The last two keys are on every place and the router adds them. What comes before them @@ -5151,9 +5152,9 @@ changes with the cursor: The last two clauses are about the **page** rather than the row, and they are always there: -- `alt+s sort: ` names the chord and the column the list is on. A chord nobody can find - is a chord that does not exist, and every printable key here belongs to the filter, so - sorting cannot be a bare letter. +- `alt+s sort` names the chord. A chord nobody can find is a chord that does not exist, and + every printable key here belongs to the filter, so sorting cannot be a bare letter. WHICH + column the list is on is on the control row's own label, wearing the arrow. - `type to filter`, because nothing else on the frame says that a letter goes into the box on the control row rather than to the page's own keys. While a filter **is** on, that slot says `esc clear the filter` instead — the one fact the box itself cannot show is that esc diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index 56984f07b9..cce41f6487 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -54,7 +54,7 @@ func TestTheTasksFootIsScreenOneEWordForWord(t *testing.T) { // sorting is a chord, and a chord nobody can find is a chord that does not // exist. The filter is named beside it because nothing else on the frame says // that a letter goes into the box on the control row rather than to the page. - const want = "enter open its room · → verbs: stop it · alt+s sort: age · type to filter" + const want = "enter open its room · → verbs: stop it · alt+s sort · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } @@ -104,7 +104,7 @@ func TestTheTasksFootSaysOnlyWhatIsTrueOfTheRowUnderIt(t *testing.T) { if !ok || item.entry.Title != "Port the parser" { t.Fatalf("the walk did not reach the earlier conversation's row: %+v", item) } - const want = "enter go inside it · alt+s sort: age · type to filter" + const want = "enter go inside it · alt+s sort · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("over work another conversation ran the foot reads\n %q\nwant\n %q", got, want) } @@ -213,7 +213,7 @@ func TestTheTasksFootNamesNoVerbWithoutTheEnginesDoor(t *testing.T) { // The cursor's row is a family, and [openTaskPlaceWithRows] has opened it — // so the fold clause is the `←` half. What this test is about is what is NOT // here: no verb at all, on a session with no door onto stopping. - const want = "enter open its room · ← fold it back up · alt+s sort: age · type to filter" + const want = "enter open its room · ← fold it back up · alt+s sort · type to filter" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index ad41b03144..6d79493703 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -340,11 +340,17 @@ const ( tasksSortBackChord = "alt+shift+s" ) -// tasksSortHint is how the foot names them, in the hint slot's own grammar: the -// key, then what it does. -func tasksSortHint(by tasksSort) string { - return tasksSortKeyChord + " sort: " + by.key.word() -} +// tasksSortHint is how the foot names the chord, in the hint slot's own grammar: +// the key, then what it does. +// +// IT NAMES THE KEY AND NOT THE COLUMN THE PAGE IS ON. It said `alt+s sort: age` +// for a while and the five cells that cost were exactly the five that did not +// fit: at a hundred columns the foot's fitter dropped this clause AND the filter +// beside it, so the one width most people are on named neither of the page's two +// keys — which is the defect the ruling that put them here was written against. +// WHICH column the list is on is already drawn, on the control row's own label, +// wearing the arrow ([tasksControlLabels]); the foot's job is the key. +func tasksSortHint(by tasksSort) string { return tasksSortKeyChord + " sort" } // ── the reason, off the row and under the cursor ──────────────────────────── From 9e150ccd236b6c00bcbad08553503f5a21fdfe33 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:20:00 -0400 Subject: [PATCH 17/20] the page's two keys are named on the frame the place opens on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conversation's row returns early from the foot, and with every fold now shut that row is where the cursor lands first — so the drive found a page naming neither of its own keys. Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 52 +++++++++++++++++--------------- internal/tui3/taskstable_test.go | 27 +++++++++++++++++ 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index 362870e552..b499c00db9 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1463,10 +1463,7 @@ func (p *tasksPlace) hint(a *app) string { if word := a.taskSheetFoldWord(); word != "" { parts = append(parts, word) } - if a.taskSheetFiltering() { - parts = append(parts, tasksClearFilterWord) - } - return strings.Join(parts, railSep) + return strings.Join(a.tasksPageKeys(parts), railSep) } item, ok := a.taskSheetCurrent() switch { @@ -1507,29 +1504,36 @@ func (p *tasksPlace) hint(a *app) string { } parts = append(parts, tasksVerbsWord+strings.Join(words, tasksVerbGap)) } - // AND LAST, THE TWO THINGS THE KEYBOARD DOES TO THE WHOLE PAGE. They are last - // because every clause before them is about the row under the cursor and - // these two are about the page; they are here AT ALL because of the ruling of - // 2026-09-11, which is worth stating in full. - // - // THE FILTER OWNS EVERY PRINTABLE KEY ON THIS PAGE, so sorting cannot be `s` - // — a bare `s` would cost `sweep`, `stop` and `site` — and it is `alt+s`. A - // chord nobody can find is a chord that does not exist, so the foot names it - // and names the key it is on. And the other half has to be named beside it: - // the control row draws the box and the arrow, but nothing on the frame says - // that a letter goes INTO that box rather than to the page's own keys. - // - // WHILE A FILTER IS ON, THE SECOND CLAUSE IS THE ONE THAT MOVED — that esc - // now means the filter and not the page, which is the one fact the box - // itself cannot show — and inviting somebody to type a filter they have - // already typed would be the frame naming one thing twice on one screen. + return strings.Join(a.tasksPageKeys(parts), railSep) +} + +// tasksPageKeys puts THE TWO THINGS THE KEYBOARD DOES TO THE WHOLE PAGE on the +// end of the foot's row clauses. They are last because every clause before them +// is about the row under the cursor and these two are about the page. +// +// IT IS ONE FUNCTION BECAUSE THE FOOT HAS TWO ROADS THROUGH IT. A conversation's +// row returns early — its clauses are its own — and with every fold now opening +// shut the cursor's FIRST resting place is a conversation, so a page key named +// only on the other road would be named on no frame a person meets first. The +// tmux drive is what found that: the page as it opens said neither key. +// +// THE FILTER OWNS EVERY PRINTABLE KEY ON THIS PAGE, so sorting cannot be `s` — a +// bare `s` would cost `sweep`, `stop` and `site` — and it is `alt+s` (the ruling +// of 2026-09-11). A chord nobody can find is a chord that does not exist, so the +// foot names it. And the other half has to be named beside it: the control row +// draws the box, but nothing else on the frame says that a letter goes INTO that +// box rather than to the page's own keys. +// +// WHILE A FILTER IS ON, THE SECOND CLAUSE IS THE ONE THAT MOVED — that esc now +// means the filter and not the page, which is the one fact the box itself cannot +// show — and inviting somebody to type a filter they have already typed would be +// the frame naming one thing twice on one screen. +func (a *app) tasksPageKeys(parts []string) []string { parts = append(parts, tasksSortHint(a.taskSheet.order)) if a.taskSheetFiltering() { - parts = append(parts, tasksClearFilterWord) - } else { - parts = append(parts, tasksFilterHint) + return append(parts, tasksClearFilterWord) } - return strings.Join(parts, railSep) + return append(parts, tasksFilterHint) } func (a *app) taskSheetKeysLine() string { return a.taskSheet.hint(a) } diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go index 37451cc0b1..e78ed3c837 100644 --- a/internal/tui3/taskstable_test.go +++ b/internal/tui3/taskstable_test.go @@ -330,6 +330,33 @@ func TestAPressOnTheCostLabelSortsTheListByCost(t *testing.T) { } } +// THE PAGE'S OWN TWO KEYS ARE NAMED ON EVERY FRAME, INCLUDING THE ONE IT OPENS +// ON. The foot has two roads through it — a conversation's row returns early +// with its own clauses — and with every fold opening shut the cursor's first +// resting place IS a conversation, so a page key named only on the other road +// would be named on no frame a person meets first. The tmux drive found this: +// the tasks place as it opens said neither key. +func TestTheTasksFootNamesThePagesKeysOverAConversationToo(t *testing.T) { + a := tasksTableApp(t) + if _, ok := a.taskSheetChat(); !ok { + t.Fatalf("the page did not open with the cursor on a conversation: %d", a.taskSheet.cursor) + } + got := a.taskSheetKeysLine() + for _, want := range []string{tasksSortHint(a.taskSheet.order), tasksFilterHint} { + if !strings.Contains(got, want) { + t.Fatalf("over a conversation the foot reads\n %q\nand never names %q", got, want) + } + } + // AND THE CLAUSE THAT MOVES WITH A FILTER MOVES ON THIS ROAD TOO. + a.taskSheet.query.setText("pages") + a.taskSheetTyped() + a.taskSheet.cursor = tasksPointAtRoot(t, a, "the pricing page") + if got := a.taskSheetKeysLine(); !strings.Contains(got, tasksClearFilterWord) || + strings.Contains(got, tasksFilterHint) { + t.Fatalf("a filtered foot over a conversation reads %q", got) + } +} + // ── the folds ─────────────────────────────────────────────────────────────── // EVERY CONVERSATION AND EVERY FAMILY OPENS SHUT (owner, 2026-09-11), `→` opens From b1f71ec8e3df76c62acd90865548917152b5e702 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:24:39 -0400 Subject: [PATCH 18/20] the grown line starts where its row's name starts Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 14 ++++++++------ internal/tui3/taskstable_test.go | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index b499c00db9..afe1ed393c 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1295,9 +1295,14 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { // frame). It is reserved BEFORE the window is placed rather than squeezed in // after: a line added afterwards would push the last row of the list off the // frame, and the row it pushes off is sometimes the cursor's own. - grown := "" + grown, grownIndent := "", "" if item, ok := r.at(lines, p.cursor); ok && tasksReasonShowing(a) { - grown = tasksReasonLine(item, width-len(tasksBareLead)-taskSheetPhoneIndent, a.pal) + // AND IT STARTS WHERE THE ROW'S NAME STARTS. It is the row's own second + // line, not a line of the page: drawn flush left under a worker five + // levels down a family it reads as a peer of the section heading, which + // is the one thing about it a person has to get right at a glance. + grownIndent = tasksBareLead + strings.Repeat(" ", ansi.StringWidth(lines[p.cursor].kin)+taskSheetPhoneIndent) + grown = tasksReasonLine(item, width-ansi.StringWidth(grownIndent), a.pal) } // The grown line costs the LIST a row and costs the FRAME nothing: the window // holds one line less of the record, and the row it gives up is spent on the @@ -1352,10 +1357,7 @@ func (p *tasksPlace) body(a *app, width, room int) []placeRow { // AND THE GROWN LINE BELONGS TO THE ROW ABOVE IT. It answers to the same // press and it is never faded, because it is part of the row the cursor is // standing on rather than a row of its own. - rows = append(rows, placeRow{ - text: tasksBareLead + strings.Repeat(" ", taskSheetPhoneIndent) + grown, - hit: hit, - }) + rows = append(rows, placeRow{text: grownIndent + grown, hit: hit}) bare = append(bare, false) } for i := range bare { diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go index e78ed3c837..e221b32ee4 100644 --- a/internal/tui3/taskstable_test.go +++ b/internal/tui3/taskstable_test.go @@ -300,6 +300,21 @@ func TestAPressOnTheCostLabelSortsTheListByCost(t *testing.T) { if !a.taskSheet.order.back { t.Fatal("a press on the column already sorted did not turn it round") } + // AND THE SECOND LABEL IS THE SORT KEY'S OWN COLUMN WHATEVER IT IS SHOWING: + // walked round to cost, a press on `cost ↓` is a press on cost. + for i := 0; i < int(tasksSortKeyCount)+1 && a.taskSheet.order.key != tasksByCost; i++ { + drive(t, a, key(tasksSortKeyChord)) + } + if _, second := tasksControlLabels(a.taskSheet.order); second != "cost "+tasksSortDown { + t.Fatalf("the page is not on cost: the second label reads %q", second) + } + a.taskSheetPress(x, y) + if now := a.taskSheet.order; now.key != tasksByCost || !now.back { + t.Fatalf("a press on the cost label left the page on %q back=%v", now.key.word(), now.back) + } + a.taskSheetSortBy(tasksByAge) + a.taskSheetSortBy(tasksByAge) + // AND THE STATE LABEL BESIDE IT IS ITS OWN COLUMN. a.taskSheetPress(len(tasksBareLead)+nameCells, y) if a.taskSheet.order.key != tasksByState { From b7c993e95e3f04a6dafcdd578f74ba85ca052f7a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 13:48:35 -0400 Subject: [PATCH 19/20] the table is measured on the list's width, and the pointer asks the list too Two defects with one cause: the columns were asked of what each row's own lead left, so a worker crossed the ninety-cell floor a few cells before the root over it and drew no state column under one that did; and the control row's hit map was asked of the FRAME, so on a frame split for the pane every label sat fifty cells from the word a person was pointing at and no click sorted anything. Co-Authored-By: Claude Opus 5 --- internal/tui3/place_tasks.go | 2 +- internal/tui3/tasksplace.go | 22 ++-- internal/tui3/taskstable.go | 27 +++-- internal/tui3/taskstable_test.go | 184 ++++++++++++++++++++----------- 4 files changed, 145 insertions(+), 90 deletions(-) diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index afe1ed393c..155d63ad11 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -1136,7 +1136,7 @@ func (a *app) taskSheetPress(x, y int) tea.Cmd { // through [tasksControlHit]) — a pointer with its own idea of where a column // sits is exactly how a click comes to sort by the wrong thing. if hits[y].kind == taskSheetHitControl { - if key, ok := tasksControlHit(x-len(tasksBareLead), width-len(tasksBareLead), a.taskSheet.order); ok { + if key, ok := tasksControlHit(x, a.taskSheetListWidth(), a.taskSheet.order); ok { a.taskSheetSortBy(key) } return nil diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 1a3af97921..a86b3bab93 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -1309,7 +1309,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo case tasksLineAir: return "" case tasksLineControl: - return lead + tasksControlRow(r.query, r.order, room, pal) + return tasksControlRow(r.query, r.order, width, pal) case tasksLineWord: if i == 0 { // THE HEAD LINE IS THE WINDOW'S CONTROL TOO (SCREEN 3d), drawn by the @@ -1321,7 +1321,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo } return placeLead + placeHeading(fit(line.text, width-len(placeLead)), pal) case tasksLineChat: - return lead + tasksChatRow(line, room, r.now, r.folder, r.tilde, r.order, pal, lit) + return tasksChatRow(line, width, r.now, r.folder, r.tilde, r.order, pal, lit) case tasksLineTail: indent := strings.Repeat(" ", taskSheetPhoneIndent) tail := room - taskSheetPhoneIndent - ansi.StringWidth(line.kin) @@ -1341,7 +1341,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo } return lead + pal.dim(line.kin) + tasksCardHead(line.item, card, pal, lit) } - return lead + tasksRow(line, room, r.now, r.order, pal, lit) + return tasksRow(line, width, r.now, r.order, pal, lit) } // tasksUnderWord is what a shut fold says about the work it is holding. @@ -1645,7 +1645,7 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string // A ROOT HAS NO MARK and its name starts where the marks are, which is the // rule (spec.md §2): a conversation is not a piece of work and has no state // of its own to wear one for. - return tasksTableRow(pal.dim(line.kin), ansi.StringWidth(line.kin), name, + return tasksTableRow(tasksBareLead+pal.dim(line.kin), ansi.StringWidth(tasksBareLead+line.kin), name, tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit) } @@ -1660,15 +1660,17 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string // stands for — and the layout is where all three were decided // ([tasksReading.lay]); the paint may not re-derive any of them. // -// IT FITS AGAINST THE ROOM AND NOT THE FRAME: the caller has already spent the -// place's left edge and the family column, and the two-line phone lane is -// [tasksCardHead]'s ([tasksReading.paint] routes to it before this is called), so -// the tier question is settled before we are here. +// IT IS HANDED THE LIST'S WHOLE WIDTH AND SPENDS ITS OWN LEAD — the place's left +// edge, the family column and the mark — out of the name, so the columns are +// asked of one width for every row of the frame and the 90-cell floor means the +// list is ninety cells wide ([tasksTableRow]). The two-line phone lane is +// [tasksCardHead]'s ([tasksReading.paint] routes to it before this is called), +// so the tier question is settled before we are here. func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palette, lit bool) string { item := line.item glyph, glyphInk := tasksGlyph(item, pal) - lead := pal.dim(line.kin) + glyphInk(glyph) + " " - cells := ansi.StringWidth(line.kin) + ansi.StringWidth(glyph) + 1 + lead := tasksBareLead + pal.dim(line.kin) + glyphInk(glyph) + " " + cells := ansi.StringWidth(tasksBareLead+line.kin) + ansi.StringWidth(glyph) + 1 return tasksTableRow(lead, cells, tasksLabel(item.entry), tasksStateField(line), tasksKeyField(by.key, line.rank, now), tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit) diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 6d79493703..04bfe95084 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -189,8 +189,8 @@ func tasksChatStateField(chat tasksChat) rowField { // columns move is a tail with extra steps. Only the NAME flexes (rowfit.go law // 1), and the lead eats into the name. func tasksTableRow(lead string, leadCells int, name string, state, second rowField, - secondInk func(string) string, room int, key tasksSortKey, pal palette, lit bool) string { - stateCells, secondCells, nameCells := tasksColumns(room, key) + secondInk func(string) string, width int, key tasksSortKey, pal palette, lit bool) string { + stateCells, secondCells, nameCells := tasksColumns(width, key) nameCells = max(nameCells-leadCells, 1) said := fit(name, nameCells) out := lead + placeSubject(said, lit, pal) + pad(nameCells-ansi.StringWidth(said)) @@ -265,15 +265,17 @@ func tasksControlLabels(by tasksSort) (state, second string) { // tasksControlRow is that line: the mark, then what has been typed or the dim // invitation to type it, and at the right the two labels. -func tasksControlRow(query string, by tasksSort, room int, pal palette) string { +func tasksControlRow(query string, by tasksSort, width int, pal palette) string { stateLabel, secondLabel := tasksControlLabels(by) mark := pal.glyph(tokens.GFilter) // THE BOX IS LAID OUT WHERE THE NAMES ARE AND THE LABELS OVER THEIR OWN - // COLUMNS, both out of [tasksColumns] — so the label a person clicks and the - // cells it stands over are the same cells at every width, and the two lines - // cannot drift apart as the frame moves. - stateCells, secondCells, nameCells := tasksColumns(room, by.key) - boxCells := max(nameCells-ansi.StringWidth(mark)-1, 1) + // COLUMNS, both out of [tasksColumns] asked of the same LIST width every row + // is — so the label a person clicks and the cells it stands over are the same + // cells at every width, and the two lines cannot drift apart as the frame + // moves. The place's left edge is spent out of the box, exactly as a row + // spends it out of the name. + stateCells, secondCells, nameCells := tasksColumns(width, by.key) + boxCells := max(nameCells-ansi.StringWidth(tasksBareLead)-ansi.StringWidth(mark)-1, 1) // WHAT IS TYPED IS IN THE READING INK AND THE INVITATION IS DIM. A person has // to be able to tell the words they typed from the words the box came with. // The invitation is [tasksFilterHint] and not the foot's longer sentence @@ -283,7 +285,7 @@ func tasksControlRow(query string, by tasksSort, room int, pal palette) string { if query != "" { said, ink = fit(query, boxCells), pal.ink } - out := pal.dim(mark) + " " + ink(said) + pad(boxCells-ansi.StringWidth(said)) + out := tasksBareLead + pal.dim(mark) + " " + ink(said) + pad(boxCells-ansi.StringWidth(said)) if stateCells > 0 { label := fit(stateLabel, stateCells) out += pal.dim(label) + pad(stateCells-ansi.StringWidth(label)) @@ -303,9 +305,10 @@ func tasksControlRow(query string, by tasksSort, room int, pal palette) string { // is exactly how a click comes to sort by the wrong thing, which is the argument // the frame and the hit map are one function for (place_tasks.go). // -// `x` is the cell inside the ROW — the place's left edge already spent. -func tasksControlHit(x, room int, by tasksSort) (tasksSortKey, bool) { - stateCells, secondCells, nameCells := tasksColumns(room, by.key) +// `x` is the cell of the LIST's line and `width` the list's own width — which on +// a frame split for the pane is not the frame's ([app.taskSheetListWidth]). +func tasksControlHit(x, width int, by tasksSort) (tasksSortKey, bool) { + stateCells, secondCells, nameCells := tasksColumns(width, by.key) switch { case stateCells > 0 && x >= nameCells && x < nameCells+stateCells: return tasksByState, true diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go index e221b32ee4..ca4746ea60 100644 --- a/internal/tui3/taskstable_test.go +++ b/internal/tui3/taskstable_test.go @@ -123,6 +123,55 @@ func TestTheTasksTableHoldsItsColumnsToTheCellAtEveryWidth(t *testing.T) { } } +// A TABLE'S COLUMNS DO NOT MOVE. Every row of one frame — a conversation with no +// mark, a worker four levels down a family, a loner — puts its `state` cell in +// the same cells and ends on the same cell. The row's lead is spent out of the +// NAME ([tasksTableRow]); a row that measured its columns from what its own lead +// left put them one or two cells further left on every level of a family, which +// is the defect this law was written after. +func TestEveryRowOfOneFramePutsItsColumnsInTheSameCells(t *testing.T) { + world, win, now := tasksTableFixture() + reading := tasksOpen(readTasks(world, tasksMine{}, win, tasksSort{}, time.Time{}, now)) + pal := newPalette(tokens.NoColor, false) + // EVERY WIDTH FROM THE PHONE'S EDGE UP, because the defect lived at the + // floor: a row that asked [tasksColumns] of what its own lead left crossed + // the ninety-cell line a few cells before a root did, so on a frame a little + // over ninety a worker drew no state column under a conversation that did. + for width := 60; width <= 130; width++ { + stateCells, _, nameCells := tasksColumns(width, tasksByAge) + if stateCells == 0 { + continue + } + start := nameCells + lines := reading.lay(width) + checked := 0 + for i, line := range lines { + var want string + switch line.kind { + case tasksLineTask: + want = rowTail([]rowField{tasksStateField(line)}, stateCells) + case tasksLineChat: + want = rowTail([]rowField{tasksChatStateField(line.chat)}, stateCells) + default: + continue + } + row := []rune(plain(reading.paint(lines, i, width, pal, false))) + if len(row) != width { + t.Fatalf("at %d cells a row is %d cells wide:\n %q", width, len(row), string(row)) + } + cell := strings.TrimRight(string(row[start:start+stateCells]), " ") + if cell != strings.TrimSpace(want) || row[start-1] != ' ' { + t.Fatalf("at %d cells the state column of\n %q\nreads %q at cell %d, want %q", + width, string(row), cell, start, want) + } + checked++ + } + if checked < 8 && width == 90 { + t.Fatalf("at %d cells the law was asked of %d rows, and the fixture has three conversations and six rows of work", width, checked) + } + } +} + // THE STATE CELL IS NEVER BLANK ON A ROW OF WORK. It is the one thing this list // is read for, and a hole in that column is a row a person has to open to find // out whether it wants them. @@ -272,77 +321,78 @@ func TestSortingByCostOrdersEveryLevelAndSaysSo(t *testing.T) { // this page goes into the filter, so a bare `s` would cost a person `sweep`, // `stop` and `site`. func TestAPressOnTheCostLabelSortsTheListByCost(t *testing.T) { - a := tasksTableApp(t) - if a.taskSheet.order.key != tasksByAge { - t.Fatalf("the page opens sorted by %q", a.taskSheet.order.key.word()) + // AT A FRAME THE PANE SPLITS AND AT ONE IT DOES NOT. The list is drawn in 72 + // of 122 cells while the pane stands beside it, and a hit map that measured + // its columns from the FRAME put every label fifty cells to the right of the + // word a person was pointing at — so at 122 no click on a label sorted + // anything. The press here is at the cell the label is PAINTED in. + for _, width := range []int{122, 100} { + a := tasksTableApp(t) + a.width = width + if a.taskSheet.order.key != tasksByAge { + t.Fatalf("the page opens sorted by %q", a.taskSheet.order.key.word()) + } + // A PRESS ON THE COLUMN ALREADY SORTED TURNS IT ROUND. + x, y := tasksLabelAt(t, a, tasksByAge.word()) + a.taskSheetPress(x, y) + if now := a.taskSheet.order; now.key != tasksByAge || !now.back { + t.Fatalf("at %d cells a press on the age label left the page on %q back=%v", width, now.key.word(), now.back) + } + // AND THE STATE LABEL BESIDE IT IS ITS OWN COLUMN — where there is one. + if stateCells, _, _ := tasksColumns(a.taskSheetListWidth(), tasksByAge); stateCells > 0 { + x, y = tasksLabelAt(t, a, tasksByState.word()) + a.taskSheetPress(x, y) + if a.taskSheet.order.key != tasksByState { + t.Fatalf("at %d cells a press on the state label sorted by %q", width, a.taskSheet.order.key.word()) + } + } + // AND THE SECOND LABEL IS THE SORT KEY'S OWN COLUMN WHATEVER IT IS + // SHOWING: walked round to cost with the chord, a press on `cost ↓` is a + // press on cost. The chord and the pointer are one door. + for i := 0; i < int(tasksSortKeyCount)+1 && a.taskSheet.order.key != tasksByCost; i++ { + drive(t, a, key(tasksSortKeyChord)) + } + if _, second := tasksControlLabels(a.taskSheet.order); second != "cost "+tasksSortDown { + t.Fatalf("%q never reached cost: the second label reads %q", tasksSortKeyChord, second) + } + x, y = tasksLabelAt(t, a, tasksByCost.word()) + a.taskSheetPress(x, y) + if now := a.taskSheet.order; now.key != tasksByCost || !now.back { + t.Fatalf("at %d cells a press on the cost label left the page on %q back=%v", width, now.key.word(), now.back) + } + // AND THE OTHER CHORD TURNS THE COLUMN THE PAGE IS ON ROUND, rather than + // walking back a key: what a person means by shift here is "the other + // way", not "the previous column". + was := a.taskSheet.order + drive(t, a, key(tasksSortBackChord)) + if now := a.taskSheet.order; now.key != was.key || now.back == was.back { + t.Fatalf("%q left the page sorted by %q back=%v, want cost the other way round", + tasksSortBackChord, now.key.word(), now.back) + } } +} + +// tasksLabelAt is the screen cell a column label is PAINTED in, found on the +// control row of the frame the place really drew — which is the only honest +// place for a pointer test to aim: the arithmetic is what is under test. +func tasksLabelAt(t *testing.T, a *app, label string) (int, int) { + t.Helper() width, height := a.size() lines, hits, _, _ := a.taskSheetFrame(width, height) - y := -1 - for at, hit := range hits { - if hit.kind == taskSheetHitControl { - y = at - break - } - } - if y < 0 { - t.Fatalf("the control row answers no press:\n%s", strings.Join(lines, "\n")) - } - // The cell the `age` label is drawn in, asked of the hit map the way the row - // itself is laid out. - room := width - len(tasksBareLead) - stateCells, secondCells, nameCells := tasksColumns(room, a.taskSheet.order.key) - x := len(tasksBareLead) + nameCells + stateCells + secondCells - 1 - a.taskSheetPress(x, y) - if a.taskSheet.order.key != tasksByAge { - t.Fatalf("a press on the age label sorted by %q", a.taskSheet.order.key.word()) - } - if !a.taskSheet.order.back { - t.Fatal("a press on the column already sorted did not turn it round") - } - // AND THE SECOND LABEL IS THE SORT KEY'S OWN COLUMN WHATEVER IT IS SHOWING: - // walked round to cost, a press on `cost ↓` is a press on cost. - for i := 0; i < int(tasksSortKeyCount)+1 && a.taskSheet.order.key != tasksByCost; i++ { - drive(t, a, key(tasksSortKeyChord)) - } - if _, second := tasksControlLabels(a.taskSheet.order); second != "cost "+tasksSortDown { - t.Fatalf("the page is not on cost: the second label reads %q", second) - } - a.taskSheetPress(x, y) - if now := a.taskSheet.order; now.key != tasksByCost || !now.back { - t.Fatalf("a press on the cost label left the page on %q back=%v", now.key.word(), now.back) - } - a.taskSheetSortBy(tasksByAge) - a.taskSheetSortBy(tasksByAge) - - // AND THE STATE LABEL BESIDE IT IS ITS OWN COLUMN. - a.taskSheetPress(len(tasksBareLead)+nameCells, y) - if a.taskSheet.order.key != tasksByState { - t.Fatalf("a press on the state label sorted by %q", a.taskSheet.order.key.word()) - } - // AND THE CHORD REACHES COST, which no label on an age-sorted page is - // standing over — the two doors are the same door. - for i := 0; i < int(tasksSortKeyCount)+1; i++ { - if a.taskSheet.order.key == tasksByCost { - break - } - drive(t, a, key(tasksSortKeyChord)) - } - if a.taskSheet.order.key != tasksByCost { - t.Fatalf("%q never reached cost: it is on %q", tasksSortKeyChord, a.taskSheet.order.key.word()) - } - if _, second := tasksControlLabels(a.taskSheet.order); second != "cost "+tasksSortDown { - t.Fatalf("sorted by cost the second label reads %q", second) - } - // AND THE OTHER CHORD TURNS THE COLUMN THE PAGE IS ON ROUND, rather than - // walking back a key: what a person means by shift here is "the other way", - // not "the previous column". - was := a.taskSheet.order - drive(t, a, key(tasksSortBackChord)) - if now := a.taskSheet.order; now.key != was.key || now.back == was.back { - t.Fatalf("%q left the page sorted by %q back=%v, want cost the other way round", - tasksSortBackChord, now.key.word(), now.back) + for y, hit := range hits { + if hit.kind != taskSheetHitControl { + continue + } + row := []rune(plain(lines[y])) + for x := 0; x+len(label) <= len(row); x++ { + if string(row[x:x+len(label)]) == label { + return x + 1, y + } + } + t.Fatalf("the control row does not draw %q:\n %s", label, string(row)) } + t.Fatalf("the control row answers no press:\n%s", strings.Join(lines, "\n")) + return 0, 0 } // THE PAGE'S OWN TWO KEYS ARE NAMED ON EVERY FRAME, INCLUDING THE ONE IT OPENS From ee61a5686a2685b4f9616ac1a203564223e197b0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 14:06:23 -0400 Subject: [PATCH 20/20] the review's findings: a conjunct that could not fire, a stale parameter name, three dead cross-references, and the roster's own paragraph Co-Authored-By: Claude Opus 5 --- internal/manual/chat/keys.md | 5 +++-- internal/tui3/taskrecord.go | 3 ++- internal/tui3/tasksconversation_test.go | 10 +++++++--- internal/tui3/tasksection_attention_test.go | 2 +- internal/tui3/tasksplace.go | 4 ++-- internal/tui3/tasksplace_test.go | 6 ++++-- internal/tui3/taskstable.go | 19 ++++++++++++------- internal/tui3/taskstable_test.go | 10 +++++++--- 8 files changed, 38 insertions(+), 21 deletions(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 8c4c80d10a..59633f9d50 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2186,8 +2186,9 @@ column actually drew. Their keyboard equivalents for standing are the commands t `+ /task` puts in the box. **No new key is added to the column by either of them.** `enter` on the `jobs` label toggles the section; `enter` on a job row opens that job's page. -**Under 60 columns the roster page is a thumb's, not a keyboard's.** Its rows are two-line -cards a tap opens, its foot is a `‹ back` bar in place of the key legend +**Under 60 columns the TASKS PAGE this column reaches is a thumb's, not a keyboard's** — the +column itself is unchanged, and its own keys are the ones above. The page's rows become +two-line cards a tap opens, its foot is a `‹ back` bar in place of the key legend `enter open its room · alt+s sort · type to filter`, and the strip that opens it is one full-width door (`▸ 3 tasks · 1 running`) rather than a row of chips. Mouse motion is ignored — a tap opens in one gesture. The tasks page describes the phone flow in full. diff --git a/internal/tui3/taskrecord.go b/internal/tui3/taskrecord.go index a67efae431..9a29935051 100644 --- a/internal/tui3/taskrecord.go +++ b/internal/tui3/taskrecord.go @@ -980,7 +980,8 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { var facts []string // WHERE IT CAME OUT OF, because the page a person opens to learn MORE may // not know less than the row they opened it from. The row carries the - // conversation (tasksplace.go's [tasksFacts]); the card dropped it, and on a + // conversation (tasksplace.go's [tasksChatRow] names it on the root the work + // hangs under); the card dropped it, and on a // machine with three projects and twelve conversations `Port the picker onto // the new list` alone does not say which codebase it touched. if line := a.taskCardSourceLine(entry); line != "" { diff --git a/internal/tui3/tasksconversation_test.go b/internal/tui3/tasksconversation_test.go index 1c885ebc34..1112fc3576 100644 --- a/internal/tui3/tasksconversation_test.go +++ b/internal/tui3/tasksconversation_test.go @@ -299,7 +299,11 @@ func TestAConversationStandsUnderItsMostUrgentWorkWithoutRefilingIt(t *testing.T // read one level of family and stopped. func TestWorkNestsToWhateverDepthTheRecordCarries(t *testing.T) { r := tasksChatReading() + // The conversation and the two families are opened by hand: everything on + // this page opens shut ([tasksReading.opens]) and the claim here is about the + // DEPTH under them. r.open = map[tasksKey]bool{ + tasksChatKey("room-a"): true, {session: "room-a", id: "2"}: true, {session: "room-a", id: "3"}: true, } @@ -325,8 +329,8 @@ func TestWorkNestsToWhateverDepthTheRecordCarries(t *testing.T) { t.Fatalf("a worker with workers of its own wears %q rather than its fold", lexer.kin) } // SHUT AGAIN, WHAT IS BEHIND THE FOLD IS BEHIND IT — at every depth. - r.open = map[tasksKey]bool{{session: "room-a", id: "2"}: true} - page := tasksPage(r, 120) + r.open = map[tasksKey]bool{tasksChatKey("room-a"): true, {session: "room-a", id: "2"}: true} + page := tasksPageFolded(r, 120) if !strings.Contains(page, "port the lexer") || strings.Contains(page, "port the token table") { t.Fatalf("a shut worker did not take its own workers with it:\n%s", page) } @@ -495,7 +499,7 @@ func TestAnOrphanedChildIsStillDrawn(t *testing.T) { if !strings.Contains(page, "the worker whose run is gone") { t.Fatalf("an orphan is off the page entirely:\n%s", page) } - line := tasksLineOf(t, r.lay(120), "the worker whose run is gone") + line := tasksLineOf(t, tasksOpen(r).lay(120), "the worker whose run is gone") if strings.Contains(line.kin, tasksKinCont) || strings.Contains(line.kin, tasksKinLast) { t.Fatalf("an orphan is drawn under a row that is not there: kin=%q", line.kin) } diff --git a/internal/tui3/tasksection_attention_test.go b/internal/tui3/tasksection_attention_test.go index d1491a3b4b..495f55b8ee 100644 --- a/internal/tui3/tasksection_attention_test.go +++ b/internal/tui3/tasksection_attention_test.go @@ -217,7 +217,7 @@ func TestWorkNobodyCouldCheckStillNeedsYourLook(t *testing.T) { if got := tasksSectionLabels(reading, tasksNeeds); !tasksSame(got, []string{"verify the pro model's pricing"}) { t.Fatalf("work nobody could check is not under %q: %v", tasksSectionWord(tasksNeeds), got) } - page := strings.Join(reading.rows(120, newPalette(tokens.NoColor, false)), "\n") + page := strings.Join(tasksOpen(reading).rows(120, newPalette(tokens.NoColor, false)), "\n") if !strings.Contains(page, tokens.GlyphNeedsHuman+" verify the pro model's pricing") { t.Fatalf("the row needing a decision did not wear %q:\n%s", tokens.GlyphNeedsHuman, page) } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index a86b3bab93..09a3b8bd12 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -377,7 +377,7 @@ func tasksNewer(a, b tasksItem, now time.Time) bool { // is running opened `out of `, which is the // one sentence on that page a person would act on, and it named the wrong owner. // A row with nothing behind it is the emptiness law's own answer, and every -// reader of this already drops an empty title ([tasksFacts], +// reader of this already drops an empty title ([tasksChatRow], // [app.taskCardSourceLine]). func tasksRowFor(world session.World, mine tasksMine, entry session.TaskIndexEntry) session.SessionRow { id := strings.TrimSpace(entry.SessionID) @@ -502,7 +502,7 @@ type tasksLine struct { chat tasksChat // under says THE CONVERSATION THIS ROW CAME OUT OF IS ALREADY NAMED ABOVE IT, // by the chat row it hangs under or by the piece of work it was cut out of. - // The row then spends those cells on its own name instead ([tasksFacts]). + // The row then spends those cells on its own name instead ([tasksChatRow]). under bool // owner is the line index of the task this line belongs to, and -1 on prose // and air. It is what the cursor stands on, what a press resolves to, and diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index e1dc787a2d..7574fc21cb 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -295,7 +295,7 @@ func TestOnePieceOfWorkIsDrawnOnceAcrossEveryAuthority(t *testing.T) { // here to open and nothing landed for a mention to point at — what the row // opens is the card that says so ([tasksItem.pick] states the reversal) — and // a row a person can see and cannot aim at is the defect that produced. - lines := reading.lay(120) + lines := tasksOpen(reading).lay(120) stops := 0 for i := range lines { if _, ok := reading.at(lines, i); ok { @@ -503,7 +503,9 @@ func TestARunningParentKeepsItsRefusedChildUnderIt(t *testing.T) { t.Fatalf("family member %q was filed under %q, want running", item.entry.Label, tasksSectionWord(item.section)) } } - reading.open = map[tasksKey]bool{tasksFamilyOf(rows[0]): true} + // The conversation over the family is opened by hand with it; what this test + // is about is what stands UNDER the running parent. + reading.open = map[tasksKey]bool{tasksChatKey("room-a"): true, tasksFamilyOf(rows[0]): true} found := false for _, line := range reading.lay(120) { if line.kind == tasksLineTask && line.item.entry.ID == "2" { diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 04bfe95084..9df9188e2b 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -67,14 +67,19 @@ const ( // the cells it stands over have to be the same cells, and two answers to where // the `cost` column is is a click that sorts by the wrong thing. // -// `room` is the cells the ROW has, after the place's left edge and the family -// column in front of it have been spent. -func tasksColumns(room int, key tasksSortKey) (state, second, name int) { +// `width` is THE LIST'S WHOLE WIDTH, with nothing spent out of it yet. Every +// caller passes that same figure — the paint, the control row over it and the +// pointer resolving a press — and a row's own lead comes out of the NAME it +// returns ([tasksTableRow] says why). Asked of what one row's lead left, the +// ninety-cell floor would fall on a worker four levels down a family a few cells +// before it fell on the conversation standing over it, and one frame would draw +// the state column on some of its rows. +func tasksColumns(width int, key tasksSortKey) (state, second, name int) { second = key.cells() - if room >= tasksStateFloor { + if width >= tasksStateFloor { state = tasksStateCells } - if name = room - state - second - tasksColumnAir; name >= tasksNameFloor { + if name = width - state - second - tasksColumnAir; name >= tasksNameFloor { return state, second, name } // A FRAME WITH NO ROOM FOR A NAME DROPS THE COLUMNS AND KEEPS THE NAME, in @@ -82,11 +87,11 @@ func tasksColumns(room int, key tasksSortKey) (state, second, name int) { // it has not named has said nothing at all (rowfit.go, law 1). if state > 0 { state = 0 - if name = room - second - tasksColumnAir; name >= tasksNameFloor { + if name = width - second - tasksColumnAir; name >= tasksNameFloor { return state, second, name } } - if name = room - tasksColumnAir; name < 1 { + if name = width - tasksColumnAir; name < 1 { name = 1 } return 0, 0, name diff --git a/internal/tui3/taskstable_test.go b/internal/tui3/taskstable_test.go index ca4746ea60..baeea29c02 100644 --- a/internal/tui3/taskstable_test.go +++ b/internal/tui3/taskstable_test.go @@ -304,9 +304,13 @@ func TestSortingByCostOrdersEveryLevelAndSaysSo(t *testing.T) { back = append(back, tasksLabel(line.item.entry)) } } - if strings.Join(back, " / ") != "pages one two / put the annual toggle up" && - back[len(back)-1] != "check the copy" { - t.Fatalf("reversed, the conversation reads %q", strings.Join(back, " / ")) + // THE TWO CLAIMS ARE TWO ASSERTIONS. The priced rows swap, and the unpriced + // one stays at the bottom — and they are checked apart because they are + // independent: the sink is the same way up whichever way the column points + // ([tasksSortKey.less]), so an `&&` over the pair would be an order check + // that could never fire. + if want := "pages one two / put the annual toggle up"; !strings.HasPrefix(strings.Join(back, " / "), want) { + t.Fatalf("reversed, the conversation reads %q, want the cheapest first", strings.Join(back, " / ")) } if back[len(back)-1] != "check the copy" { t.Fatalf("reversed, the row nobody priced came off the bottom: %q", strings.Join(back, " / "))