From 2d32782c9bcc094ff73e7f5e94476fbf50d8878c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:23:55 -0400 Subject: [PATCH 1/8] =?UTF-8?q?home:=20`needs=20you`=20becomes=20two=20gro?= =?UTF-8?q?ups=20=E2=80=94=20the=20questions=20that=20stopped=20something,?= =?UTF-8?q?=20then=20the=20landings=20to=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP checkpoint on the lane branch. The panel gains a group mechanism ([homePanelGroup]): a run of rows at the foot of a panel drawn under a dim line of its own, which folds before any row above it gives way. Co-Authored-By: Claude Opus 5 --- internal/tui3/homecell.go | 25 ++- internal/tui3/homegrid.go | 276 +++++++++++++++++++---- internal/tui3/homemachine.go | 32 ++- internal/tui3/homepanel_left.go | 10 + internal/tui3/homepanel_live_test.go | 150 ++++++++++++- internal/tui3/homepanel_needs.go | 318 ++++++++++++++++++++++----- internal/tui3/placeprose.go | 22 +- 7 files changed, 704 insertions(+), 129 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 3766d47f63..190770bccd 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -112,6 +112,8 @@ func (a *app) homeLineRows(line homeLine, at, width int, pal palette, heading bo texts, head = []string{homeCellHead(cell, width, pal, heading)}, at case cellWhisper, cellFold: texts = []string{homeCellQuiet(cell, width, pal, lit)} + case cellGroup: + texts = []string{homeCellGroup(cell, width, pal)} case cellBar: texts = []string{homeCellBand(homeCellLeadBlank+homeSpendMeter(cell.share, width-homeGridLead, pal), width, pal, lit)} case cellSpark: @@ -200,6 +202,18 @@ func homeSparkCells(values []float64) []string { return out } +// homeCellGroup is a group's own line inside a panel: its word and count at the +// left and its clause at the right, both dim, under the rows' own lead +// ([homePanelGroup]). +// +// IT IS DIMMER THAN A HEADING ON PURPOSE. A panel's heading is the places' one +// heading ink and marks itself when the cursor is in it ([homeCellHead]); a +// group is a sorting of rows INSIDE one panel, and a second thing on the column +// wearing heading ink would read as a second panel. +func homeCellGroup(cell *homeCell, width int, pal palette) string { + return homeCellLeadBlank + switcherSides(max(1, width-homeGridLead), cell.title, cell.right, pal.dim, pal.dim) +} + // homeCellQuiet is a whisper or a fold: dim words under the rows' own lead. func homeCellQuiet(cell *homeCell, width int, pal palette, lit bool) string { text := homeCellLeadBlank + pal.dim(fit(cell.title, max(0, width-homeGridLead))) @@ -216,15 +230,18 @@ func homeCellBand(text string, width int, pal palette, lit bool) string { } // homeCellRow paints a row and the line under it. +// A ROW THAT GROWS DRAWS ITS SECOND LINE ONLY UNDER THE CURSOR, and the band +// covers both of them: the two lines are one row, and a ground that stopped +// half way would read as two ([homeCell.grows]). func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) []string { cell := line.cell body := homeCellBody(a.homeCellDoor(cell, at, width-homeGridLead), width-homeGridLead, pal, lit) rows := []string{homeCellBand(a.homeCellLead(cell, at, pal)+body, width, pal, lit)} - if cell.sub != "" { - under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line), pal.dim, pal.muted) - rows = append(rows, homeCellLeadBlank+under) + if cell.sub == "" || (cell.grows && at != a.home.cursor) { + return rows } - return rows + under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted) + return append(rows, homeCellBand(homeCellLeadBlank+under, width, pal, lit && cell.grows)) } // homeCellLead is the row's mark and the air after it, or two blank cells. diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index b484a42197..701d6528bc 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -254,6 +254,13 @@ type homeGridInput struct { rows []switcherRow // ledger is the `since you left` lines the switcher reads. ledger []switcherRow + // calls is every landing whose check is the person's, as the `to check` + // group draws them, and older how many aged out of it. IT IS READ ONCE, + // HERE, because three readers need the same answer: the group's rows, the + // `since you left` line that steps aside for a landing already on the + // screen, and the pulse's count (homepanel_needs.go's [needsCallOf]). + calls []needsItem + callsOlder int // world is every project, the ones home knows only through a watch // included ([homeView.everyProject]). world session.World @@ -283,7 +290,9 @@ func (h *homeView) gridInput() homeGridInput { world.Projects = h.everyProject() here := switcherHere{session: h.here, coming: h.claim, hosted: h.far} reading := readSwitcher(world, h.items, h.fired, here, h.gone, h.seen, h.world.Read, h.ledger) - return homeGridInput{rows: reading.rows, ledger: reading.ledger, world: world, items: h.items, + calls, older := needsFresh(needsCalls(world, h.world.Read), h.world.Read) + return homeGridInput{rows: reading.rows, ledger: reading.ledger, calls: calls, callsOlder: older, + world: world, items: h.items, errands: h.switchExchanges(), bucket: h.bucket, launch: h.launch, tilde: h.tilde, last: h.last, repos: h.repos, spend: h.spend, seen: h.seen, now: h.world.Read} } @@ -304,11 +313,42 @@ type homePanelRows struct { // said is what the heading carries after its word — a count, an age — and // "" for the word alone. said string + // group is the run of rows at the FOOT of lines that stands under a dim line + // of its own and folds before any row above it, and nil for a panel whose + // rows are one list ([homePanelGroup]). + group *homePanelGroup // right is a clause the heading carries at its right margin, and money the // figure inside it drawn in the money ink — the day's spend on `spend`. right, money string } +// homePanelGroup is a SECOND HEADING INSIDE ONE PANEL: a run of the panel's own +// rows, drawn under a dim line of their own, which folds to nothing before the +// panel gives up a row above it. +// +// IT IS THE PANEL'S SHAPE SAID AGAIN ONE LEVEL DOWN, deliberately: a panel is a +// heading, its rows and a fold, and a group is a line, its rows and the same +// fold — so a squeeze needs no second rule. The rows sit at the FOOT of +// [homePanelRows.lines], so the ordinary bottom-up cut already takes them first, +// and when none of them is left on the screen the group's line goes and the +// panel's own fold names the group instead of saying `more` +// ([homeGridPanel.fold]). +// +// `needs you` is the only panel with one today: the landings a person has not +// checked, under the live questions that stopped a conversation +// (homepanel_needs.go). +type homePanelGroup struct { + // at is where the group's rows begin in [homePanelRows.lines]; everything + // before it is the panel's own. + at int + // word is the group's name — the left of its line, and the word the panel's + // fold uses instead of `more` while the group is shut. + word string + // said is the count after the word, and right the dim clause the line + // carries at its margin. + said, right string +} + // homeCellKind is which shape one line of a panel is drawn in. type homeCellKind uint8 @@ -319,6 +359,10 @@ const ( cellHead cellWhisper cellFold + // cellGroup is a group's own line inside a panel: its word and count dim at + // the left, its clause dim at the right ([homePanelGroup]). It is not a + // stop and it is never lit. + cellGroup // cellBar, cellSpark and cellFacts are the spend panel's three lines: the // day against its allowance, the fortnight, and who it went to and what for // (homepanel_spend.go). @@ -365,8 +409,19 @@ type homeCell struct { hold bool door string // sub is the line under the row, and subRight what that line carries at - // its right — the answers a digit sends. + // its right — the answers a key sends. sub, subRight string + // answers is the clause of chips this row WOULD draw at the right of its + // second line — each key beside its own word — and "" for a row with no + // answer to offer. Whether they are actually drawn is the frame's to say: + // exactly one row draws them ([app.homeAnswerAt]). + answers string + // grows says the row draws that line ONLY WHILE THE CURSOR IS ON IT, which + // is how a landing is one line at rest and two under the cursor + // (homepanel_needs.go). The line it grows into is reserved by its panel + // ([homeGridPanel.height]) so the column does not change shape as the + // cursor walks over the rows. + grows bool // share is how full the spend bar is, and spark the fortnight's days. share float64 spark []float64 @@ -384,8 +439,14 @@ type homeCell struct { } // height is how many screen rows one line takes. +// +// A ROW THAT GROWS UNDER THE CURSOR IS ONE ROW HERE. The line it grows into is +// the PANEL's reservation ([homeGridPanel.height]) rather than this row's +// height, because exactly one row of a frame is under the cursor and a layout +// that changed shape as the cursor walked over these rows would move every panel +// under them on every arrow key ([homeCell.grows]). func (l homeLine) height() int { - if l.cell != nil && l.cell.sub != "" { + if l.cell != nil && l.cell.sub != "" && !l.cell.grows { return 2 } return 1 @@ -447,12 +508,44 @@ func (p homeGridPanel) height() int { for _, line := range p.read.lines[:p.shown] { n += line.height() } + // A GROUP'S LINE AND THE AIR OVER IT ARE THE PANEL'S ROWS TOO, drawn only + // while the group has a row of its own left on the screen ([homeGridPanel.lines]). + if p.groupOpen() { + n++ + if p.read.group.at > 0 { + n++ + } + } + // AND ONE LINE IS KEPT FOR THE ROW THE CURSOR WILL GROW. Which row that is + // belongs to the paint; that one of them will grow is known here, and + // reserving it is what keeps the column the same height whichever row the + // cursor is standing on ([homeCell.grows]). + if p.growsARow() { + n++ + } if p.folds() { n++ } return n } +// groupOpen reports that the panel is drawing its group's own line, which it +// does while at least one of the group's rows is still shown. +func (p homeGridPanel) groupOpen() bool { + return p.read.group != nil && p.shown > p.read.group.at +} + +// growsARow reports that one of the rows on the screen will grow a line under +// the cursor. +func (p homeGridPanel) growsARow() bool { + for _, line := range p.read.lines[:p.shown] { + if line.cell != nil && line.cell.grows { + return true + } + } + return false +} + // homeColumnHeight is a column's height with one blank row between panels. func homeColumnHeight(column []*homeGridPanel) int { n, drawn := 0, 0 @@ -581,23 +674,18 @@ func growColumn(column []*homeGridPanel, room int) { } } -// shrink cuts a panel to its floor: the heading, as many whole rows as fit in -// what the floor leaves after the fold line, and the fold. A panel already -// inside its floor is left alone. +// shrink cuts a panel to its floor: the heading, as many whole rows as fit under +// it, and the fold. A panel already inside its floor is left alone. +// +// IT TAKES ROWS FROM THE BOTTOM, WHICH IS WHY A GROUP NEEDS NO RULE OF ITS OWN. +// A group's rows sit at the foot of the panel's list ([homePanelGroup]), so the +// walk down from the natural height spends them first and the group's line goes +// with the last of them — the `to check` landings fold before a `needs you` row +// is given up, without this function knowing that either exists. func (p *homeGridPanel) shrink() { - if p.height() <= p.slot.least { - return + for p.shown > 0 && p.height() > p.slot.least { + p.shown-- } - budget := p.slot.least - 2 - kept, used := 0, 0 - for _, line := range p.read.lines { - if used+line.height() > budget { - break - } - used += line.height() - kept++ - } - p.shown = kept } // homeGridLayout reads every panel and fits each column: the panels in table @@ -698,7 +786,20 @@ func (p homeGridPanel) lines() []homeLine { } return out } - out = append(out, p.read.lines[:p.shown]...) + if group := p.read.group; group != nil && p.groupOpen() { + out = append(out, p.read.lines[:group.at]...) + // A GROUP STANDS OFF THE ROWS ABOVE IT with the same blank row that + // stands between two panels, and takes none when it is the panel's + // first line. + if group.at > 0 { + out = append(out, homeLine{kind: homeBlank}) + } + out = append(out, homeLine{kind: homeSwitchHead, cell: &homeCell{kind: cellGroup, + panel: id, title: group.word + rowSep + group.said, right: group.right}}) + out = append(out, p.read.lines[group.at:p.shown]...) + } else { + out = append(out, p.read.lines[:p.shown]...) + } if p.folds() { out = append(out, p.fold()) } @@ -712,7 +813,7 @@ func (p homeGridPanel) lines() []homeLine { func (p homeGridPanel) fold() homeLine { var counts []string if n := p.hidden(); n > 0 { - counts = append(counts, groupedInt(n)+" more") + counts = append(counts, groupedInt(n)+" "+p.foldMoreWord()) } if n := p.read.older; n > 0 { counts = append(counts, groupedInt(n)+" "+homeFoldOlderWord) @@ -729,6 +830,22 @@ func (p homeGridPanel) fold() homeLine { return homeLine{kind: homeSwitchHead, cell: cell} } +// homeFoldMoreWord is what the fold calls the rows it is standing for when they +// are simply more of the panel's own. +const homeFoldMoreWord = "more" + +// foldMoreWord is what the fold calls the rows it is standing for: the GROUP's +// name where every one of them is the group's and the group's own line is not on +// the screen — `8 to check · tasks` is the whole of what a squeezed panel says +// about its landings — and `more` everywhere else. +func (p homeGridPanel) foldMoreWord() string { + group := p.read.group + if group == nil || p.groupOpen() || p.shown < group.at { + return homeFoldMoreWord + } + return group.word +} + // homeFoldKey is the identity a fold door carries beside its place word, so the // cursor on `3 more · tasks` is told apart from a `since you left` line that // opens the same place ([homeLine.sameRow]). It starts with a NUL, which no @@ -1035,47 +1152,94 @@ func switcherRowLine(row switcherRow, cell *homeCell) homeLine { dir: homeBucketOf(row.session.Transcript), cell: cell} } -// homeGridAnswer is a digit on the resting grid: THE TOP QUESTION, FROM -// ANYWHERE ON HOME, WITH NO CURSOR MOVE (law 7). The top question is the first -// row of `needs you` that draws its answers, so the key a person presses is one -// they can see on the screen; a digit with no such row falls through to the -// row under the cursor and then to the box, as it always did. +// homeGridAnswer is an answer key on the resting grid: THE ONE ANSWERING ROW OF +// THE FRAME, FROM ANYWHERE ON HOME (law 7). That row is the one drawing the +// chips, so the key a person presses is one they can see on the screen; a key +// with no such row falls through to the row under the cursor and then to the +// box, as it always did. func (a *app) homeGridAnswer(key string) (tea.Cmd, bool) { if !a.home.gridOn() { return nil, false } - for _, line := range a.home.lines { - if line.cell == nil || line.cell.panel != panelNeeds { - continue - } - if words := a.homeRowAnswers(line); words != "" && words != answerWaitingWord && words != needsOpenWord { - return a.answerRowKey(a.homeTrue(line.row), key) + at := a.homeAnswerAt() + if at < 0 { + return nil, false + } + line := a.home.lines[at] + if line.task != nil { + return a.homeAnswerLanding(line, key) + } + return a.answerRowKey(a.homeTrue(line.row), key) +} + +// homeAnswerAt is THE ONE ROW OF THE FRAME THAT DRAWS THE CHIPS AND TAKES THE +// KEY: the row under the cursor when it can take an answer, and the top row of +// `needs you` that can otherwise. It is -1 when nothing on the frame is +// answerable. +// +// THE CURSOR OUTRANKS THE TOP ROW BECAUSE A PERSON WHO WALKED SOMEWHERE MEANT +// IT. Law 7 put the chips on the top question so that a digit worked without +// moving the cursor, and that still holds for a frame nobody has walked; once +// somebody has walked onto a landing, `a` has to mean THAT landing or the key +// answers a row the person is not looking at. One function, so the chip and the +// key can never be two different rows. +func (a *app) homeAnswerAt() int { + if a.homeOffersAnswer(a.home.cursor) { + return a.home.cursor + } + for at := range a.home.lines { + if a.homeOffersAnswer(at) { + return at } } - return nil, false + return -1 } -// homeRowAnswers is what a `needs you` row draws at the right of its question: -// the answers, under the answer band's own four rules ([drawAnswerBand]) — the -// question is fresh and offered answers, this window has somewhere to leave -// one, it has not already sent one (the waiting word stands in for a moment -// after it has), and no question this window raised about the row is standing -// over it ([app.answersStepAside]). Every fact is in memory; nothing is read. +// homeOffersAnswer reports that the line at `at` is a row this window can +// actually send an answer for. +func (a *app) homeOffersAnswer(at int) bool { + if at < 0 || at >= len(a.home.lines) { + return false + } + words := a.homeRowOffer(a.home.lines[at]) + return words != "" && words != answerWaitingWord +} + +// homeRowOffer is the answers ONE row could take, under the answer band's own +// four rules ([drawAnswerBand]) — the question is fresh and offered answers, +// this window has somewhere to leave one, it has not already sent one (the +// waiting word stands in for a moment after it has), and no question this +// window raised about the row is standing over it ([app.answersStepAside]). +// Every fact is in memory; nothing is read. // -// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an -// answer — the row is not the top question, or its question has a paragraph — -// and the gate is about answers (homepanel_needs.go). -func (a *app) homeRowAnswers(line homeLine) string { - if line.cell == nil || line.cell.subRight == "" { +// A LANDING IS THE SAME FOUR RULES WITH NOTHING ASKING. There is no live +// question to be fresh, so what is left is the doorstep: a conversation with a +// folder to leave the answer in can be answered from here, and one without +// cannot (homepanel_needs.go's [app.homeAnswerLanding]). +func (a *app) homeRowOffer(line homeLine) string { + if line.cell == nil || line.cell.panel != panelNeeds || line.cell.answers == "" { return "" } - if line.cell.subRight == needsOpenWord { - return needsOpenWord + row, now := a.homeTrue(line.row), a.home.world.Read + if line.task != nil { + question, ok := needsLandingQuestion(line) + if !ok { + return "" + } + if sent, ok := a.answerSent(row, question); ok { + if now.Sub(sent.at) < answerHoldFor { + return answerWaitingWord + } + return "" + } + if a.leaveAnswer == nil && !a.answeringHere(row) { + return "" + } + return line.cell.answers } if line.kind != homeSession { return "" } - row, now := a.homeTrue(line.row), a.home.world.Read question, ok := answerable(row, now) if !ok { return "" @@ -1089,6 +1253,26 @@ func (a *app) homeRowAnswers(line homeLine) string { if (a.leaveAnswer == nil && !a.answeringHere(row)) || a.answersStepAside(row) { return "" } + return line.cell.answers +} + +// homeRowAnswers is what the line under one row carries at its right: the +// answers where this is the frame's one answering row, the waiting word where +// this window has already answered it, and the row's own door word everywhere +// else. +// +// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an +// answer — the row is not the answering row, or its question has a paragraph — +// and the gate is about answers (homepanel_needs.go). +func (a *app) homeRowAnswers(line homeLine, at int) string { + if line.cell == nil { + return "" + } + if words := a.homeRowOffer(line); words != "" { + if words == answerWaitingWord || at == a.homeAnswerAt() { + return words + } + } return line.cell.subRight } diff --git a/internal/tui3/homemachine.go b/internal/tui3/homemachine.go index 625c058c39..de4ae23c46 100644 --- a/internal/tui3/homemachine.go +++ b/internal/tui3/homemachine.go @@ -94,7 +94,7 @@ func (f machineFacts) nearCeiling() bool { // read with it. It runs on a beat and never on a draw. func (a *app) readMachine(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView) { a.readMachineMoney(now) - a.machine.hands, a.machine.wants = machineCounts(sessions, bands, a.exchanges) + a.machine.hands, a.machine.wants = machineCounts(now, sessions, bands, a.exchanges) } // readMachineMoney is the money half alone: what the day has cost, and the @@ -127,8 +127,15 @@ func (a *app) readMachineMoney(now time.Time) { // draws and these figures are about the MACHINE, so a count taken from the rows // on screen would fall the moment a ninth thing started — which is the opposite // of what the figure means. -func machineCounts(sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) { +func machineCounts(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) { for _, row := range sessions { + // AND A LANDING NOBODY HAS CHECKED IS A WANT TOO, counted through the + // panel's own reading of it (homepanel_needs.go's [needsCallOf] and + // [needsAged]) rather than through a second test here. It is what makes + // `N want you` the sum of the two groups of `needs you`: the count and + // the rows are the same arithmetic, so a pulse over a home can no longer + // claim a number the panel under it does not show. + wants += machineChecks(now, row) switch { case row.Archived: case row.NeedsPerson(): @@ -169,6 +176,27 @@ func machineCounts(sessions []session.SessionRow, bands map[string][]StandingIte return hands, wants } +// machineChecks is how many of one conversation's landings are waiting to be +// checked — the `to check` group's rows for that conversation, counted with the +// group's own reading. +// +// AN ARCHIVED CONVERSATION IS NOT COUNTED, for the same reason its own row is +// not: it has been put away, and work put away is not waiting on anybody. +func machineChecks(now time.Time, row session.SessionRow) int { + if row.Archived { + return 0 + } + n := 0 + for i := range row.Tasks.Rows { + entry := row.Tasks.Rows[i] + if _, ok := needsCallOf(row, entry); !ok || needsAged(needsCallAt(entry), now) { + continue + } + n++ + } + return n +} + // machineSpentToday is WHAT THIS MACHINE HAS SPENT TODAY, and it is the usage // ledger's answer — the same reading, through the same arithmetic // ([spendDayTotal]), that the spend place draws as `today $3.42 of $500`. diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 3da381cec9..c3b8f1e8ec 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -22,9 +22,19 @@ import ( // own ledger rows ([switcherReading.addLedger]), each carrying its door. type leftPanel struct{ homePanelBase } +// SAID ONCE ACROSS THE COLUMNS. A landing whose check is still the person's is +// drawn by `needs you`'s `to check` group, one column over and higher up the +// page ([needsChecking]); this panel drew it a second time, as an ordinary thing +// that happened while nobody was looking. It comes back here the moment it stops +// being a question — answered, or aged out of the group — because then it IS +// just something that happened. func (leftPanel) rows(in *homeGridInput) homePanelRows { + checking := needsChecking(in) lines := make([]homeLine, 0, len(in.ledger)) for _, row := range in.ledger { + if row.task != nil && checking[row.task.SessionID+"/"+row.task.ID] { + continue + } lines = append(lines, leftLine(row)) } out := homePanelCut(panelLeft, lines) diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 0ed1fad1bf..e90c9b691f 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -120,20 +120,28 @@ func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) { } } -// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF ITS OWN, and enter aims at -// the task rather than at the conversation's live edge. Work nobody could check -// says what the person can do about it, and draws no second `enter` beside that. +// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF THE `to check` GROUP, one +// line of its own, under a group line that says what the group is. enter aims at +// the task rather than at the conversation's live edge. func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { l := newLiveLab(t) l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", - Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute), + FilesChanged: 3}) a := l.open() rows := panelRows(a, panelNeeds) - if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].sub != needsUncheckedWord || rows[0].subRight != "" { - t.Fatalf("the task's call is not a row of needs you that says what to do: %+v", rows) + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].tag != "3 files" { + t.Fatalf("the task's call is not a one-line row of needs you: %+v", rows) + } + if rows[0].mark != cellMarkNone { + t.Fatalf("a landing wears a mark: %+v", rows[0]) + } + frame := homeText(a) + if !strings.Contains(frame, needsCheckWord+" · 1") || !strings.Contains(frame, needsCheckClause) { + t.Fatalf("the group line does not name the group and say what it is:\n%s", frame) } - if frame := homeText(a); !strings.Contains(frame, "landed unchecked · enter to look") || strings.Contains(frame, "nobody could check it") { - t.Fatalf("the row under the call does not say what to do:\n%s", frame) + if strings.Contains(frame, "landed unchecked") { + t.Fatalf("the retired sub-line is still drawn:\n%s", frame) } for _, line := range panelLines(a, panelNeeds) { if line.cell.kind == cellRow && (line.task == nil || line.task.ID != "4") { @@ -142,6 +150,120 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { } } +// THE GROUP LINE IS DRAWN ONLY WHERE THE GROUP HAS ROWS, and it is not a stop: +// the cursor walks from the last question straight onto the first landing. +func TestToCheckDrawsNoGroupLineWithoutLandings(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + a := l.open() + if frame := homeText(a); strings.Contains(frame, needsCheckClause) { + t.Fatalf("a group with no rows drew its line:\n%s", frame) + } + for _, line := range panelLines(a, panelNeeds) { + if line.cell.kind == cellGroup { + t.Fatalf("a group line was built with no rows under it") + } + } +} + +// BLOCKING FIRST, HOWEVER OLD THE LANDING IS: a consent asked a minute ago sits +// above a landing from a week back, and the landing is under the group line. +func TestNeedsBlockingRowsSortFirst(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-time.Minute))}) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-40 * time.Hour)}) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 2 || rows[0].title != "Pricing Site" || rows[1].title != "fix the flaky sieve" { + t.Fatalf("the week-old landing did not sort under the fresh question: %+v", rows) + } + if rows[0].mark != cellMarkNeeds || rows[1].mark != cellMarkNone { + t.Fatalf("the mark is not on the stopped row alone: %+v", rows) + } + kinds := []homeCellKind{} + for _, line := range panelLines(a, panelNeeds) { + kinds = append(kinds, line.cell.kind) + } + if len(kinds) < 4 || kinds[0] != cellHead || kinds[1] != cellRow || kinds[len(kinds)-2] != cellGroup { + t.Fatalf("the group line does not stand between the question and the landing: %v", kinds) + } +} + +// THE LANDINGS ARE NEWEST FIRST, which is the opposite of the questions above +// them and is said in [needsPanel.rows]. +func TestToCheckDrawsTheNewestLandingFirst(t *testing.T) { + l := newLiveLab(t) + for i, ago := range []time.Duration{30 * time.Minute, 5 * time.Hour} { + id := itoa(i + 1) + l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: "call " + id, + Title: "call " + id, Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) + } + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" { + t.Fatalf("to check is not newest first: %+v", rows) + } +} + +// ONE LINE AT REST AND TWO UNDER THE CURSOR: the landing grows the report's +// first sentence and the task's own two answers, and the words are the ask's. +func TestALandingGrowsItsReportAndAnswersUnderTheCursor(t *testing.T) { + l := newLiveLab(t) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + a := l.open() + at := -1 + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + at = i + } + } + if at < 0 { + t.Fatal("the landing is not a line of the column") + } + if frame := homeText(a); homeLineAfter(frame, "fix the flaky sieve") != "" && + strings.Contains(homeLineAfter(frame, "fix the flaky sieve"), "nobody could check it") { + t.Fatalf("the landing grew its line with the cursor elsewhere:\n%s", frame) + } + a.home.cursor = at + frame := homeText(a) + under := homeLineAfter(frame, "fix the flaky sieve") + if !strings.Contains(under, "nobody could check it") { + t.Fatalf("the cursor row did not grow its report:\n%s", frame) + } + if !strings.Contains(under, session.LandingYesKey+" accept") || !strings.Contains(under, session.LandingNoKey+" not right") { + t.Fatalf("the grown line does not carry the ask's own answers:\n%s", frame) + } +} + +// AND THE KEY THE GROWN LINE DRAWS IS THE KEY THAT ANSWERS IT, through the one +// door a landing is answered by anywhere ([app.homeAnswerLanding]). +func TestALandingUnderTheCursorTakesItsOwnAnswerKey(t *testing.T) { + l := newLiveLab(t) + l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, string(kind)+"/"+itoa64(id)+"/"+key) + return nil + } + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + a.home.cursor = i + } + } + homeText(a) + if _, took := a.homeGridAnswer(session.LandingYesKey); !took { + t.Fatal("the landing under the cursor did not take its own accept key") + } + if len(left) != 1 || left[0] != string(session.QuestionLanding)+"/4/"+session.LandingYesKey { + t.Fatalf("the accept did not reach the conversation's doorstep as a landing answer: %v", left) + } +} + // A YOUR-CALL OLDER THAN TWO DAYS IS HISTORY, NOT A QUESTION: two fresh calls // are the panel's rows and its count, and the three that landed days ago are // one door into tasks under them. @@ -153,12 +275,18 @@ func TestNeedsYouAgesOldCallsOntoTheFold(t *testing.T) { Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) } a := l.open() - if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 2" || rows[1].title != "call 1" { + if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" { t.Fatalf("needs you is not the two fresh calls: %+v", rows) } frame := homeText(a) - if !strings.Contains(frame, "needs you · 2") || !strings.Contains(frame, "3 older · tasks") { - t.Fatalf("the heading does not count what is listed, or the fold does not count what aged:\n%s", frame) + // AND THE HEADING COUNTS THE QUESTIONS, NOT THE LANDINGS. With nothing + // stopped it draws its word alone (the emptiness law); the landings are + // counted on the group's own line and the aged ones on the fold. + if !strings.Contains(frame, "needs you") || strings.Contains(frame, "needs you · ") { + t.Fatalf("the heading counted the landings:\n%s", frame) + } + if !strings.Contains(frame, needsCheckWord+" · 2") || !strings.Contains(frame, "3 older · tasks") { + t.Fatalf("the group does not count what is listed, or the fold what aged:\n%s", frame) } } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index bb79db6705..392024cd5a 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -6,30 +6,46 @@ import ( "strings" "time" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/aforge-v2/internal/session" ) // needsPanel is `needs you` (docs/design/home-mission-control/DESIGN.md §1, §3 -// P1): every question on the machine that is waiting on a person, the longest -// wait first (homeattention.go's [attentionOlder]). +// P1): everything on the machine that is waiting on a person, in TWO GROUPS on +// one panel. +// +// BLOCKING FIRST, THEN WHAT HAS LANDED (the spec of record for #884). The top +// group is the live questions — a conversation stopped on a question or on a +// consent card, a watch that needs somebody — longest wait first +// ([attentionOlder]), each wearing the amber mark and carrying its own sentence. +// Under them the `to check` group is every task whose call is the person's, +// newest first, one line each. A live question is always above a landing however +// old the landing is, because a landing costs nothing while it waits and a +// stopped conversation costs everything. // -// THREE SOURCES AND ONE LIST. A conversation stopped on a question, a task the -// record marks as the person's call, and a watch that needs somebody are three -// different engines asking the same thing, and a person answering them does not -// care which engine it was. Each row is two lines: the title with its age at the -// right, and under it the question's own sentence. +// THE DIFFERENCE BETWEEN THE TWO GROUPS IS SAID ONCE, ON THE GROUP'S OWN LINE: +// `to check · 8` at the left and `finished, nobody has checked it` at the right +// (placeprose.go's [needsCheckWord] and [needsCheckClause]). It used to be said +// under every landing row, which was the same nine words nine times and pushed +// `where you were` off a forty-row frame (owner, 2026-09-11). // -// THE ANSWERS ARE DRAWN ON THE TOP ANSWERABLE ROW AND NOWHERE ELSE (law 7). A -// digit pressed anywhere on home answers that row ([app.homeGridAnswer]), so the -// chips beside it are the key a person is about to press. Chips on a second row -// would be a `1` on screen twice with one meaning, which is a guess; every other -// row says `enter`, and enter opens it. +// A LANDING IS ONE LINE AT REST AND TWO UNDER THE CURSOR. The second line is the +// first sentence of what the work came to and the two answers the task itself +// offers ([session.TaskAsk]), which is the whole of what "check it" means; the +// panel reserves the line it grows into ([homeCell.grows]) so the column does +// not move as the cursor walks. +// +// THE ANSWERS ARE DRAWN ON ONE ROW OF THE FRAME AND NOWHERE ELSE (law 7): the +// row under the cursor when it can take an answer, and the top row that can +// otherwise ([app.homeAnswerAt]). Drawing and routing ask that one function, so +// a `1` on the screen and the key a person presses cannot be two different rows. type needsPanel struct{ homePanelBase } const ( // needsOpenWord is what a row says where its answers are not drawn: a - // question with a paragraph, a task's call, a watch's question, any row - // below the top one. enter opens it. + // question with a paragraph, a landing this window has nowhere to leave an + // answer for, any row that is not the frame's answering row. enter opens it. needsOpenWord = "enter" // needsAnswersCap is how many of a question's answers fit on its row. A // question with more draws the first ones and then [needsOpenWord], because @@ -37,37 +53,42 @@ const ( needsAnswersCap = 3 ) -// needsItem is one row before the panel orders it: when it was asked, the line, -// and the answers it would draw if it were the top answerable row. +// needsItem is one row before the panel orders it: when it was asked, and the +// line. type needsItem struct { - asked time.Time - line homeLine - answers string + asked time.Time + line homeLine } func (needsPanel) rows(in *homeGridInput) homePanelRows { - calls, older := needsFresh(needsCalls(in), in.now) - items := append(needsAsked(in), calls...) - sort.SliceStable(items, func(i, j int) bool { return attentionOlder(items[i].asked, items[j].asked) }) - lines := make([]homeLine, 0, len(items)) - drawn := false - for _, item := range items { - // A line that already names its door draws no second one ([needsUncheckedWord]). - if item.line.cell.sub != needsUncheckedWord { - item.line.cell.subRight = needsOpenWord - } - if item.answers != "" && !drawn { - item.line.cell.subRight, drawn = item.answers, true - } + asked := needsAsked(in) + sort.SliceStable(asked, func(i, j int) bool { return attentionOlder(asked[i].asked, asked[j].asked) }) + // AND THE LANDINGS ARE NEWEST FIRST, which is the opposite order and the + // right one for them: `needs you` is ranked by how long something has been + // stopped, and nothing is stopped here — the freshest landing is the work + // still in the person's head, and the oldest ages out of the group entirely + // ([needsFresh]). + calls := append([]needsItem(nil), in.calls...) + sort.SliceStable(calls, func(i, j int) bool { return attentionOlder(calls[j].asked, calls[i].asked) }) + lines := make([]homeLine, 0, len(asked)+len(calls)) + for _, item := range append(asked, calls...) { lines = append(lines, item.line) } out := homePanelCut(panelNeeds, lines) - out.said = countWord(len(lines)) - out.older = older + // THE HEADING COUNTS THE QUESTIONS AND THE GROUP LINE COUNTS THE LANDINGS. + // A frame holding only landings draws `needs you` with no count at all + // rather than `needs you · 0`, which is the emptiness law applied to a + // heading ([countWord]). + out.said = countWord(len(asked)) + out.older = in.callsOlder + if len(calls) > 0 { + out.group = &homePanelGroup{at: len(asked), word: needsCheckWord, + said: countWord(len(calls)), right: needsCheckClause} + } return out } -// needsFresh is the task calls that are still a question, and how many are +// needsFresh is the task calls that are still worth checking, and how many are // history. // // A YOUR-CALL OLDER THAN [homeNeedsTaskFresh] IS HISTORY, NOT A QUESTION (owner, @@ -80,7 +101,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows { // not known to be old, and stays. func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int) { for _, item := range items { - if !item.asked.IsZero() && now.Sub(item.asked) > homeNeedsTaskFresh { + if needsAged(item.asked, now) { older++ continue } @@ -89,6 +110,12 @@ func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int) return fresh, older } +// needsAged is that rule as one predicate, so the panel's rows and the pulse's +// count age a landing out at the same instant ([machineCounts]). +func needsAged(asked, now time.Time) bool { + return !asked.IsZero() && now.Sub(asked) > homeNeedsTaskFresh +} + // needsAsked is the conversations and watches the switcher already ranks as // waiting on somebody. func needsAsked(in *homeGridInput) []needsItem { @@ -97,7 +124,7 @@ func needsAsked(in *homeGridInput) []needsItem { if !row.needs { continue } - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title} + cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord} homeLiveMargin(cell, row, sinceAt(row.at, in.now)) item := needsItem{asked: row.at} switch row.kind { @@ -105,7 +132,7 @@ func needsAsked(in *homeGridInput) []needsItem { head, whole := needsSentence(row.session) cell.sub = head if _, ok := answerable(row.session, in.now); ok && whole { - item.answers = answersWord(row.session.Presence.Question) + cell.answers = answersWord(row.session.Presence.Question) } case switcherStanding: cell.sub = switcherFirstLine(row.item.Item.NeedsPerson) @@ -123,48 +150,125 @@ func needsAsked(in *homeGridInput) []needsItem { // the tasks place can never disagree about which work is waiting. // // ENTER OPENS THE CONVERSATION ON THE TASK'S RECORD ([app.homeLandOnTask]), -// which is where the question and its two answers are. -func needsCalls(in *homeGridInput) []needsItem { +// which is where the question and the whole of its evidence are. +func needsCalls(world session.World, now time.Time) []needsItem { var items []needsItem - for _, project := range in.world.Projects { + for _, project := range world.Projects { for _, row := range project.Sessions { for i := range row.Tasks.Rows { entry := row.Tasks.Rows[i] - status := taskEntryStatus(entry, row.Runs(entry)) - if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) { + status, ok := needsCallOf(row, entry) + if !ok { continue } - items = append(items, needsCall(project, row, entry, status, in.now)) + items = append(items, needsCall(project, row, entry, status, now)) } } } return items } -// needsCall is one of those rows. +// needsCallOf is THE ONE READING OF "THIS LANDING IS WAITING ON THE PERSON", +// and its status. +// +// THREE READERS AND ONE ANSWER. The `to check` group draws these rows, `since +// you left` drops the landings this group is already drawing ([leftPanel.rows]), +// and the pulse counts them beside the questions ([machineCounts]). A second +// spelling of this test anywhere would be a `3 want you` over four rows, which +// is the exact failure the one-reader law exists for. +// +// A LANDING THE CONVERSATION IS ITSELF ASKING ABOUT IS NOT ONE OF THESE +// ([needsAskedInChat]): that is a stopped conversation, it is a `needs you` row, +// and it is the row that can take a digit. +func needsCallOf(row session.SessionRow, entry session.TaskIndexEntry) (session.TaskStatus, bool) { + status := taskEntryStatus(entry, row.Runs(entry)) + if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) { + return session.TaskStatus{}, false + } + return status, true +} + +// needsCallAt is when a landing asked — when the work ended, or when it started +// for a row that never recorded an ending. +func needsCallAt(entry session.TaskIndexEntry) time.Time { + if !entry.EndedAt.IsZero() { + return entry.EndedAt + } + return entry.StartedAt +} + +// needsCall is one of those rows: the title, how many files it wrote and how +// long ago, and — under the cursor — what it came to and its two answers. func needsCall(project session.Project, row session.SessionRow, entry session.TaskIndexEntry, status session.TaskStatus, now time.Time) needsItem { title := strings.TrimSpace(entry.Label) if title == "" { title = strings.TrimSpace(entry.Title) } - sub := switcherFirstLine(status.Reason) - if sub == "" { - sub = status.Word - } - if status.Ask.Kind == session.TaskAskCheck { - sub = needsUncheckedWord - } - asked := entry.EndedAt - if asked.IsZero() { - asked = entry.StartedAt - } - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, right: sinceAt(asked, now), sub: sub, - key: needsCallKey + entry.ID} + asked := needsCallAt(entry) + // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped + // and will not move until somebody answers it; a landing has already + // finished, and a column of question marks over work that is DONE was the + // screen saying the opposite of what was true. + cell := &homeCell{panel: panelNeeds, title: title, right: sinceAt(asked, now), + tag: needsFilesWord(entry), key: needsCallKey + entry.ID, + grows: true, sub: needsCallSub(status), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, dir: homeBucketOf(row.Transcript), task: &entry, cell: cell} return needsItem{asked: asked, line: line} } +// needsCallSub is the line a landing grows under the cursor: the first sentence +// of what the work came to, and the engine's own reason for the call where the +// work left no report. +func needsCallSub(status session.TaskStatus) string { + if sub := switcherFirstLine(status.Reason); sub != "" { + return sub + } + return status.Word +} + +// needsFilesWord is how many files a landing wrote, and NOTHING for a landing +// that wrote none — the emptiness law: `0 files` is a fact drawn as a hole. +func needsFilesWord(entry session.TaskIndexEntry) string { + if entry.FilesChanged <= 0 { + return "" + } + return itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) +} + +// needsCallAnswers is the two answers a landing offers, IN THE TASK'S OWN WORDS +// AND ON TASK-STATES' OWN KEYS. +// +// THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No, filled in by the +// engine for the row's own shape — `accept` / `not right` for work nobody could +// check, `resolve it` for a branch that would not fasten) and this file spells +// none of them. THE KEYS ARE [session.LandingYesKey] AND [session.LandingNoKey], +// letter for letter, because a landing is answered with the same two keys +// wherever it is drawn (answers.go states the law from the writing end) and a +// surface that renumbered them to `1` and `2` would be teaching a person a key +// that does not work on the record they open next. +func needsCallAnswers(status session.TaskStatus) string { + yes, no := strings.TrimSpace(status.Ask.Yes), strings.TrimSpace(status.Ask.No) + if yes == "" || no == "" { + return "" + } + return session.LandingYesKey + " " + yes + homeCellGap + session.LandingNoKey + " " + no +} + +// needsChecking is every landing the `to check` group is drawing, by the +// identity a `since you left` line carries for the same task +// (homepanel_left.go's [leftKey]) — so one piece of work is one row of home's +// column and never two. +func needsChecking(in *homeGridInput) map[string]bool { + said := make(map[string]bool, len(in.calls)) + for _, item := range in.calls { + if item.line.task != nil { + said[item.line.task.SessionID+"/"+item.line.task.ID] = true + } + } + return said +} + // needsCallKey prefixes a task row's [homeCell.key], so a conversation's own // question and one of its tasks' calls are two rows and never one. const needsCallKey = "call:" @@ -222,3 +326,101 @@ func answersWord(question session.PresenceQuestion) string { } return strings.Join(parts, homeCellGap) } + +// ── answering a landing from home ─────────────────────────────────────────── + +// needsLandingQuestion is the landing question a `to check` row stands for, as +// the answer doors want it: the kind, the node's id, and the two options in the +// task's own words. It is false for a row that is not a landing or whose id the +// index never recorded. +// +// IT IS BUILT FROM THE ROW AND NEVER FROM A LIVE SESSION'S PRESENCE. That is the +// whole difference between this group and the one above it: nothing is asking, +// so there is no question object to read — the task index says the call is the +// person's and [session.TaskAsk] says what the two answers are called. +func needsLandingQuestion(line homeLine) (session.PresenceQuestion, bool) { + if line.task == nil || line.cell == nil || line.cell.answers == "" { + return session.PresenceQuestion{}, false + } + id, err := strconv.ParseUint(strings.TrimSpace(line.task.ID), 10, 64) + if err != nil || id == 0 { + return session.PresenceQuestion{}, false + } + yes, no, ok := needsAnswerWords(line.cell.answers) + if !ok { + return session.PresenceQuestion{}, false + } + return session.PresenceQuestion{Kind: session.QuestionLanding, ID: id, Options: []session.AnswerOption{ + {Key: session.LandingYesKey, Label: yes}, + {Key: session.LandingNoKey, Label: no, Safe: true}, + }}, true +} + +// needsAnswerWords reads the two words back out of the clause the row draws, so +// the chip a person sees and the label the receipt says are the SAME STRING and +// not two readings of one status. +func needsAnswerWords(clause string) (yes, no string, ok bool) { + parts := strings.SplitN(clause, homeCellGap, 2) + if len(parts) != 2 { + return "", "", false + } + yes = strings.TrimPrefix(parts[0], session.LandingYesKey+" ") + no = strings.TrimPrefix(parts[1], session.LandingNoKey+" ") + return yes, no, yes != "" && no != "" +} + +// homeAnswerLanding takes one of a landed task's own answers from home, and it +// is THE ONE DOOR on whichever side of the window the conversation is. +// +// In this window the answer goes straight to [session.Agent.ResolveQuestion], +// which is where the record card's `a` and `n` end up too ([app.taskCardKey] → +// [app.questionOptionKey] → the block). In any other window it is left on that +// conversation's doorstep ([app.leaveAnswer] → [session.WriteAnswer]), and that +// session's own heartbeat hands it to the SAME ResolveQuestion +// ([session.Agent.drainAnswers] → [session.Agent.applyLanding] → +// [session.Agent.ResolveUnverified]). There is no third path, and nothing here +// settles a task itself. +// +// A CONVERSATION THAT IS NOT RUNNING APPLIES IT WHEN IT NEXT RUNS, which is what +// this surface has always promised for another window's question and is why the +// row says `answered · waiting for it to pick that up` until it is picked up. +func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) { + question, ok := needsLandingQuestion(line) + if !ok { + return nil, false + } + label := question.Label(key) + if label == "" { + return nil, false + } + row := a.homeTrue(line.row) + if _, sent := a.answerSent(row, question); sent { + // ONE ANSWER PER LANDING, for [app.answerRowKey]'s reason: the first is + // on its way and the row is still saying so. + return nil, false + } + if a.answeringHere(row) { + doors, ok := a.questionDoors() + if !ok { + return nil, false + } + answer := session.Answer{At: time.Now(), Kind: session.QuestionLanding, ID: question.ID, + Key: key, Picked: []string{key}, DecidedBy: session.DecidedByPerson} + if err := doors.ResolveQuestion(answer); err != nil { + return nil, false + } + a.home.say(answerSentWord+label, "") + return nil, true + } + dir := strings.TrimSpace(row.Dir) + if a.leaveAnswer == nil || dir == "" { + return nil, false + } + if err := a.leaveAnswer(dir, question.Kind, question.ID, key); err != nil { + a.home.say(answerFailedWord, dir) + return nil, true + } + a.rememberAnswered(dir, question, label) + a.home.say(answerSentWord+label, "") + return nil, true +} diff --git a/internal/tui3/placeprose.go b/internal/tui3/placeprose.go index cbadb1188d..29d6677133 100644 --- a/internal/tui3/placeprose.go +++ b/internal/tui3/placeprose.go @@ -96,15 +96,21 @@ const ( standNotOursWord = "that one does not stand over this conversation" ) -// needsUncheckedWord is the line under a `needs you` row for work that landed -// and that nobody could check (homepanel_needs.go's [needsCall]). +// The two words the `to check` group of `needs you` is drawn with +// (homepanel_needs.go), spelled once here because the heading, the fold and the +// manual all quote them. // -// IT SAYS WHAT THE PERSON CAN DO, NOT WHAT THE MACHINE COULD NOT. The engine's -// own reason, `nobody could check it`, is the right sentence on the task's -// record, where the two answers stand beside it; on home, under a title and an -// age, it read as a shrug (owner, 2026-09-10). The line names its own door, so -// the row draws no second `enter` beside it. -const needsUncheckedWord = "landed unchecked · enter to look" +// THE CLAUSE IS THE WHOLE EXPLANATION AND IT IS SAID ONCE. The difference +// between a row that has stopped a conversation and a landing nobody has looked +// at is not obvious from a title and an age, and it used to be spelled under +// every landing — `landed unchecked · enter to look`, the same sub-line on nine +// rows (owner, 2026-09-11). It is seven words on the group's own line now, in +// the place a heading's clause already lives, and the rows under it are one line +// each. +const ( + needsCheckWord = "to check" + needsCheckClause = "finished, nobody has checked it" +) // ── THE FIVE-LEVEL SCALE (SCREEN 2a) ──────────────────────────────────────── // From 57e959489828ba522dc681cc14fd17cfeead10f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:25:37 -0400 Subject: [PATCH 2/8] home: the laws of the two groups, as tests Blocking first, the group line's shape and that it is not a stop, the chips on one row of the frame, `since you left` stepping aside, the squeeze order and the pulse's count. Co-Authored-By: Claude Opus 5 --- internal/tui3/homeneedsgroups_test.go | 193 ++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 internal/tui3/homeneedsgroups_test.go diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go new file mode 100644 index 0000000000..022bbbceca --- /dev/null +++ b/internal/tui3/homeneedsgroups_test.go @@ -0,0 +1,193 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// ── `needs you`, IN TWO GROUPS (#884, the spec of record §1) ──────────────── +// +// The panel holds the live questions that have stopped something, then — under +// a dim line of their own — the landings nobody has checked. These tests pin +// the rules that are easy to break from the other side of the column: what the +// group line is, which row takes a key, and what `since you left` may say about +// a landing this panel is already showing. + +// openAt is the live lab at a frame of a chosen size, which is what the squeeze +// needs. +func (l *liveLab) openAt(width, height int) *app { + l.t.Helper() + a := l.app(l.mine) + a.width, a.height = width, height + a.leaveAnswer = func(string, session.QuestionKind, uint64, string) error { return nil } + a.openHome() + homeText(a) + return a +} + +// landed writes one landing whose call is the person's. +func (l *liveLab) landed(id, label string, ago time.Duration) { + l.t.Helper() + l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: label, Title: label, + Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)}) +} + +// THE GROUP LINE IS FURNITURE AND NOT A STOP: the cursor walks from the last +// question straight onto the first landing, and the blank over the group line +// is not a stop either. +func TestTheToCheckGroupLineIsNotACursorStop(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + at := -1 + for i, line := range a.home.lines { + if line.cell != nil && line.cell.title == "Pricing Site" { + at = i + } + } + if at < 0 { + t.Fatal("the question is not a line of the column") + } + a.home.cursor = at + a.home.move(1) + line := a.home.lines[a.home.cursor] + if line.task == nil || line.task.ID != "4" { + t.Fatalf("the cursor did not step from the question onto the landing: %+v", line.cell) + } + for _, line := range a.home.lines { + if line.cell != nil && line.cell.kind == cellGroup && line.stop() { + t.Fatal("the group line is a cursor stop") + } + } +} + +// THE CHIPS ARE ON EXACTLY ONE ROW OF THE FRAME (law 7). Two questions and a +// landing, and only one answer clause is painted anywhere. +func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(9, "needs your ok to run write", l.now.Add(-5*time.Minute))}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + frame := homeText(a) + if got := strings.Count(frame, "1 allow once"); got != 1 { + t.Fatalf("the consent's chips are drawn on %d rows:\n%s", got, frame) + } + if got := strings.Count(frame, session.LandingYesKey+" accept"); got != 0 { + t.Fatalf("a landing nobody is standing on drew its answers:\n%s", frame) + } + // AND THE CURSOR OUTRANKS THE TOP ROW. Walked onto the landing, the chips + // move with it and the question above says `enter` again. + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == "4" { + a.home.cursor = i + } + } + frame = homeText(a) + if strings.Count(frame, "1 allow once") != 0 { + t.Fatalf("the chips stayed on the top question with the cursor on a landing:\n%s", frame) + } + if got := strings.Count(frame, session.LandingYesKey+" accept"); got != 1 { + t.Fatalf("the landing under the cursor drew its answers %d times:\n%s", got, frame) + } +} + +// A DIGIT WITH THE CURSOR ON NOTHING ANSWERABLE STILL REACHES THE TOP QUESTION, +// which is law 7 and did not change. +func TestADigitStillReachesTheTopQuestionFromALandingThatCannotAnswer(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, string(kind)+"/"+key) + return nil + } + a.home.point(a.file) + homeText(a) + if _, took := a.homeGridAnswer("1"); !took { + t.Fatal("the digit did not reach the top question") + } + if len(left) != 1 || left[0] != string(session.QuestionConsent)+"/1" { + t.Fatalf("the digit went somewhere else: %v", left) + } +} + +// SAID ONCE ACROSS THE COLUMNS: `since you left` does not repeat a landing that +// `to check` is showing, and shows it again once the landing has aged out of the +// group. +func TestSinceYouLeftOmitsALandingToCheckIsShowing(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + a.home.seen = l.now.Add(-4 * time.Hour) + a.home.build() + frame := homeText(a) + if got := strings.Count(frame, "fix the flaky sieve"); got != 1 { + t.Fatalf("the landing is drawn on %d rows of the column:\n%s", got, frame) + } + if rows := panelRows(a, panelLeft); len(rows) != 0 { + t.Fatalf("since you left repeated the landing: %+v", rows) + } + // AND ONCE IT IS HISTORY IT IS AN ORDINARY LINE OF `since you left` AGAIN. + l2 := newLiveLab(t) + l2.landed("4", "fix the flaky sieve", homeNeedsTaskFresh+time.Hour) + b := l2.open() + b.home.seen = l2.now.Add(-homeNeedsTaskFresh - 2*time.Hour) + b.home.build() + found := false + for _, row := range panelRows(b, panelLeft) { + if strings.Contains(row.title, "fix the flaky sieve") { + found = true + } + } + if !found { + t.Fatalf("an aged-out landing never came back to since you left:\n%s", homeText(b)) + } +} + +// THE GROUP FOLDS BEFORE A `needs you` ROW GOES. At a hundred and twenty by +// fourteen the panel keeps its question and says the landings on its fold. +func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + for i, label := range []string{"one", "two", "three"} { + l.landed(itoa(i+1), "landed "+label, time.Duration(i+1)*time.Hour) + } + a := l.openAt(120, 14) + frame := homeText(a) + if !strings.Contains(frame, "Pricing Site") { + t.Fatalf("the question gave way before the landings did:\n%s", frame) + } + if strings.Contains(frame, needsCheckClause) { + t.Fatalf("the group line survived the squeeze:\n%s", frame) + } + if !strings.Contains(frame, "3 "+needsCheckWord+" · tasks") { + t.Fatalf("the fold does not say what the folded group is:\n%s", frame) + } +} + +// THE PULSE'S COUNT IS THE SUM OF THE TWO GROUPS, read through the panel's own +// reading of a landing rather than through a second one ([machineChecks]). +func TestThePulseCountsBothGroupsOfNeedsYou(t *testing.T) { + l := newLiveLab(t) + l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.landed("5", "port the parser", 5*time.Hour) + // AND A LANDING THAT AGED OUT IS NOT COUNTED, because it is not a row. + l.landed("6", "old business", homeNeedsTaskFresh+time.Hour) + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 3 || a.machine.wants != len(rows) { + t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(panelRows(a, panelNeeds))) + } +} From ee4dd44f814f42257ab04c3e7998adb9dbcc5ffe Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:28:20 -0400 Subject: [PATCH 3/8] home: the manual and the design laws learn the two groups `home.md`'s `needs you` section is the questions alone; a new `to check` section is the landings, their one line at rest, the line the cursor grows and the two keys that answer it. `keys.md` and `places.md` stop saying `the top question`. DESIGN.md laws 5, 7 and 8 say the group's squeeze, the answering row and that a landing wears no mark. Co-Authored-By: Claude Opus 5 --- docs/design/home-mission-control/DESIGN.md | 16 ++- internal/manual/chat/home.md | 109 ++++++++++++++++----- internal/manual/chat/keys.md | 2 +- internal/manual/chat/places.md | 2 +- 4 files changed, 99 insertions(+), 30 deletions(-) diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index ff8fd07030..49a5c1e7b0 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -60,13 +60,23 @@ own keys, each a door to the place that owns it. The chat list is one panel. 5. **A short terminal squeezes in priority order.** `next up` and `spend` give way first, then `since you left`, then `running`; `needs you` and `where you were` shrink last. A squeezed panel keeps its heading and `N more · `. + A GROUP INSIDE A PANEL folds before the panel gives up a row above it: the + rows of `needs you`'s `to check` group sit at the foot of the panel's list, so + the ordinary bottom-up cut spends them first, and when none is left the + group's line goes and the fold names it — `8 to check · tasks` (#884). 6. **Preselect the previous thing.** On double-space the cursor is on the chat you were in before this one (this window's own stack, `chattabs.go` `tabList`). Enter is a switch in two keys; esc goes back. -7. **Digits answer the top question from anywhere on home.** No cursor move. - The answers are drawn on the row so the key is never a guess. +7. **One row of the frame draws its answers, and the key answers it from + anywhere on home.** No cursor move. That row is the one under the cursor when + it can take an answer and the top answerable row otherwise (#884), so a digit + works on a frame nobody has walked and a landing somebody HAS walked onto is + the row the key means. The answers are drawn on the row so the key is never a + guess, and they are the question's own keys — a consent's digits, a landing's + `a`/`n`. 8. **Two marks, one accent, one hint line.** `GlyphNeedsHuman` in `hueWarn` for - a person, the single spinner cell for work. Nothing else wears a glyph or a + a person — a conversation or a watch that has STOPPED, never a landing on + `to check`, which has already finished — the single spinner cell for work. Nothing else wears a glyph or a colour. Money is a number. Key hints live on the foot and the alt map, never inside a panel. 9. **Nothing grows past its budget.** Every panel folds inside itself with diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 4757715ca9..98528c8709 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -17,12 +17,17 @@ answering one question you would ask walking up to a colleague's desk: ? Clever Bet Prediction Model 6h npm run dev · pricing-site · a background job the 2024 season only, or all three? enter since you left · 12h - where you were Spark Fleet Ssh Audit · 2 hosts up, 1 not - Understanding Hash Tables here made apartments-minto-street.md - explain open addressing vs chaining - Understanding Bloom Filters 11h spend today $0.14 of $20 - Locate Recent Sandbox Task infra 1d ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20 - 70 more · type to find one opus 63% · 3 chats and 1 task today + to check · 3 finished, nobody has checked it Spark Fleet Ssh Audit · 2 hosts up, 1 not + tier-B subs 3 files · 1d made apartments-minto-street.md + built the tier-B flow a accept n not right + package release radar 1d spend today $0.14 of $20 + 1 more · 15 older · tasks ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20 + opus 63% · 3 chats and 1 task today + where you were + Understanding Hash Tables here next up + explain open addressing vs chaining the 6am repo watch in 20h + Understanding Bloom Filters 11h + 70 more · type to find one projects next up ~/aforge-v2 12 chats · 1 running master the 6am repo watch in 20h @@ -50,7 +55,7 @@ from 170, where `projects` and `spend` take the third. | Panel | What a row is | `enter` on a row | Dim line when it holds nothing | | --- | --- | --- | --- | -| `needs you` | a question waiting on a person, anywhere | opens where it was asked | `questions from any chat or task land here · a digit answers them` | +| `needs you` | a question waiting on a person, anywhere, and under them the `to check` group of work that landed | opens where it was asked | `questions from any chat or task land here · a digit answers them` | | `where you were` | a conversation | opens it | `your conversations · what you type below starts one` | | `projects` | a folder with conversations | starts a new chat there | never empty — the folder this window opened in is always a row | | `running` | a task, a background job, a watch firing | brings its conversation here | `work you send off with /task runs here on its own` | @@ -66,40 +71,89 @@ while a number still draws nothing at zero — never `$0.00`, never `0 tasks`. ## What needs me — the needs you panel, the ? rows at the top of home -**The top of the left column, and its rows wear the amber `?`.** A row is there when +**The top of the left column.** The panel holds **two groups**: the questions that have +stopped something, then the work that landed and has not been checked. + +**The first group is the questions, and only they wear the amber `?`.** A row is there when something has stopped and cannot go on without you, from any project: - a conversation stopped on a question — a command to approve, a task to approve, a card about something standing; -- a task the record marks as `your call` — work nobody could check reads - `landed unchecked · enter to look`, and `enter` opens the conversation on the task's - record, where its two answers are; - a reminder, watch or rule that stopped and wants an answer. -Each row is **two lines**: the title with **how long it has waited** at the right, and under -it what it is asking, in the question's own words. The longest wait is at the top. The -heading counts the rows: `needs you · 2`. +Each is **two lines**: the title with **how long it has waited** at the right, and under it +what it is asking, in the question's own words. **The longest wait is at the top**, and the +heading counts these rows and no others: `needs you · 2`. A frame where nothing is stopped +draws the heading with no number at all. **A permission question is repeated exactly and nothing is added to it.** The session that is stopped writes one sentence — `needs your ok to run ` and the tool's name — and the row is that sentence, whole. A question with a paragraph shows only its first line. -**Only a task's call ages.** A `your call` is a row for two days after it landed; after that -the panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the -tasks place, where every one of them still is. A question a conversation is stopped on, and -a watch that needs somebody, never age off home. +**A question stands above every landing**, however old the landing is: a stopped +conversation is costing you something and a landing is not. Four rows show, eight in a tall window, then `N more · tasks`. Amber is spent on the `?` mark and on nothing else; a machine with nothing waiting has no accent on it at all. -## Answer from home — a digit answers the top question from anywhere +## to check — the group of landings nobody has checked + +**`to check` is every task whose call is yours: work that finished, where nobody could say +whether it is right.** It is a dim line inside the `needs you` panel — `to check · 8` at the +left, and at the right the one clause that says what the group is: + +``` + to check · 8 finished, nobody has checked it + tier-B subs 3 files · 1d + package release radar 1d +``` + +The line is drawn only while the group has rows, and the rows are **newest first** — the +opposite of the questions above them, because nothing here is waiting and the freshest +landing is the one still in your head. + +**A landing is one line at rest**: its name, its file count and how long ago. A landing +with no files to its name draws nothing where the count would be, never `0 files`. + +**The row under the cursor grows a second line** — the first sentence of what the work came +to, and its two answers at the right: + +``` + tier-B subs 3 files · 1d + built the tier-B subscription flow a accept n not right +``` + +The two words are the task's own (`accept`/`not right` for work nobody could check, +`resolve it`/`drop it` for a branch that would not fasten) and the two keys are +**`a` and `n`, the same keys the landing card and the task's record take** — so what you +learn here works everywhere else. `enter` opens the conversation on the task's record. + +**A landing wears no mark.** The `?` means something has stopped and will not move; this +work has already finished. + +**Only a landing ages.** A `your call` is a row for two days after it landed; after that the +panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the tasks +place, where every one of them still is. A question a conversation is stopped on, and a +watch that needs somebody, never age off home. -**The top question that has answers draws them on its own row** — `1 allow once 2 always -3 deny`, three at most, then `enter` — and **a digit answers it from anywhere on home**, +**A landing on `to check` is not repeated by `since you left`.** It goes back to that panel +as an ordinary line once it has been answered or has aged out of the group. + +**On a short frame the whole group folds to one line before any question gives way** — +`8 to check · tasks`, which opens the tasks place. + +## Answer from home — a digit answers the question that is drawing its answers + +**Exactly one row of the frame draws its answers** — `1 allow once 2 always 3 deny`, +three at most, then `enter` — and **the key answers that row from anywhere on home**, wherever the cursor is. Every other row says `enter` instead, so one `1` is never on the screen twice with two meanings. The answers are that question's own, on its own keys: there is no home-specific `y`/`n` translation anywhere on this screen. +**Which row is it?** The row under the cursor when that row can take an answer, and the top +answerable row otherwise. So a digit works on a frame you have not walked, and once you have +walked onto a landing in `to check` its `a`/`n` are the keys that are live. + Every kind of question can be answered this way: the whole question is in the file home reads, and the answer goes back through the one door that knows which part of the engine is waiting on it. **Not yet a question the model raised with `ask`**: that row says @@ -118,9 +172,10 @@ With something typed in the box a digit is a character going into it, never an a ## What opens when I press a landed row on home — I clicked a needs you row and it opened the chat **A task's row opens its conversation with the task's record in front.** `enter` on a -`your call` row in `needs you` opens the conversation that ran the work, on the task's -record, where the question and its two answers are — whatever project it belongs to, with -the conversation you were in left running behind it. +`to check` row opens the conversation that ran the work, on the task's record, where the +question, its whole report and its two answers are — whatever project it belongs to, with +the conversation you were in left running behind it. You do not have to open it to answer: +`a` and `n` on the row itself send the same two answers. A conversation's own question opens that conversation, where the question is waiting above the box. Work that landed also reaches home through **the `since you left` panel**: a line @@ -142,6 +197,10 @@ Three ways to settle it, and they are the same door: whatever that row is asking (`accept`/`not right`, `resolve it`/`drop it`), plus `[s] tell it`, which opens the task's page rather than answering, and a dimmer `[d] let aforge decide this one`; +- **home** — walk onto its row in `to check` and press `a` or `n`. The conversation applies + it on its own beat; if it is not running, the answer waits on its doorstep and is applied + the next time that conversation runs, and the row says + `answered · waiting for it to pick that up` meanwhile; - **the task's room** — enter on its roster row opens it, and the same chips stand at the foot of the page; `a`, `n`, `s` and `d` over an empty box answer it with nothing selected; @@ -370,7 +429,7 @@ instead, **the foot names the chord that still reaches them** — `ctrl+o open f `ctrl+e pause` on a standing order — and the rest are on their chords too: `ctrl+y` copy path, `ctrl+e` put away. `ctrl+o` on a `projects` row opens that project's folder. -A digit answers the top question in `needs you` wherever you are standing. `enter` acts on +A digit answers the one row of `needs you` drawing its answers, wherever you are standing. `enter` acts on the row under the cursor. `alt+.` draws the map. ## Why is the needs you heading highlighted, why is one project name darker than the others diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index c2afc76933..0df5284d72 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1984,7 +1984,7 @@ what each holds), and its keys are a small grammar: | --- | --- | | `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the column you are in, from one panel into the next; `↑` off the top of a column reaches **the tab bar** (*The tab bar is a row the cursor can stand on*) | | `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on | -| a digit | answers **the top question in `needs you`**, from anywhere on home, with no cursor move — its answers are drawn on its own row, `1 allow once 2 always 3 deny` | +| a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `to check` offers `a accept n not right` | | `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a spend or `next up` row opens spend or standing, a fold line opens the place it names | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index abb42edfc9..d012653dab 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -416,7 +416,7 @@ project, as **seven panels** in one, two or three columns: `needs you`, `where y empty panel keeps its heading and one dim line naming what arrives there. Its own keys are in the **Home** page: `↑↓` walk a panel, `←→` cross columns, a digit -answers the top question in `needs you`, and `enter` opens a row. `tab` is the way to the +answers the one row of `needs you` drawing its answers, and `enter` opens a row. `tab` is the way to the next place, and the errand pane is taken into with `→` rather than `tab`. Home has no `alt+` keys — `alt+g` and `alt+q` are unbound there. From 245e82abdce51bb57eb2e9fdec899d15e525d4dd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:29:54 -0400 Subject: [PATCH 4/8] demo home: a group of landings nobody has checked, across three projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture had ONE `unverified` row, which draws a `to check` group of one — the shape a group line, its count, its clause and its fold are all untested by. Four more, deliberately unlike each other: seventeen files, no files, a name long enough to be cut, and one old enough to age onto the fold. Co-Authored-By: Claude Opus 5 --- cmd/aforge-demo-home/seed_work.go | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmd/aforge-demo-home/seed_work.go b/cmd/aforge-demo-home/seed_work.go index 34a4470a48..76e1080e20 100644 --- a/cmd/aforge-demo-home/seed_work.go +++ b/cmd/aforge-demo-home/seed_work.go @@ -174,6 +174,56 @@ var demoTasks = []demoTask{ DurationMS: 7 * 60 * 1000, }, }, + // AND A GROUP OF LANDINGS NOBODY HAS CHECKED, ACROSS THREE PROJECTS. Home's + // `needs you` panel holds them as its `to check` group (homepanel_needs.go), + // and a fixture with ONE of them draws a group of one — which is the shape a + // group line, a count, a clause and a fold all fail to be tested by. These + // are deliberately unlike each other: one wrote seventeen files, one wrote + // none, one has a name long enough to be cut, and one is old enough to age + // onto the fold rather than onto a row. + { + project: firstProjectName, talk: "Reading the Ledger", ago: 26 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Take the fortnight's spend apart by model", Status: string(session.TaskUnverified), + Outcome: "Opus is four fifths of the fortnight and nearly all of it is one conversation; the split is in the page.", + Files: []string{"docs/design/spend/fortnight.md"}, + FilesChanged: 1, Cost: 0.19, Model: "anthropic/claude-sonnet-4", Tokens: 24_400, + DurationMS: 5 * 60 * 1000, + }, + }, + { + project: firstProjectName, talk: "The Manual's Missing Page", ago: 30 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Write the page about reading a task, and every question it should answer", + Status: string(session.TaskUnverified), + Outcome: "Seventeen sections, each under two thousand characters, and every probe in the table reaches one of them.", + Files: []string{ + "internal/manual/chat/reading-a-task-page.md", "internal/manual/chat/tasks.md", + "internal/manual/chat/home.md", "internal/manual/chat/keys.md", + }, + FilesChanged: 17, Cost: 0.61, Model: "anthropic/claude-opus-4.1", Tokens: 88_200, + DurationMS: 19 * 60 * 1000, + }, + }, + { + project: "pricing-site", talk: "Pricing Research", ago: 7 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "2", Title: "Read what the four nearest competitors charge", Status: string(session.TaskUnverified), + Outcome: "Three of the four price per seat and one per workspace; nobody could check the figures against a source.", + Cost: 0.08, Model: "anthropic/claude-sonnet-4", Tokens: 11_300, + DurationMS: 3 * 60 * 1000, + }, + }, + { + project: "infra", talk: "Rotating the Deploy Key", ago: 4 * 24 * time.Hour, + entry: session.TaskIndexEntry{ + ID: "1", Title: "Rotate the deploy key and re-fasten the runners", Status: string(session.TaskUnverified), + Outcome: "The key is rotated and both runners took it; the third host was not reachable to prove.", + Files: []string{"terraform/runners.tf", "runbooks/deploy-key.md"}, + FilesChanged: 2, Cost: 0.24, Model: "anthropic/claude-sonnet-4", Tokens: 31_100, + DurationMS: 8 * 60 * 1000, + }, + }, } // writeTaskIndex appends every row to its own project's index and answers how From 82b70b52e20ce13d20df9c46e7249f8912cd2a3d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 11:33:09 -0400 Subject: [PATCH 5/8] home: one landing is one row of the panel, and its facts are one clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving the binary found the same landing on two rows of `needs you`: a live conversation waiting on nothing but its own landing writes `waiting on you · your call on ` with NO question object, so it was drawn as a question above the `to check` row for the same work. The landing's row is the one that survives. Also from the drive: the group line stands at the panel's own left edge, and a landing's files and age are one clause, `3 files · 1d`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/tui3/homecell.go | 2 +- internal/tui3/homeneedsgroups_test.go | 28 +++++++++++++ internal/tui3/homepanel_live_test.go | 2 +- internal/tui3/homepanel_needs.go | 57 +++++++++++++++++++++++---- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 190770bccd..c863cd1537 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -211,7 +211,7 @@ func homeSparkCells(values []float64) []string { // group is a sorting of rows INSIDE one panel, and a second thing on the column // wearing heading ink would read as a second panel. func homeCellGroup(cell *homeCell, width int, pal palette) string { - return homeCellLeadBlank + switcherSides(max(1, width-homeGridLead), cell.title, cell.right, pal.dim, pal.dim) + return switcherSides(max(1, width), cell.title, cell.right, pal.dim, pal.dim) } // homeCellQuiet is a whisper or a fold: dim words under the rows' own lead. diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index 022bbbceca..07b748cfa6 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -191,3 +191,31 @@ func TestThePulseCountsBothGroupsOfNeedsYou(t *testing.T) { t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(panelRows(a, panelNeeds))) } } + +// SAID ONCE INSIDE THE PANEL TOO. A live conversation waiting on nothing but its +// own landing writes `waiting on you · your call on <title>` with no question +// object ([session.Agent.waitingOnPerson] reads a pending decision last), and +// drawing it as a question put the same piece of work on two rows of one panel. +// The landing's row is the one that survives — it has the work's own name, its +// files and its two answers. +func TestAConversationWaitingOnItsOwnLandingIsOnlyTheLandingsRow(t *testing.T) { + l := newLiveLab(t) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: "your call on fix the flaky sieve"}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" { + t.Fatalf("the landing is on the panel twice, or not at all: %+v", rows) + } + // AND A CONVERSATION WITH A QUESTION OF ITS OWN KEEPS ITS ROW, landing or no + // landing: the question is a different thing, and it blocks. + l2 := newLiveLab(t) + l2.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Question: consentQuestionAt(7, "needs your ok to run bash", l2.now.Add(-time.Minute))}) + l2.landed("4", "fix the flaky sieve", 30*time.Minute) + b := l2.open() + if rows := panelRows(b, panelNeeds); len(rows) != 2 { + t.Fatalf("a conversation with a question of its own lost its row: %+v", rows) + } +} diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index e90c9b691f..1dd49c4084 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -130,7 +130,7 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) { FilesChanged: 3}) a := l.open() rows := panelRows(a, panelNeeds) - if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].tag != "3 files" { + if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].right != "3 files · 30m" { t.Fatalf("the task's call is not a one-line row of needs you: %+v", rows) } if rows[0].mark != cellMarkNone { diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 392024cd5a..617ef83898 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -118,10 +118,20 @@ func needsAged(asked, now time.Time) bool { // needsAsked is the conversations and watches the switcher already ranks as // waiting on somebody. +// +// A CONVERSATION WAITING ONLY ON ITS OWN LANDING IS NOT ONE OF THEM — THE +// LANDING IS. [session.Agent.waitingOnPerson] reads a pending decision LAST and +// has no question object to write for it, so such a session's presence says +// `waiting on you · your call on <title>` with [session.PresenceQuestion] left +// zero. Drawn as a question, that is the same piece of work twice on one panel: +// once under the CONVERSATION's name with a bare `enter`, and once under the +// TASK's name with its files and its two answers. The second row is the better +// one and this drops the first (the spec of record's "said once", applied inside +// the panel as well as across the columns). func needsAsked(in *homeGridInput) []needsItem { var items []needsItem for _, row := range in.rows { - if !row.needs { + if !row.needs || needsOnlyItsOwnLanding(in, row) { continue } cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord} @@ -143,6 +153,26 @@ func needsAsked(in *homeGridInput) []needsItem { return items } +// needsOnlyItsOwnLanding reports that this row is a conversation whose presence +// offers NO question of its own and that has a landing in the `to check` group, +// which together mean the only thing it is waiting on is that landing. +// +// BOTH HALVES MATTER. A conversation with a question on its desk always writes +// the question object, so a missing one is the engine saying "nothing is being +// asked here"; and without a landing of its own on the screen there would be +// nothing left to say it, so the row stays. +func needsOnlyItsOwnLanding(in *homeGridInput, row switcherRow) bool { + if row.kind != switcherConversation || row.session.Presence.Question.Kind != "" { + return false + } + for _, call := range in.calls { + if call.line.task != nil && call.line.task.SessionID == row.session.ID { + return true + } + } + return false +} + // needsCalls is every task the project's record marks as the person's call — // work that finished and nobody could check, a landing that turned the work // back, a branch that would not fasten. They are read off the world's own index @@ -209,8 +239,8 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta // and will not move until somebody answers it; a landing has already // finished, and a column of question marks over work that is DONE was the // screen saying the opposite of what was true. - cell := &homeCell{panel: panelNeeds, title: title, right: sinceAt(asked, now), - tag: needsFilesWord(entry), key: needsCallKey + entry.ID, + cell := &homeCell{panel: panelNeeds, title: title, right: needsCallFacts(entry, asked, now), + key: needsCallKey + entry.ID, grows: true, sub: needsCallSub(status), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, dir: homeBucketOf(row.Transcript), task: &entry, cell: cell} @@ -227,13 +257,24 @@ func needsCallSub(status session.TaskStatus) string { return status.Word } -// needsFilesWord is how many files a landing wrote, and NOTHING for a landing -// that wrote none — the emptiness law: `0 files` is a fact drawn as a hole. -func needsFilesWord(entry session.TaskIndexEntry) string { +// needsCallFacts is a landing's right margin: how many files it wrote and how +// long ago, as ONE clause — `3 files · 1d`. +// +// IT IS ONE CLAUSE AND NOT TWO FACTS SIDE BY SIDE because they are read as one +// sentence about the same piece of work, and because a row cut between them +// would leave `3 files` with no age beside a column of rows that all have one. +// A landing that wrote no files says NOTHING where the count would be, never +// `0 files` (the emptiness law). +func needsCallFacts(entry session.TaskIndexEntry, asked, now time.Time) string { + age := sinceAt(asked, now) if entry.FilesChanged <= 0 { - return "" + return age + } + files := itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) + if age == "" { + return files } - return itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged) + return files + rowSep + age } // needsCallAnswers is the two answers a landing offers, IN THE TASK'S OWN WORDS From e4a3f8c05196459558ebe8e5784ecf5ad6ecd340 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Fri, 11 Sep 2026 11:36:25 -0400 Subject: [PATCH 6/8] home: a landing's two answers are home's own digits, and the grown line is the report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BARE LETTER ON HOME ALWAYS TYPES — the box promises "type to search or start something new" and the promise has no asterisk — so `[a]`/`[n]`, the landing's keys everywhere else, cannot be answer keys on this screen. The WORDS stay the task's own ([session.TaskAsk]); the key a person presses here is the drawn digit, which is the one printable exception home already makes, and it is mapped back to the landing's own key before the answer leaves the surface. And the line the cursor grows is the report's first sentence, with the engine's reason standing in only where the work left no report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- internal/manual/chat/home.md | 29 ++++++---- internal/manual/chat/keys.md | 2 +- internal/tui3/homeneedsgroups_test.go | 4 +- internal/tui3/homepanel_live_test.go | 21 ++++--- internal/tui3/homepanel_needs.go | 82 ++++++++++++++++++++------- 5 files changed, 94 insertions(+), 44 deletions(-) diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 98528c8709..0f7c66d9b9 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -19,7 +19,7 @@ answering one question you would ask walking up to a colleague's desk: since you left · 12h to check · 3 finished, nobody has checked it Spark Fleet Ssh Audit · 2 hosts up, 1 not tier-B subs 3 files · 1d made apartments-minto-street.md - built the tier-B flow a accept n not right + built the tier-B flow 1 accept 2 not right package release radar 1d spend today $0.14 of $20 1 more · 15 older · tasks ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20 opus 63% · 3 chats and 1 task today @@ -120,13 +120,16 @@ to, and its two answers at the right: ``` tier-B subs 3 files · 1d - built the tier-B subscription flow a accept n not right + built the tier-B subscription flow 1 accept 2 not right ``` -The two words are the task's own (`accept`/`not right` for work nobody could check, -`resolve it`/`drop it` for a branch that would not fasten) and the two keys are -**`a` and `n`, the same keys the landing card and the task's record take** — so what you -learn here works everywhere else. `enter` opens the conversation on the task's record. +The two words are the task's own — `accept`/`not right` for work nobody could check, +`resolve it`/`drop it` for a branch that would not fasten. **The two keys are `1` and `2` +here and `[a]`/`[n]` everywhere else**, and that is deliberate: a bare letter on home always +types, because the box under it promises "type to search or start something new" and the +promise has no asterisk. A digit drawn on the row is the one printable exception home makes, +so a landing takes `1` and `2` on this screen and its own letters on its card, in its room +and on its record. `enter` opens the conversation on the task's record. **A landing wears no mark.** The `?` means something has stopped and will not move; this work has already finished. @@ -147,12 +150,16 @@ as an ordinary line once it has been answered or has aged out of the group. **Exactly one row of the frame draws its answers** — `1 allow once 2 always 3 deny`, three at most, then `enter` — and **the key answers that row from anywhere on home**, wherever the cursor is. Every other row says `enter` instead, so one `1` is never on the -screen twice with two meanings. The answers are that question's own, on its own keys: -there is no home-specific `y`/`n` translation anywhere on this screen. +screen twice with two meanings. The words are that question's own and nothing here is put +into a vocabulary of home's; there is no `y`/`n` anywhere on this screen. + +**The keys are the question's own wherever those keys are digits**, which is every card a +conversation stops on. The one place they are not is a landing in `to check`: its keys are +`[a]`/`[n]` everywhere else and `1`/`2` here, because a bare letter on home types. **Which row is it?** The row under the cursor when that row can take an answer, and the top answerable row otherwise. So a digit works on a frame you have not walked, and once you have -walked onto a landing in `to check` its `a`/`n` are the keys that are live. +walked onto a landing in `to check` its `1`/`2` are the keys that are live. Every kind of question can be answered this way: the whole question is in the file home reads, and the answer goes back through the one door that knows which part of the engine @@ -175,7 +182,7 @@ With something typed in the box a digit is a character going into it, never an a `to check` row opens the conversation that ran the work, on the task's record, where the question, its whole report and its two answers are — whatever project it belongs to, with the conversation you were in left running behind it. You do not have to open it to answer: -`a` and `n` on the row itself send the same two answers. +`1` and `2` on the row itself send the same two answers. A conversation's own question opens that conversation, where the question is waiting above the box. Work that landed also reaches home through **the `since you left` panel**: a line @@ -197,7 +204,7 @@ Three ways to settle it, and they are the same door: whatever that row is asking (`accept`/`not right`, `resolve it`/`drop it`), plus `[s] tell it`, which opens the task's page rather than answering, and a dimmer `[d] let aforge decide this one`; -- **home** — walk onto its row in `to check` and press `a` or `n`. The conversation applies +- **home** — walk onto its row in `to check` and press `1` or `2`. The conversation applies it on its own beat; if it is not running, the answer waits on its doorstep and is applied the next time that conversation runs, and the row says `answered · waiting for it to pick that up` meanwhile; diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 0df5284d72..c047fe2222 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -1984,7 +1984,7 @@ what each holds), and its keys are a small grammar: | --- | --- | | `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the column you are in, from one panel into the next; `↑` off the top of a column reaches **the tab bar** (*The tab bar is a row the cursor can stand on*) | | `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on | -| a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `to check` offers `a accept n not right` | +| a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `to check` offers `1 accept 2 not right`, its own `[a]`/`[n]` being letters and letters always type on home | | `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a spend or `next up` row opens spend or standing, a fold line opens the place it names | | `pgup` / `pgdown` | jump a screenful | | `tab` | **the next place** on the bar | diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index 07b748cfa6..64b8d5a060 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -80,7 +80,7 @@ func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) { if got := strings.Count(frame, "1 allow once"); got != 1 { t.Fatalf("the consent's chips are drawn on %d rows:\n%s", got, frame) } - if got := strings.Count(frame, session.LandingYesKey+" accept"); got != 0 { + if got := strings.Count(frame, needsYesKey+" accept"); got != 0 { t.Fatalf("a landing nobody is standing on drew its answers:\n%s", frame) } // AND THE CURSOR OUTRANKS THE TOP ROW. Walked onto the landing, the chips @@ -94,7 +94,7 @@ func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) { if strings.Count(frame, "1 allow once") != 0 { t.Fatalf("the chips stayed on the top question with the cursor on a landing:\n%s", frame) } - if got := strings.Count(frame, session.LandingYesKey+" accept"); got != 1 { + if got := strings.Count(frame, needsYesKey+" accept"); got != 1 { t.Fatalf("the landing under the cursor drew its answers %d times:\n%s", got, frame) } } diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go index 1dd49c4084..525fdbc273 100644 --- a/internal/tui3/homepanel_live_test.go +++ b/internal/tui3/homepanel_live_test.go @@ -212,7 +212,8 @@ func TestToCheckDrawsTheNewestLandingFirst(t *testing.T) { func TestALandingGrowsItsReportAndAnswersUnderTheCursor(t *testing.T) { l := newLiveLab(t) l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve", - Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)}) + Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute), + Outcome: "Reseeded the generator and the sieve is stable over a thousand runs."}) a := l.open() at := -1 for i, line := range a.home.lines { @@ -223,17 +224,16 @@ func TestALandingGrowsItsReportAndAnswersUnderTheCursor(t *testing.T) { if at < 0 { t.Fatal("the landing is not a line of the column") } - if frame := homeText(a); homeLineAfter(frame, "fix the flaky sieve") != "" && - strings.Contains(homeLineAfter(frame, "fix the flaky sieve"), "nobody could check it") { + if frame := homeText(a); strings.Contains(frame, "Reseeded the generator") { t.Fatalf("the landing grew its line with the cursor elsewhere:\n%s", frame) } a.home.cursor = at frame := homeText(a) under := homeLineAfter(frame, "fix the flaky sieve") - if !strings.Contains(under, "nobody could check it") { - t.Fatalf("the cursor row did not grow its report:\n%s", frame) + if !strings.Contains(under, "Reseeded the generator") { + t.Fatalf("the cursor row did not grow the report's first sentence:\n%s", frame) } - if !strings.Contains(under, session.LandingYesKey+" accept") || !strings.Contains(under, session.LandingNoKey+" not right") { + if !strings.Contains(under, needsYesKey+" accept") || !strings.Contains(under, needsNoKey+" not right") { t.Fatalf("the grown line does not carry the ask's own answers:\n%s", frame) } } @@ -256,8 +256,13 @@ func TestALandingUnderTheCursorTakesItsOwnAnswerKey(t *testing.T) { } } homeText(a) - if _, took := a.homeGridAnswer(session.LandingYesKey); !took { - t.Fatal("the landing under the cursor did not take its own accept key") + // AND A BARE LETTER STILL TYPES, which is why the key on this screen is a + // digit: `a` is the landing's key everywhere else and must not be one here. + if _, took := a.homeGridAnswer(session.LandingYesKey); took { + t.Fatal("a bare letter answered a landing from home") + } + if _, took := a.homeGridAnswer(needsYesKey); !took { + t.Fatal("the landing under the cursor did not take the accept it draws") } if len(left) != 1 || left[0] != string(session.QuestionLanding)+"/4/"+session.LandingYesKey { t.Fatalf("the accept did not reach the conversation's doorstep as a landing answer: %v", left) diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 617ef83898..20c568241c 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -241,16 +241,20 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta // screen saying the opposite of what was true. cell := &homeCell{panel: panelNeeds, title: title, right: needsCallFacts(entry, asked, now), key: needsCallKey + entry.ID, - grows: true, sub: needsCallSub(status), answers: needsCallAnswers(status)} + grows: true, sub: needsCallSub(entry, status), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, dir: homeBucketOf(row.Transcript), task: &entry, cell: cell} return needsItem{asked: asked, line: line} } -// needsCallSub is the line a landing grows under the cursor: the first sentence -// of what the work came to, and the engine's own reason for the call where the -// work left no report. -func needsCallSub(status session.TaskStatus) string { +// needsCallSub is the line a landing grows under the cursor: THE FIRST SENTENCE +// OF WHAT THE WORK CAME TO, which is what a person needs in order to say whether +// it holds. The engine's own reason for the call stands in where the work left no +// report, and the tier's word where there is neither. +func needsCallSub(entry session.TaskIndexEntry, status session.TaskStatus) string { + if sub := switcherFirstLine(entry.Outcome); sub != "" { + return sub + } if sub := switcherFirstLine(status.Reason); sub != "" { return sub } @@ -277,23 +281,56 @@ func needsCallFacts(entry session.TaskIndexEntry, asked, now time.Time) string { return files + rowSep + age } -// needsCallAnswers is the two answers a landing offers, IN THE TASK'S OWN WORDS -// AND ON TASK-STATES' OWN KEYS. +// The two keys a landing is answered with ON HOME, and the landing keys they +// stand for. +// +// ── WHY THEY ARE DIGITS HERE AND LETTERS EVERYWHERE ELSE ──────────────────── +// +// A landing is `[a] accept · [n] not right` on its card, in its room and on its +// record, and [session.LandingYesKey] is that letter (answers.go: the landing +// keys are task-states' own, letter for letter). HOME CANNOT OFFER A LETTER. Its +// foot promises "type to search or start something new" and the promise has no +// asterisk — a bare letter on this screen always types, whatever the cursor is +// resting on (home.go's [app.homeKey] states it), and the ONE printable +// exception it allows is a digit drawn on the row itself. An `a` that sometimes +// accepted a landing and sometimes began "a site for my café" is the mode that +// law exists to forbid. +// +// So the KEY is home's and the WORDS are the task's: `1 accept 2 not right`, +// read off [session.TaskAsk], mapped back to the landing's own keys before the +// answer leaves this surface ([needsLandingKey]). Nothing about the answer is +// renumbered — only the key a person presses on THIS screen, which is the same +// bargain the drawn digit has always been. +const ( + needsYesKey = "1" + needsNoKey = "2" +) + +// needsCallAnswers is the two answers a landing offers, in the task's own words +// on home's own two digits. // // THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No, filled in by the // engine for the row's own shape — `accept` / `not right` for work nobody could // check, `resolve it` for a branch that would not fasten) and this file spells -// none of them. THE KEYS ARE [session.LandingYesKey] AND [session.LandingNoKey], -// letter for letter, because a landing is answered with the same two keys -// wherever it is drawn (answers.go states the law from the writing end) and a -// surface that renumbered them to `1` and `2` would be teaching a person a key -// that does not work on the record they open next. +// none of them. A row whose ask has no words offers nothing, which is the +// absence law: there is no default pair to fall back on. func needsCallAnswers(status session.TaskStatus) string { yes, no := strings.TrimSpace(status.Ask.Yes), strings.TrimSpace(status.Ask.No) if yes == "" || no == "" { return "" } - return session.LandingYesKey + " " + yes + homeCellGap + session.LandingNoKey + " " + no + return needsYesKey + " " + yes + homeCellGap + needsNoKey + " " + no +} + +// needsLandingKey is home's digit as the landing key the engine answers to. +func needsLandingKey(key string) (string, bool) { + switch key { + case needsYesKey: + return session.LandingYesKey, true + case needsNoKey: + return session.LandingNoKey, true + } + return "", false } // needsChecking is every landing the `to check` group is drawing, by the @@ -392,8 +429,8 @@ func needsLandingQuestion(line homeLine) (session.PresenceQuestion, bool) { return session.PresenceQuestion{}, false } return session.PresenceQuestion{Kind: session.QuestionLanding, ID: id, Options: []session.AnswerOption{ - {Key: session.LandingYesKey, Label: yes}, - {Key: session.LandingNoKey, Label: no, Safe: true}, + {Key: needsYesKey, Label: yes}, + {Key: needsNoKey, Label: no, Safe: true}, }}, true } @@ -405,8 +442,8 @@ func needsAnswerWords(clause string) (yes, no string, ok bool) { if len(parts) != 2 { return "", "", false } - yes = strings.TrimPrefix(parts[0], session.LandingYesKey+" ") - no = strings.TrimPrefix(parts[1], session.LandingNoKey+" ") + yes = strings.TrimPrefix(parts[0], needsYesKey+" ") + no = strings.TrimPrefix(parts[1], needsNoKey+" ") return yes, no, yes != "" && no != "" } @@ -431,7 +468,8 @@ func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) { return nil, false } label := question.Label(key) - if label == "" { + answer, ok := needsLandingKey(key) + if label == "" || !ok { return nil, false } row := a.homeTrue(line.row) @@ -445,9 +483,9 @@ func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) { if !ok { return nil, false } - answer := session.Answer{At: time.Now(), Kind: session.QuestionLanding, ID: question.ID, - Key: key, Picked: []string{key}, DecidedBy: session.DecidedByPerson} - if err := doors.ResolveQuestion(answer); err != nil { + sent := session.Answer{At: time.Now(), Kind: session.QuestionLanding, ID: question.ID, + Key: answer, Picked: []string{answer}, DecidedBy: session.DecidedByPerson} + if err := doors.ResolveQuestion(sent); err != nil { return nil, false } a.home.say(answerSentWord+label, "") @@ -457,7 +495,7 @@ func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) { if a.leaveAnswer == nil || dir == "" { return nil, false } - if err := a.leaveAnswer(dir, question.Kind, question.ID, key); err != nil { + if err := a.leaveAnswer(dir, question.Kind, question.ID, answer); err != nil { a.home.say(answerFailedWord, dir) return nil, true } From c85ea8c4fa03f1e89bad50f578d7be18908dfa00 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Fri, 11 Sep 2026 11:38:57 -0400 Subject: [PATCH 7/8] docs: the change entry for #900 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../unreleased/900-home-needs-groups.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/changes/unreleased/900-home-needs-groups.md diff --git a/docs/changes/unreleased/900-home-needs-groups.md b/docs/changes/unreleased/900-home-needs-groups.md new file mode 100644 index 0000000000..13d29425fb --- /dev/null +++ b/docs/changes/unreleased/900-home-needs-groups.md @@ -0,0 +1,27 @@ +--- +kind: changed +title: home's `needs you` is two groups — the questions that stopped something, then what to check +pr: 900 +surface: [chat] +invalidates: + - "`needs you` drew a conversation's question, a watch and a landed task as ONE flat list ordered oldest-first. It is TWO GROUPS on one panel: the live questions first (a conversation stopped on a question or a consent card, a watch that needs somebody), longest wait first, and under them a dim group line `to check · N` with the clause `finished, nobody has checked it` at its right, holding every task whose call is the person's, NEWEST first. A live question is always above a landing, however old the landing is." + - "Every landing on home carried the sub-line `landed unchecked · enter to look` (`needsUncheckedWord` in `internal/tui3/placeprose.go`). That constant is DELETED and no row says it. A landing is ONE line — its name and `N files · age` — and only the row under the cursor grows a second line, which is the report's first sentence with the task's own two answers at its right." + - "A landing on `needs you` wore the amber `?` mark. It wears NONE. The mark means a thing has stopped and will not move until somebody answers it, and a landing has already finished; law 8's `two marks` is unchanged and the `?` is now only on a stopped conversation or watch." + - "The heading `needs you · N` counted every row including the landings. It counts the QUESTIONS only; the landings are counted on the group's own line. A frame holding only landings draws `needs you` with no number at all, which is the emptiness law applied to a heading." + - "A digit on home answered THE TOP question of `needs you`, always. It answers THE ONE ROW OF THE FRAME THAT IS DRAWING ITS ANSWERS: the row under the cursor when that row can take one, and the top answerable row otherwise. Drawing and routing read the same function (`app.homeAnswerAt`), so the chip on the screen and the key a person presses can never be two different rows." + - "A landed task could not be answered from home at all — `homeRowAnswers` gated the chips on a live session's presence question, which a `to check` row by construction does not have. A landing on `to check` now draws its own two answers in the TASK'S OWN WORDS (`session.TaskAsk.Yes`/`.No`) and takes them. THE KEYS ON HOME ARE `1` AND `2`, not the `[a]`/`[n]` the landing card, the task's room and its record take: a bare letter on home always types (the box promises `type to search or start something new` and the promise has no asterisk), and the drawn digit is the one printable exception that screen makes. The digit is mapped back to `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface, so the engine sees the same answer it has always seen." + - "A conversation whose presence says `waiting on you · your call on <title>` with no question object was drawn as a question on `needs you`, above the `to check` row for the same piece of work — one landing on two rows of one panel. `internal/session`'s `Agent.waitingOnPerson` reads a pending decision last and has no question object to write for it, so this is the ordinary shape of any live conversation sitting on a landed task. The conversation's row is dropped and the landing's row stands: it has the work's own name, its files and its answers." + - "`since you left` listed every landing since the look stamp, including the ones `needs you` was showing as your call. It DROPS a landing that the `to check` group is drawing, and shows it again as an ordinary line once that landing has been answered or has aged out of the group." + - "The pulse's `N want you` counted conversations, watches and errands and never a landed task, so it disagreed with `needs you · N` under it. It is the SUM OF BOTH GROUPS now, counted through the panel's own reading of a landing (`needsCallOf`, `needsAged`) rather than through a second test. The tab bar's `tasks` number is unrelated and unchanged — `placeCounts.ChangedIn` is `how many things moved since you last looked at this place`, and that seam is still nil." + - "A panel squeezed by taking rows off the bottom until it reached its floor, and there was no such thing as a group inside a panel. `homePanelRows.group` is a run of rows at the FOOT of a panel's list drawn under a dim line of its own; because they are last, the ordinary bottom-up cut spends them first, and when none is left the group's line goes and the panel's own fold names the group — `4 to check · 1 older · tasks`. `homeGridPanel.shrink` is now a plain walk down from the natural height rather than the old `least - 2` arithmetic." + - "The demo home (`cmd/aforge-demo-home`) had exactly ONE `unverified` row, which draws a `to check` group of one. It has five, across three projects, deliberately unlike each other: seventeen files, no files at all, a name long enough to be cut, and one old enough to age onto the fold instead of onto a row." +--- + +The note on the panel was that the difference between `needs you` and `to check` is not +obvious, and the fix is one dim clause on the group's line rather than a label on every row. +Seven words, once, in the place a heading's clause already lives — and the cursor row's +`1 accept 2 not right` says what checking one actually means. + +Two things the tmux drive found that unit tests had not: the same landing drawn on two rows +of one panel whenever its conversation is live, and that `a`/`n` cannot be answer keys on a +screen whose foot promises that typing starts a conversation. From 2a8b07e6fde377b3c8da849c219f2713f34ec7b0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha <instrument.santosh@gmail.com> Date: Fri, 11 Sep 2026 12:02:24 -0400 Subject: [PATCH 8/8] =?UTF-8?q?home:=20what=20the=20review=20found=20?= =?UTF-8?q?=E2=80=94=20a=20paused=20run=20kept=20its=20row,=20one=20slot?= =?UTF-8?q?=20per=20landing,=20and=20three=20counts=20agree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh-eyes pass over the diff found six things worth fixing and they share one cause: `to check` gives one conversation more than one answerable row, and every reading built when a conversation had at most one was wrong in a new way. - `needsLandingsSpeakFor` is the ENGINE'S SENTENCE and not an absence. Reading "no question object" as "only waiting on its landing" took a conversation stopped at its FUEL GATE or on a sub-harness offer off home entirely: both lanes bank no card at the desk and say so. The test is now `your call on <title of one of its own landings>`. - `needsCallOf` refuses an archived conversation, so work somebody put away is neither a row of `to check` nor a want. - `needsWants` is the ONE reading of how many rows of `needs you` a conversation is, and the pulse counts from it — it used to count the conversation AND its landing for the row it had just dropped. - `answerSlot` keys a sent answer by the QUESTION and not by the conversation. Answering a second landing forgot the first, whose chips came back and whose key sent a second answer for a question already answered from here. - `needsOpenWord` no longer claims a landing says `enter`, and `needsCallAnswers` states why every ask that reaches home is [session.TaskAskCheck] (the index carries no merge, ground or phase facts, so `taskAskOf` can only take its check arm) — which is why `needsLandingQuestion` names one kind rather than guessing between two. - DESIGN.md law 7 said the keys are the question's own, which is what this change deliberately departs from for a landing. It says which, and why. - `taskLedgerKey` is the one spelling of a piece of work's identity across home's columns; it was written out three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- docs/design/home-mission-control/DESIGN.md | 10 +- internal/manual/chat/home.md | 4 +- internal/tui3/homeband_answer.go | 17 ++- internal/tui3/homemachine.go | 37 ++---- internal/tui3/homeneedsgroups_test.go | 96 ++++++++++++++- internal/tui3/homepanel_left.go | 14 ++- internal/tui3/homepanel_needs.go | 135 +++++++++++++++++---- 7 files changed, 248 insertions(+), 65 deletions(-) diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md index 49a5c1e7b0..7b05c704e3 100644 --- a/docs/design/home-mission-control/DESIGN.md +++ b/docs/design/home-mission-control/DESIGN.md @@ -72,8 +72,14 @@ own keys, each a door to the place that owns it. The chat list is one panel. it can take an answer and the top answerable row otherwise (#884), so a digit works on a frame nobody has walked and a landing somebody HAS walked onto is the row the key means. The answers are drawn on the row so the key is never a - guess, and they are the question's own keys — a consent's digits, a landing's - `a`/`n`. + guess. THE WORDS ARE THE QUESTION'S OWN EVERYWHERE; the KEYS are too wherever + the question's own keys are digits, which is every card a conversation stops + on. The one exception is a landing in `to check`, whose keys are `[a]`/`[n]` + on its card, in its room and on its record and are `1`/`2` here — because a + bare letter on home types (home.go: the foot promises "type to search or start + something new" and the promise has no asterisk), and the drawn digit is the + one printable exception this screen makes. The digit is mapped back to + `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface. 8. **Two marks, one accent, one hint line.** `GlyphNeedsHuman` in `hueWarn` for a person — a conversation or a watch that has STOPPED, never a landing on `to check`, which has already finished — the single spinner cell for work. Nothing else wears a glyph or a diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 0f7c66d9b9..7fa75d212b 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -123,8 +123,8 @@ to, and its two answers at the right: built the tier-B subscription flow 1 accept 2 not right ``` -The two words are the task's own — `accept`/`not right` for work nobody could check, -`resolve it`/`drop it` for a branch that would not fasten. **The two keys are `1` and `2` +The two words are the task's own — `accept` and `not right` for work nobody could check, +which is every row that reaches this group. **The two keys are `1` and `2` here and `[a]`/`[n]` everywhere else**, and that is deliberate: a bare letter on home always types, because the box under it promises "type to search or start something new" and the promise has no asterisk. A digit drawn on the row is the one printable exception home makes, diff --git a/internal/tui3/homeband_answer.go b/internal/tui3/homeband_answer.go index 906b56bb7f..6f94155343 100644 --- a/internal/tui3/homeband_answer.go +++ b/internal/tui3/homeband_answer.go @@ -117,13 +117,26 @@ func answerable(row session.SessionRow, now time.Time) (session.PresenceQuestion // answerSent is what this window already sent for one row's question, when it // sent one and the question is still the same question. func (a *app) answerSent(row session.SessionRow, question session.PresenceQuestion) (homeAnswered, bool) { - sent, ok := a.answered[strings.TrimSpace(row.Dir)] + sent, ok := a.answered[answerSlot(row.Dir, question)] if !ok || sent.kind != question.Kind || sent.id != question.ID { return homeAnswered{}, false } return sent, true } +// answerSlot is the key this window remembers one answer under: the session +// folder AND the question inside it. +// +// ONE SLOT PER QUESTION, NOT PER CONVERSATION. A conversation used to have at +// most one answerable row on this screen, so its folder was identity enough. +// `to check` gives one conversation a row per landing it is holding +// (homepanel_needs.go), and a single slot meant answering the second landing +// forgot the first — whose chips came back, and whose key could be pressed +// again, sending a second answer for a question already answered from here. +func answerSlot(dir string, question session.PresenceQuestion) string { + return strings.TrimSpace(dir) + "\x00" + string(question.Kind) + ":" + itoa64(question.ID) +} + // drawAnswerBand is the chips, or the line that says they have been pressed. // // A WINDOW THAT CANNOT ANSWER DRAWS NOTHING (the absence law): with no seam @@ -347,7 +360,7 @@ func (a *app) rememberAnswered(dir string, question session.PresenceQuestion, la delete(a.answered, at) } } - a.answered[dir] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now} + a.answered[answerSlot(dir, question)] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now} } // answeringHere reports that the row on home IS the conversation this window is diff --git a/internal/tui3/homemachine.go b/internal/tui3/homemachine.go index de4ae23c46..a0e9a2fa9b 100644 --- a/internal/tui3/homemachine.go +++ b/internal/tui3/homemachine.go @@ -129,17 +129,17 @@ func (a *app) readMachineMoney(now time.Time) { // of what the figure means. func machineCounts(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) { for _, row := range sessions { - // AND A LANDING NOBODY HAS CHECKED IS A WANT TOO, counted through the - // panel's own reading of it (homepanel_needs.go's [needsCallOf] and - // [needsAged]) rather than through a second test here. It is what makes - // `N want you` the sum of the two groups of `needs you`: the count and - // the rows are the same arithmetic, so a pulse over a home can no longer - // claim a number the panel under it does not show. - wants += machineChecks(now, row) + // AND A LANDING NOBODY HAS CHECKED IS A WANT TOO. How many rows of + // `needs you` a conversation is — its own question, its landings, or + // both — is ONE reading and it is the panel's own + // (homepanel_needs.go's [needsWants]), so a pulse over a home cannot + // claim a number the rows under it do not show. It answers zero for an + // archived conversation and for one whose only question IS its landing, + // which is the row the panel drops. + wants += needsWants(row, now) switch { case row.Archived: case row.NeedsPerson(): - wants++ case row.Tasks.Running > 1: hands += row.Tasks.Running case row.Tasks.Running == 1 || row.Live && row.Presence.State == session.PresenceWorking: @@ -176,27 +176,6 @@ func machineCounts(now time.Time, sessions []session.SessionRow, bands map[strin return hands, wants } -// machineChecks is how many of one conversation's landings are waiting to be -// checked — the `to check` group's rows for that conversation, counted with the -// group's own reading. -// -// AN ARCHIVED CONVERSATION IS NOT COUNTED, for the same reason its own row is -// not: it has been put away, and work put away is not waiting on anybody. -func machineChecks(now time.Time, row session.SessionRow) int { - if row.Archived { - return 0 - } - n := 0 - for i := range row.Tasks.Rows { - entry := row.Tasks.Rows[i] - if _, ok := needsCallOf(row, entry); !ok || needsAged(needsCallAt(entry), now) { - continue - } - n++ - } - return n -} - // machineSpentToday is WHAT THIS MACHINE HAS SPENT TODAY, and it is the usage // ledger's answer — the same reading, through the same arithmetic // ([spendDayTotal]), that the spend place draws as `today $3.42 of $500`. diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go index 64b8d5a060..92acc21e93 100644 --- a/internal/tui3/homeneedsgroups_test.go +++ b/internal/tui3/homeneedsgroups_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "path/filepath" "strings" "testing" "time" @@ -28,6 +29,20 @@ func (l *liveLab) openAt(width, height int) *app { return a } +// archive puts one of the lab's conversations away, the way a person does. +func (l *liveLab) archive(id string) { + l.t.Helper() + dir := filepath.Join(l.project("-alpha"), id) + meta, err := session.LoadMeta(dir) + if err != nil { + l.t.Fatal(err) + } + meta.Archived = true + if err := session.SaveMeta(dir, meta); err != nil { + l.t.Fatal(err) + } +} + // landed writes one landing whose call is the person's. func (l *liveLab) landed(id, label string, ago time.Duration) { l.t.Helper() @@ -177,18 +192,95 @@ func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) { } // THE PULSE'S COUNT IS THE SUM OF THE TWO GROUPS, read through the panel's own -// reading of a landing rather than through a second one ([machineChecks]). +// reading of a conversation's rows rather than through a second one +// ([needsWants]). +// +// THE LANDINGS' OWN CONVERSATION IS LIVE AND WAITING ON THEM HERE, which is the +// shape that made the first cut of this count disagree with the rows: the +// conversation contributed a want the panel does not draw a row for. func TestThePulseCountsBothGroupsOfNeedsYou(t *testing.T) { l := newLiveLab(t) l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting, Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))}) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: needsYourCallLead + "fix the flaky sieve"}) l.landed("4", "fix the flaky sieve", 30*time.Minute) l.landed("5", "port the parser", 5*time.Hour) // AND A LANDING THAT AGED OUT IS NOT COUNTED, because it is not a row. l.landed("6", "old business", homeNeedsTaskFresh+time.Hour) a := l.open() if rows := panelRows(a, panelNeeds); len(rows) != 3 || a.machine.wants != len(rows) { - t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(panelRows(a, panelNeeds))) + t.Fatalf("the pulse says %d want you over %d rows: %+v", a.machine.wants, len(rows), rows) + } +} + +// A CONVERSATION STOPPED AT ITS FUEL GATE KEEPS ITS ROW, landing or no landing. +// It banks no card at the desk and so writes no question object, and the first +// cut of the "said once" test read that absence as "this is only waiting on its +// landing" — which took a paused run off home entirely. +func TestAPausedRunKeepsItsRowBesideItsOwnLanding(t *testing.T) { + l := newLiveLab(t) + l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting, + Reason: "out of fuel · 12 of 12 spent"}) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + a := l.open() + rows := panelRows(a, panelNeeds) + if len(rows) != 2 { + t.Fatalf("the paused run lost its row beside its landing: %+v", rows) + } + frame := homeText(a) + if !strings.Contains(frame, "out of fuel") || !strings.Contains(frame, "fix the flaky sieve") { + t.Fatalf("the frame does not hold both:\n%s", frame) + } + if a.machine.wants != len(rows) { + t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(rows)) + } +} + +// WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM: no row on +// `to check`, and nothing in the count. +func TestAnArchivedConversationsLandingIsNotOnToCheck(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.archive("aaaa000000000002") + a := l.open() + if rows := panelRows(a, panelNeeds); len(rows) != 0 { + t.Fatalf("an archived conversation's landing is on to check: %+v", rows) + } + if a.machine.wants != 0 { + t.Fatalf("the pulse counts an archived conversation's landing: %d", a.machine.wants) + } +} + +// ONE ANSWER PER LANDING, AND A CONVERSATION MAY HOLD SEVERAL. This window +// remembers what it sent per QUESTION; a single slot per conversation meant +// answering the second landing forgot the first, whose chips came back and whose +// key sent a second answer for a question already answered from here. +func TestAnsweringASecondLandingDoesNotReopenTheFirst(t *testing.T) { + l := newLiveLab(t) + l.landed("4", "fix the flaky sieve", 30*time.Minute) + l.landed("5", "port the parser", 5*time.Hour) + a := l.open() + var left []string + a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error { + left = append(left, itoa64(id)+"/"+key) + return nil + } + press := func(id string) { + for i, line := range a.home.lines { + if line.task != nil && line.task.ID == id { + a.home.cursor = i + } + } + homeText(a) + a.homeGridAnswer(needsYesKey) + } + press("4") + press("5") + press("4") + want := []string{"4/" + session.LandingYesKey, "5/" + session.LandingYesKey} + if len(left) != len(want) || left[0] != want[0] || left[1] != want[1] { + t.Fatalf("a landing was answered twice: %v", left) } } diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index c3b8f1e8ec..eb288bb863 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -32,7 +32,7 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { checking := needsChecking(in) lines := make([]homeLine, 0, len(in.ledger)) for _, row := range in.ledger { - if row.task != nil && checking[row.task.SessionID+"/"+row.task.ID] { + if row.task != nil && checking[taskLedgerKey(*row.task)] { continue } lines = append(lines, leftLine(row)) @@ -53,12 +53,22 @@ func leftLine(row switcherRow) homeLine { view: row.item, item: row.item.Item, cell: cell} } +// taskLedgerKey is ONE piece of work's identity across home's columns: the +// conversation that ran it and the node's number, which is the only pair that +// is unique ([session.TaskIndexEntry.ID] repeats across sessions). It is spelled +// here because three readers ask it — this panel's own key, the `to check` +// group's set of what it is already drawing, and the comparison between them +// (homepanel_needs.go's [needsChecking]). +func taskLedgerKey(task session.TaskIndexEntry) string { + return task.SessionID + "/" + task.ID +} + // leftKey is a ledger line's identity beside its place word ([homeLine.sameRow]): // the task, the file, or — for a firing or memory's line — its words. func leftKey(row switcherRow) string { switch { case row.task != nil: - return row.task.SessionID + "/" + row.task.ID + return taskLedgerKey(*row.task) case row.path != "": return row.path } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 20c568241c..3585cda8e5 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -43,9 +43,15 @@ import ( type needsPanel struct{ homePanelBase } const ( - // needsOpenWord is what a row says where its answers are not drawn: a - // question with a paragraph, a landing this window has nowhere to leave an - // answer for, any row that is not the frame's answering row. enter opens it. + // needsOpenWord is what a QUESTION row says where its answers are not drawn: + // one with a paragraph, or one that is not the frame's answering row. enter + // opens it. + // + // A LANDING NEVER SAYS IT. Its second line only exists under the cursor, and + // there the answers are the thing the line is for; a `to check` row that this + // window cannot answer draws the report's sentence and nothing at its right, + // which is the emptiness law rather than an instruction repeated on every + // row. enter still opens the record ([app.homeLandOnTask]). needsOpenWord = "enter" // needsAnswersCap is how many of a question's answers fit on its row. A // question with more draws the first ones and then [needsOpenWord], because @@ -121,17 +127,17 @@ func needsAged(asked, now time.Time) bool { // // A CONVERSATION WAITING ONLY ON ITS OWN LANDING IS NOT ONE OF THEM — THE // LANDING IS. [session.Agent.waitingOnPerson] reads a pending decision LAST and -// has no question object to write for it, so such a session's presence says -// `waiting on you · your call on <title>` with [session.PresenceQuestion] left -// zero. Drawn as a question, that is the same piece of work twice on one panel: -// once under the CONVERSATION's name with a bare `enter`, and once under the -// TASK's name with its files and its two answers. The second row is the better -// one and this drops the first (the spec of record's "said once", applied inside -// the panel as well as across the columns). +// writes the sentence `your call on <title>` into the presence with no question +// object, so such a session drew as a question ABOVE the `to check` row for the +// very same piece of work: once under the CONVERSATION's name with a bare +// `enter`, once under the TASK's name with its files and its two answers. The +// second row is the better one and this drops the first (the spec of record's +// "said once", applied inside the panel as well as across the columns). func needsAsked(in *homeGridInput) []needsItem { var items []needsItem for _, row := range in.rows { - if !row.needs || needsOnlyItsOwnLanding(in, row) { + if !row.needs || (row.kind == switcherConversation && + needsLandingsSpeakFor(row.session, needsCallTitlesOn(in, row.session.ID))) { continue } cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord} @@ -153,26 +159,89 @@ func needsAsked(in *homeGridInput) []needsItem { return items } -// needsOnlyItsOwnLanding reports that this row is a conversation whose presence -// offers NO question of its own and that has a landing in the `to check` group, -// which together mean the only thing it is waiting on is that landing. +// needsYourCallLead opens the sentence a session writes into its presence while +// nothing but a landed task is waiting on somebody — internal/session's +// `yourCallLine`, which is the tier's own word and a preposition +// ([tierYourCallWord]; taskpresence.go builds it the same way from the same +// word). It is rebuilt here rather than imported because the engine keeps it +// unexported; the seam is noted in the change entry. +const needsYourCallLead = tierYourCallWord + " on " + +// needsLandingsSpeakFor reports that the WHOLE of what a conversation is waiting +// on is one of its own landings, which the `to check` group is already drawing +// under the work's own name. // -// BOTH HALVES MATTER. A conversation with a question on its desk always writes -// the question object, so a missing one is the engine saying "nothing is being -// asked here"; and without a landing of its own on the screen there would be -// nothing left to say it, so the row stays. -func needsOnlyItsOwnLanding(in *homeGridInput, row switcherRow) bool { - if row.kind != switcherConversation || row.session.Presence.Question.Kind != "" { +// IT IS THE ENGINE'S SENTENCE AND NOT AN ABSENCE. The first cut of this test was +// "the presence carries no question object", and that is wrong in two lanes that +// bank no card at the desk and say so: a sub-harness offer (`wants to run …`) +// and an adaptive run at its fuel gate (`out of fuel · …`) both write a reason +// with no question, and a conversation stopped at either of those would have +// VANISHED from home the moment it happened to own a landing +// (internal/session's taskpresence.go says which lanes bank nothing). So the +// test is the sentence the pending-decision arm writes, opened by +// [needsYourCallLead] and closed by the title of one of this conversation's own +// landings — a question of any other kind keeps its row. +func needsLandingsSpeakFor(row session.SessionRow, titles []string) bool { + if len(titles) == 0 || row.Presence.Question.Kind != "" { return false } - for _, call := range in.calls { - if call.line.task != nil && call.line.task.SessionID == row.session.ID { + reason := strings.TrimSpace(row.Reason()) + if !strings.HasPrefix(reason, needsYourCallLead) { + return false + } + for _, title := range titles { + if title != "" && strings.HasSuffix(reason, title) { return true } } return false } +// needsCallTitlesOn is the titles the `to check` group is drawing for one +// conversation, read out of the reading the beat already took. +func needsCallTitlesOn(in *homeGridInput, id string) []string { + var titles []string + for _, call := range in.calls { + if call.line.task != nil && call.line.task.SessionID == id { + titles = append(titles, strings.TrimSpace(call.line.task.Title)) + } + } + return titles +} + +// needsWants is how many rows of `needs you` ONE conversation is: one per +// landing of its own that the `to check` group draws, and its own question where +// the panel keeps that row. +// +// THE PANEL BUILDS ITS ROWS FROM THESE READINGS AND THE PULSE COUNTS THEM FROM +// THIS ONE, so `N want you` over a home and the rows under it are the same +// arithmetic ([machineCounts]). +func needsWants(row session.SessionRow, now time.Time) int { + titles := needsCallTitles(row, now) + n := len(titles) + if row.NeedsPerson() && !needsLandingsSpeakFor(row, titles) { + n++ + } + return n +} + +// needsCallTitles is one conversation's landings as the `to check` group would +// draw them, by title, taken straight off the index row. +func needsCallTitles(row session.SessionRow, now time.Time) []string { + if row.Archived { + return nil + } + var titles []string + for i := range row.Tasks.Rows { + entry := row.Tasks.Rows[i] + if _, ok := needsCallOf(row, entry); !ok || needsAged(needsCallAt(entry), now) { + continue + } + titles = append(titles, strings.TrimSpace(entry.Title)) + } + return titles +} + // needsCalls is every task the project's record marks as the person's call — // work that finished and nobody could check, a landing that turned the work // back, a branch that would not fasten. They are read off the world's own index @@ -211,6 +280,12 @@ func needsCalls(world session.World, now time.Time) []needsItem { // ([needsAskedInChat]): that is a stopped conversation, it is a `needs you` row, // and it is the row that can take a digit. func needsCallOf(row session.SessionRow, entry session.TaskIndexEntry) (session.TaskStatus, bool) { + // WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM. Archiving + // is the decision to stop being asked about it, and every other panel already + // reads it that way ([machineCounts] skips an archived row outright). + if row.Archived { + return session.TaskStatus{}, false + } status := taskEntryStatus(entry, row.Runs(entry)) if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) { return session.TaskStatus{}, false @@ -309,11 +384,19 @@ const ( // needsCallAnswers is the two answers a landing offers, in the task's own words // on home's own two digits. // -// THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No, filled in by the -// engine for the row's own shape — `accept` / `not right` for work nobody could -// check, `resolve it` for a branch that would not fasten) and this file spells +// THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No) and this file spells // none of them. A row whose ask has no words offers nothing, which is the // absence law: there is no default pair to fall back on. +// +// AND EVERY ASK THAT REACHES HERE IS [session.TaskAskCheck]. The status comes +// from [session.TaskIndexEntry.StatusFacts], which fills the state, the ending +// and the kind and nothing else — the merge, the ground hold and the phase are +// the NODE's facts and the index does not carry them — so `taskAskOf` can only +// take its `check` arm for an index row. A conflict is a different question with +// different words, it is banked by the engine as `conflict:<id>` +// ([session.Agent.landingQuestion]), and it reaches a person through the task's +// own card and not through this panel. So [needsLandingQuestion] names one kind +// rather than guessing between two. func needsCallAnswers(status session.TaskStatus) string { yes, no := strings.TrimSpace(status.Ask.Yes), strings.TrimSpace(status.Ask.No) if yes == "" || no == "" { @@ -341,7 +424,7 @@ func needsChecking(in *homeGridInput) map[string]bool { said := make(map[string]bool, len(in.calls)) for _, item := range in.calls { if item.line.task != nil { - said[item.line.task.SessionID+"/"+item.line.task.ID] = true + said[taskLedgerKey(*item.line.task)] = true } } return said