diff --git a/cmd/codeaf/chatv3_host_teams_test.go b/cmd/codeaf/chatv3_host_teams_test.go index 611318ef3..0bed218f6 100644 --- a/cmd/codeaf/chatv3_host_teams_test.go +++ b/cmd/codeaf/chatv3_host_teams_test.go @@ -221,3 +221,21 @@ func TestTheWrapUpDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) { t.Fatal("an engine without the wrap-up doors was handed them, or lost the others") } } + +// Contract 6.2: The host wire never lends the laptop a closed team's packet files. +func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) { + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &quietAgent{}, ProfileDir: t.TempDir()}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + welcome := loop.Client.Welcome() + if !welcome.Teams || !welcome.Delegation || !welcome.WrapUp { + t.Fatalf("the engine welcome has no complete teams road: %+v", welcome) + } + if seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome); seam.History != nil { + t.Fatal("the host seam offered to read closed history from this laptop") + } +} diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 404a97b89..a952c83ed 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" + teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -361,6 +362,13 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C profileDir = settings.ProfileDir } options.ProfileDir = profileDir + if options.Teams.Load != nil { + // The linked-local engine keeps its profile on this machine, so its closed + // teams' packets can be read here without borrowing the --host wire. + options.Teams.History = func(team string) ([]teamstore.Packet, error) { + return teamstore.Packets(profileDir, team) + } + } options.EngineRoad = true options.ReadCredits = v3LocalCreditReader(settings) options.Connections = v3Connections(v3Connect(profileDir)) diff --git a/cmd/codeaf/chatv3_local_test.go b/cmd/codeaf/chatv3_local_test.go index b93d50ed2..6919a4181 100644 --- a/cmd/codeaf/chatv3_local_test.go +++ b/cmd/codeaf/chatv3_local_test.go @@ -29,9 +29,56 @@ import ( "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" ) +// Contract 6.1: A plain launch reads a closed team's report from the engine profile on this machine. +func TestPlainLaunchReadsClosedTeamReportFromEngineProfile(t *testing.T) { + engineProfile := t.TempDir() + surfaceProfile := t.TempDir() + t.Setenv("CODEAF_HOME", surfaceProfile) + t.Setenv("CODEAF_PROFILE_DIR", surfaceProfile) + t.Setenv("HOME", t.TempDir()) + t.Setenv(config.APIKeyEnv, "not-a-real-key") + t.Cleanup(func() { stopPoolErrands(surfaceProfile) }) + const harbor = "0a0a0a0a0a0a" + if err := teamstore.Save(engineProfile, []teamstore.Team{{ID: harbor, Name: "harbor", Manager: "hm", + Members: []teamstore.Member{{Key: "hm", Handle: "boss"}}}}); err != nil { + t.Fatal(err) + } + p, err := teamstore.Raise(engineProfile, teamstore.Packet{Team: teamstore.Person, Origin: harbor, + Kind: teamstore.PacketClosing, RaisedBy: teamstore.FromManager, Question: "close harbor?", + Options: []teamstore.Option{{ID: teamstore.OptionClose, Label: "Close", Consequence: "the team closes"}}, + Report: &teamstore.ClosingReport{Done: "the parser"}}) + if err != nil { + t.Fatal(err) + } + p, err = teamstore.Decide(engineProfile, p.ID, teamstore.Person, teamstore.OptionClose, "") + if err != nil { + t.Fatal(err) + } + if closed, err := teamstore.AcceptClosing(engineProfile, p); err != nil || !closed { + t.Fatalf("close on report: %v, %v", closed, err) + } + welcome := remote.Welcome{Version: remote.Version, Workspace: "/srv/app", ProfileDir: engineProfile, + Teams: true, Delegation: true, WrapUp: true} + fleet := onePipeFleet("", hostedClient(t)) + t.Cleanup(fleet.closeAll) + options, settings := hostOptions(fleet, welcome, false) + if options.Teams.Load == nil { + t.Fatal("the engine did not hand teams to the plain launch") + } + localDoors(&options, welcome, settings) + if options.Teams.History == nil { + t.Fatal("the plain launch has no history door") + } + got, err := options.Teams.History(harbor) + if err != nil || len(got) != 1 || got[0].ID != p.ID || got[0].Report == nil || got[0].Report.Done != "the parser" { + t.Fatalf("closed team's report from engine profile: %+v, %v", got, err) + } +} + // consentSource is an OpenAI-shaped fake whose turn always asks for the same // harmless bash command and then finishes after the tool result comes back. func consentSource(t *testing.T, command string) *httptest.Server { diff --git a/docs/changes/unreleased/1516-teams-surface.md b/docs/changes/unreleased/1516-teams-surface.md new file mode 100644 index 000000000..fe0d7d327 --- /dev/null +++ b/docs/changes/unreleased/1516-teams-surface.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: wall tiles say what each conversation spent, and the teams surface stops saying untrue things +pr: 1516 +surface: [chat, engine] +invalidates: + - "A wall tile drew no spend, although #1429 promised it. A tile's state line now ends in the figure that conversation's own status line shows (the work it started included), and draws nothing when it spent nothing." + - "A team write the store refused (a lock, an unreadable file, a far machine that kept changing) still drew `Made beta · 1`, `Organized · …` or `harbor is closed`. Those notices now wait for the write that carried their edit, and a refusal takes their place: `beta was not saved · `." + - "Wall tiles, and a reopened manager conversation, drew the wake sentence (`Your team's replies started this turn; the person did not speak. …`) that the live conversation never draws. Neither draws it now; a team delivery draws as its lines." + - "A team cap under a cent read `$0.00` and offered `Raise to $0`, whose stored ceiling lifted nothing, and the settings card said `$0.0010 a day`. Every place now spells a cap one way (`$5`, `$5.50`, `$0.001`), and Raise always offers twice the ceiling and names it exactly. The card spells a whole-dollar cap `$5`, where it said `$5.00`." + - "A team that was wrapping up showed no deadline anywhere. The teams page header and the manager's side column now say `wrapping up · 12m left`, then `under a minute left`, then `out of time`." + - "On a plain local launch, a closed team's pane said its report `is not readable over this connection`. The report now opens there; only over --host does that sentence remain." +--- diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index b6060ab31..b5452ade6 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -66,10 +66,15 @@ A tile reads top to bottom: - **the title** on the top border, the brightest thing in the tile, after the dots of the teams it is in (up to three, then `+N`) - **one dim line** saying what the conversation is doing now, like `running bash · 2m`, - `writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago` + `writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago`, and at + its right end **what the conversation has spent**, like `$0.42`: the same figure its own + status line shows, the work it started included. A conversation that has spent nothing + shows no figure, and on a narrow tile the figure gives way before the words do - **the conversation's own newest lines**, drawn the way the conversation draws them, fading with age so the newest are where your eye lands; lines that just arrived are lifted for a - moment and then settle + moment and then settle. A turn a team started shows what the team sent (`◆ manager → + @api do` and its words) and the reply, never the note codeaf wrote to start the turn, the + same as the conversation itself - a small **activity line** on the bottom border while there is activity to show A conversation this window can only show as a snapshot (over a shared connection only the one @@ -336,6 +341,16 @@ names, members and colours, and it is renamed to `spaces.json.migrated`. A teams cannot be read is moved aside as `teams.json.unreadable-` rather than written over, so nothing you made is lost. +## The wall said my team was not saved + +A change to your teams shows in this window at once and is saved a moment later. What says +it happened waits for the save: `Made harbor · 2` on the Teams row, `Organized · 1 new team`, +and on the teams page `harbor is closed` or a move's words. When the save is refused (another +codeaf holding the file, a file that would not read, a far machine whose teams kept changing), +those words never show. The row says `harbor was not saved · ` instead, and the +teams page says `the close of harbor was not saved` or `the move was not saved`. The change is +still in this window, and Undo is still offered where it was. + ## Every key in the conversations view `?` (or `Help ?`) opens a sheet of all of these, and every row on it is a button that does diff --git a/internal/manual/chat/team-questions-and-caps.md b/internal/manual/chat/team-questions-and-caps.md index 648882df6..49344d01c 100644 --- a/internal/manual/chat/team-questions-and-caps.md +++ b/internal/manual/chat/team-questions-and-caps.md @@ -73,6 +73,13 @@ When the pool reaches its cap: A manager can never raise a cap: money is yours. Every held wake is one line in the traffic, `held @web: harbor reached its $5 cap today`. +## Can a team cap be less than a cent + +Yes. A cap is spelled as you set it everywhere it appears, on the card, the teams page and the +team's settings: `$5`, `$5.50`, and under a cent `$0.001`, never rounded to `$0.00`. **Raise +to** always offers twice the ceiling the team reached, and names exactly that figure: a +`$0.001` cap offers `Raise to $0.002`. + ## What if today's team spending cannot be read When a capped team's usage ledger or team list cannot be read, no new paid team work starts. @@ -103,6 +110,15 @@ The wrap-up has 15 minutes and $2 of team spend. When it runs out of either befo manager reports, codeaf brings you the report itself, marked `wrap-up incomplete`, with **Close now** and **Keep going**. +## How long does my team have left to wrap up + +While a team is wrapping up, the teams page's header for it and the Traffic column beside its +manager say how long it has: `wrapping up · 12m left`, then `wrapping up · under a minute +left`, and `wrapping up · out of time` once the 15 minutes are gone and the report has not +come yet. The words go when the report arrives. The time is counted from when the wrap-up +began, kept with the team, so it reads the same after a restart. The team's chip on the tab +strip does not show it. + ## What if the wrap-up report could not be sent, the decisions file was busy A wrap-up that runs out of its 15 minutes or its $2 before the manager reports is diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 17772ddb5..abc9d45a0 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -315,11 +315,11 @@ waits under `▸ Closed · N`. Over `--host`, against an engine that does not offer the wrap-up, the card says `Wrap up first is not offered over this connection` and offers `Close now` and `Cancel`. -## Closed teams: reopening and deleting +## Closed teams: reopening, reports and deleting -Open `▸ Closed · N` on the rail and choose a team. The pane shows when it was opened and -closed, its closing report when it closed on one (what was done, what was left, where the files -are, what it spent), and its members, each still a door to its conversation. Two buttons: +Open `▸ Closed · N` on the rail and choose a team. On this machine, the pane shows when it was +opened and closed, its `closing report` when it closed on one (`done`, `left`, `files`, and +`spent`), and its members, each still a door to its conversation. Two buttons: - **`Reopen`** (`r`) opens the team again: its members' tabs come back and its manager is brought in front. A team whose parent is closed too offers **`Reopen harbor too`**, because a @@ -341,13 +341,16 @@ The page says what a team is in one sentence and offers two buttons: **`✦ Orga conversations`**, which suggests teams from the conversations you have open, and **`+ New team`**. `o` and `n` press them. -## Over --host +## Over --host: why a closed team's report is not readable Over `--host` the page shows the teams of the machine the conversations run on: their decisions, their spend and their managers. The **Teams** tab of `/settings` edits that machine's defaults, and each value says `from Settings`. An older engine keeps the tab read only and says `changing them is not available over this connection`. A closed team's -report is not read over the connection yet, and the page says so where the report would be. +report is not read over the connection yet, and the page says +`its closing report is kept where the team ran, and is not readable over this connection` +where the report would be. A plain local launch reads the report from this machine's engine +profile and shows it in the closed team's pane. ## Why the page looks the way it does diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 787031c2c..3080224a0 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -49,6 +49,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"will closing a sub-team stop its manager if that manager is also in the parent team", "teams-page"}, {"the pane said the manager was open in another window", "teams-page"}, {"how do I reopen a closed team", "teams-page"}, + {"why does my closed team say its report is not readable", "teams-page"}, + {"can a team cap be less than a cent", "team-questions-and-caps"}, + {"the wall said my team was not saved", "conversations-and-teams"}, + {"how long does my team have left to wrap up", "team-questions-and-caps"}, + {"does the conversations view show what each conversation spent", "conversations-and-teams"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/session/team_cap.go b/internal/session/team_cap.go index 5ff4f033d..5d75407c6 100644 --- a/internal/session/team_cap.go +++ b/internal/session/team_cap.go @@ -37,7 +37,6 @@ package session import ( "fmt" - "math" "sync" "github.com/Agent-Field/codeaf/internal/teams" @@ -154,7 +153,7 @@ func (a *Agent) poolHold(profile string, owner teams.Team, cap float64) string { return "" } held := fmt.Sprintf("%s reached its %s cap today (spent %s); the person has been asked whether to raise it, and nothing new starts until they answer", - owner.Name, teamMoney(ceiling), teamMoney(spent)) + owner.Name, teamMoney(ceiling), teamSpendMoney(spent)) if found && (latest.Waiting() || latest.Cap.CapUSD >= ceiling) { if !latest.Waiting() { held = fmt.Sprintf("%s reached its %s cap today and the person chose to stop it for today", owner.Name, teamMoney(ceiling)) @@ -187,7 +186,7 @@ func latestCapPacket(profile, owner, day string) (teams.Packet, bool) { // capPacket is the packet a pool at its ceiling raises to the person. func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packet { - raiseTo := math.Round(ceiling*2*100) / 100 + raiseTo := teams.RaiseTo(ceiling) return teams.Packet{ Team: teams.Person, Origin: owner.ID, Kind: teams.PacketCap, RaisedBy: teams.FromSystem, Question: fmt.Sprintf("%s reached its %s cap today", owner.Name, teamMoney(ceiling)), @@ -199,14 +198,15 @@ func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packe }, Recommendation: &teams.Recommendation{Option: teams.OptionStopToday, Reason: "the cap is the limit you set; raise it only if today's work is worth more to you"}, - Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: math.Round(spent*100) / 100, RaiseTo: raiseTo}, + Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: teams.RoundMoney(spent), RaiseTo: raiseTo}, } } -// teamMoney is dollars as the person reads them: $5, $5.50. -func teamMoney(usd float64) string { - if usd == math.Trunc(usd) { - return fmt.Sprintf("$%.0f", usd) - } - return fmt.Sprintf("$%.2f", usd) -} +// teamMoney is dollars as the person reads them: $5, $5.50, $0.001. It is +// teams' one spelling of a cap, so the refusal here, the packet and every +// screen that draws the same cap say the same figure ([teams.Money]). +func teamMoney(usd float64) string { return teams.Money(usd) } + +// teamSpendMoney is a MEASURED spend as the person reads it: kept to the cent, +// or finer under a cent ([teams.RoundMoney]), then spelled as a cap is. +func teamSpendMoney(usd float64) string { return teams.Money(teams.RoundMoney(usd)) } diff --git a/internal/session/team_cap_precision_test.go b/internal/session/team_cap_precision_test.go new file mode 100644 index 000000000..6ed30cb6b --- /dev/null +++ b/internal/session/team_cap_precision_test.go @@ -0,0 +1,75 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// Contract 4.1 and 4.2: Every raise names a larger cap and keeps positive sub-cent spend. +func TestTeamCapPacketKeepsPreciseFigures(t *testing.T) { + for _, tc := range []struct { + cap, raise float64 + capWord, raiseWord string + }{ + {0.00001, 0.00002, "$0.00001", "$0.00002"}, + {0.0004, 0.0008, "$0.0004", "$0.0008"}, + {0.001, 0.002, "$0.001", "$0.002"}, + {0.004, 0.008, "$0.004", "$0.008"}, + {0.005, 0.01, "$0.005", "$0.01"}, + {0.009, 0.018, "$0.009", "$0.018"}, + {0.01, 0.02, "$0.01", "$0.02"}, + {0.015, 0.03, "$0.015", "$0.03"}, + {1, 2, "$1", "$2"}, + {5, 10, "$5", "$10"}, + {7.5, 15, "$7.50", "$15"}, + {1234.5, 2469, "$1,234.50", "$2,469"}, + } { + p := capPacket(teams.Team{ID: "aaaaaaaaaaaa", Name: "harbor"}, "2026-09-25", tc.cap, tc.cap) + if p.Cap == nil || p.Cap.RaiseTo <= tc.cap || p.Cap.RaiseTo != tc.raise || p.Cap.SpentUSD <= 0 { + t.Errorf("cap %.8f: facts %+v", tc.cap, p.Cap) + continue + } + if want := "harbor reached its " + tc.capWord + " cap today"; p.Question != want { + t.Errorf("cap %.8f: question %q, want %q", tc.cap, p.Question, want) + } + if want := "Raise to " + tc.raiseWord; p.Options[0].Label != want { + t.Errorf("cap %.8f: label %q, want %q", tc.cap, p.Options[0].Label, want) + } + if want := "harbor and its sub-teams go on until " + tc.raiseWord + " today"; p.Options[0].Consequence != want { + t.Errorf("cap %.8f: consequence %q, want %q", tc.cap, p.Options[0].Consequence, want) + } + } +} + +// Contract 4.2 and 4.3: A decided sub-cent raise admits new work until its new ceiling. +func TestTeamSubCentRaiseAdmitsWorkUntilNewCeiling(t *testing.T) { + fixture := newTeamFixture(t, true) + cap := 0.001 + if err := teams.Update(fixture.profile, func(f *teams.File) error { + return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.CapUSDDay = &cap }) + }); err != nil { + t.Fatal(err) + } + spend := &capSpend{usd: cap, stamp: "one"} + stubCapSpend(t, spend) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + roles := manager.teamRoles() + if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.001 cap") || !strings.Contains(held, "spent $0.001") { + t.Fatalf("at the first ceiling: %q", held) + } + p := onlyPacket(t, fixture.profile, teams.Person) + if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, teams.OptionRaiseCap, ""); err != nil { + t.Fatal(err) + } + spend.usd, spend.stamp = 0.0015, "two" + if held := manager.teamCapHold(fixture.profile, roles); held != "" { + t.Fatalf("below the raised ceiling: %q", held) + } + spend.usd, spend.stamp = 0.002, "three" + if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.002 cap") { + t.Fatalf("at the raised ceiling: %q", held) + } +} diff --git a/internal/session/team_wakewatch.go b/internal/session/team_wakewatch.go index e3158f1f8..5d909190d 100644 --- a/internal/session/team_wakewatch.go +++ b/internal/session/team_wakewatch.go @@ -458,7 +458,7 @@ func (a *Agent) teamWakeMember(profile string, roles []teamRole, now time.Time) // handed the directive over already; there is nothing left to wake on. return } - text := "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\n" + news + text := teamWakeMemberLead + "\n\n" + news woke, reason := a.teamWakeWith(text) if woke { a.teamWakeCount(now) @@ -524,7 +524,7 @@ func (a *Agent) teamWakeManager(profile string, roles []teamRole, batch map[stri if len(groups) == 0 { return } - text := "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands.\n\n" + + text := teamWakeManagerLead + "\n\n" + strings.Join(groups, "\n\n") woke, reason := a.teamWakeWith(text) if woke { diff --git a/internal/session/team_wrapup.go b/internal/session/team_wrapup.go index 02b3aad6f..4322fcc7b 100644 --- a/internal/session/team_wrapup.go +++ b/internal/session/team_wrapup.go @@ -230,7 +230,7 @@ func (a *Agent) teamWrapUpDue(profile string, now time.Time) { case now.Sub(w.started) >= wrapBound(w): why = fmt.Sprintf("the wrap-up ran out of time (%s) before the manager brought its report", wrapBound(w).Round(time.Minute)) case spendErr == nil && w.measured && spent-w.spentAt >= wrapUpSpendUSD: - why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamMoney(spent-w.spentAt)) + why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamSpendMoney(spent-w.spentAt)) default: continue } diff --git a/internal/session/teamwake_note.go b/internal/session/teamwake_note.go new file mode 100644 index 000000000..050ee1617 --- /dev/null +++ b/internal/session/teamwake_note.go @@ -0,0 +1,37 @@ +package session + +import "strings" + +// ── THE SENTENCE A TEAM WAKE OPENS WITH, WRITTEN ONCE ─────────────────────── +// +// A turn woken by team traffic starts on a note the session writes +// (team_wakewatch.go): a sentence telling the model that nobody typed this, +// then what arrived. The sentence is for the model. The live conversation +// never draws it (tui3's followup.go), and a surface that draws a transcript +// asks [TeamWakeNote] to leave it out the same way, so the wall's tiles and a +// reopened conversation show what the live one shows. The composers use these +// constants, so the reader can never drift from what was written. +const ( + // teamWakeMemberLead opens the note that wakes a member on its manager's + // directive or answer. + teamWakeMemberLead = "Your manager started this turn (a directive, or the answer to your question); the person did not speak." + // teamWakeManagerLead opens the note that wakes a manager on its team's + // replies. + teamWakeManagerLead = "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands." + // teamWakeEarlierLead is how the member's note opened in builds of the + // team delegation work before it landed, and journals written by them + // still hold it. + teamWakeEarlierLead = "Your manager's directive started this turn;" +) + +// TeamWakeNote reports whether text is a note the session wrote to wake a +// turn on team traffic. +func TeamWakeNote(text string) bool { + text = strings.TrimSpace(text) + for _, lead := range []string{teamWakeMemberLead, teamWakeManagerLead, teamWakeEarlierLead} { + if strings.HasPrefix(text, lead) { + return true + } + } + return false +} diff --git a/internal/session/teamwake_note_test.go b/internal/session/teamwake_note_test.go new file mode 100644 index 000000000..71d60baec --- /dev/null +++ b/internal/session/teamwake_note_test.go @@ -0,0 +1,27 @@ +package session + +import "testing" + +// Contract 3.3: one classifier knows a team wake note by the very sentences the +// session composes it from, and nothing else. +func TestTeamWakeNoteKnowsTheWakeSentencesAndNothingElse(t *testing.T) { + for _, text := range []string{ + teamWakeMemberLead + "\n\nTeam traffic in \"harbor\" (@api), from your manager:\ndirective from manager: status?", + teamWakeManagerLead + "\n\nWhat your members in \"harbor\" did:\n@api finished", + " " + teamWakeManagerLead, + } { + if !TeamWakeNote(text) { + t.Errorf("a wake note was not known: %q", text) + } + } + for _, text := range []string{ + "", + "Task 3 finished: the parser is fixed.", + "Team traffic in \"harbor\" (@api), from your manager:\ndirective from manager: status?", + "Your team is great; the person did not speak.", + } { + if TeamWakeNote(text) { + t.Errorf("a note that is not a wake was taken for one: %q", text) + } + } +} diff --git a/internal/teams/money.go b/internal/teams/money.go new file mode 100644 index 000000000..41bf54219 --- /dev/null +++ b/internal/teams/money.go @@ -0,0 +1,105 @@ +package teams + +import ( + "math" + "strconv" + "strings" +) + +// ── A CAP'S MONEY, SPELLED ONE WAY EVERYWHERE ─────────────────────────────── +// +// A team's cap is a figure a person chose, and it is said in four places: the +// session's refusal and cap packet (`harbor reached its $5 cap today`, `Raise +// to $10`), the teams page's header, the packet's card, and the team's settings +// card. They used to spell it three ways, and below a cent two of them lied: +// `$0.001` read `$0.00` in the packet and `Raise to $0` offered a ceiling that +// lifted nothing. So the spelling and the rounding live here, once, and every +// one of those places calls them. + +// moneyMicro is the finest step a figure under a cent keeps: a millionth of a +// dollar, which holds every cap a person can sensibly type and drops the noise +// a float sum leaves in the last digits. +const moneyMicro = 1e6 + +// RoundMoney is a MEASURED figure (a pool's spend, a share of a cap) rounded +// the way it is kept and said: to the cent from a cent up, and to a millionth +// of a dollar below it, so a positive figure under a cent is never rounded away +// to nothing. +func RoundMoney(usd float64) float64 { + if usd >= 0.01 || usd <= 0 { + return math.Round(usd*100) / 100 + } + if r := microRound(usd); r > 0 { + return r + } + return usd +} + +// microRound is usd to the millionth of a dollar, which drops the noise a +// float sum leaves in its last digits and keeps every figure a person types. +func microRound(usd float64) float64 { return math.Round(usd*moneyMicro) / moneyMicro } + +// Money is a figure as a person reads it, and it is the figure itself: `$5` +// for whole dollars, `$5.50` for whole cents, `$1,234.50` in the thousands, +// and otherwise the shortest spelling that is still the number, `$0.001`, +// `$0.015`. A positive figure is never spelled `$0.00`. A measured spend goes +// through [RoundMoney] first; a cap is spelled as it was chosen. +func Money(usd float64) string { + r := microRound(usd) + if r == 0 && usd > 0 { + r = usd + } + cents := math.Round(r * 100) + var plain string + switch { + case math.Abs(cents-r*100) > 1e-6: + plain = strconv.FormatFloat(r, 'f', -1, 64) + case math.Mod(cents, 100) == 0: + plain = strconv.FormatFloat(cents/100, 'f', 0, 64) + default: + plain = strconv.FormatFloat(cents/100, 'f', 2, 64) + } + whole, frac, _ := strings.Cut(plain, ".") + if frac != "" { + frac = "." + frac + } + return "$" + groupThousands(whole) + frac +} + +// RaiseTo is the ceiling a cap packet offers for a pool at ceiling: twice it, +// to the millionth, and ALWAYS larger than the ceiling it raises, because a +// raise to the same figure (a sub-cent cap rounded to the cent was `Raise to +// $0`) is a button that lifts nothing. +func RaiseTo(ceiling float64) float64 { + if r := microRound(ceiling * 2); r > ceiling { + return r + } + return ceiling * 2 +} + +// groupThousands puts a comma between each group of three digits. +func groupThousands(digits string) string { + neg := strings.HasPrefix(digits, "-") + digits = strings.TrimPrefix(digits, "-") + if len(digits) <= 3 { + if neg { + return "-" + digits + } + return digits + } + var b strings.Builder + if neg { + b.WriteByte('-') + } + head := len(digits) % 3 + if head > 0 { + b.WriteString(digits[:head]) + } + for i := head; i < len(digits); i += 3 { + if b.Len() > 0 && !(neg && b.Len() == 1) { + b.WriteByte(',') + } + b.WriteString(digits[i : i+3]) + } + return b.String() +} diff --git a/internal/teams/money_test.go b/internal/teams/money_test.go new file mode 100644 index 000000000..c67a26f1f --- /dev/null +++ b/internal/teams/money_test.go @@ -0,0 +1,23 @@ +package teams + +import "testing" + +// Contract 4.2 and 4.3: A sub-team share keeps a positive sub-cent cap. +func TestSubTeamShareKeepsSubCentCap(t *testing.T) { + f := tree() + for _, tc := range []struct{ cap, share, want float64 }{ + {0.001, 0.5, 0.0005}, + {0.009, 0.5, 0.0045}, + {5, 0.5, 2.5}, + } { + if err := f.SetSettings("aaaaaaaaaaaa", func(s *Settings) { + s.CapUSDDay = &tc.cap + s.SubShare = &tc.share + }); err != nil { + t.Fatal(err) + } + if got := f.SubTeamCap("aaaaaaaaaaaa", defaults); got != tc.want { + t.Errorf("cap %v share %v: sub-team got %v, want %v", tc.cap, tc.share, got, tc.want) + } + } +} diff --git a/internal/teams/teamsettings.go b/internal/teams/teamsettings.go index 1ce77917d..3457f867e 100644 --- a/internal/teams/teamsettings.go +++ b/internal/teams/teamsettings.go @@ -283,7 +283,8 @@ func (f *File) CanNest(parent string, d Defaults) bool { } // SubTeamCap is the cap a new sub-team under parent is made with: the -// parent's effective cap times its effective share, rounded to the cent. A +// parent's effective cap times its effective share, rounded by [RoundMoney] +// (to the cent, or finer under a cent, so a sub-cent share is not zero). A // parent with no cap gives none (0), and the sub-team then shares whatever // pool is above it. The caller writes the answer on the new team // ([File.SetSettings]), so a later change to the share moves no team that @@ -293,5 +294,5 @@ func (f *File) SubTeamCap(parent string, d Defaults) float64 { if e.CapUSDDay <= 0 { return 0 } - return math.Round(e.CapUSDDay*e.SubShare*100) / 100 + return RoundMoney(e.CapUSDDay * e.SubShare) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a876de230..6b488fb97 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3822,6 +3822,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.wallTakeRead(msg) return a, nil + case wallTreeMsg: + a.wallTakeTree(msg) + return a, nil + case wallTickMsg: return a, a.wallTick() diff --git a/internal/tui3/replay.go b/internal/tui3/replay.go index c2c49c50f..d11383a02 100644 --- a/internal/tui3/replay.go +++ b/internal/tui3/replay.go @@ -773,10 +773,16 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ continue } // A LINE THE TEAM SENT IS A CARD, headed by who said it to whom - // (teamcard.go), and never the person's `›`. - if len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead) { + // (teamcard.go), and never the person's `›`. A TEAM WAKE WITH + // NOTHING DELIVERED IN IT IS NOT DRAWN: it is the sentence that told + // the model nobody typed this turn, which the live conversation + // never draws either (followup.go). + switch asideShapeOf(e) { + case asideTeam: blocks = append(blocks, entry{kind: entryTeam, text: text, team: e.Team, turn: turn}) continue + case asideHidden: + continue } // A LINE THE SESSION WROTE GOES IN THE SESSION'S OWN LANE — the dim // "· " row this surface says everything of its own in ([feed.note]) — diff --git a/internal/tui3/sidetraffic.go b/internal/tui3/sidetraffic.go index 99211b92b..4e6c6f47d 100644 --- a/internal/tui3/sidetraffic.go +++ b/internal/tui3/sidetraffic.go @@ -45,6 +45,8 @@ type sideTrafficCacheKey struct { kind, rows, width, height, hotDoor, moved int ascii, held bool minute int64 + // wrap is the team's wrap-up words (teamwrapclock.go). + wrap string } // sideTrafficCache is the Traffic view's rows as last laid. @@ -94,12 +96,18 @@ func (a *app) sideTrafficView(height int) ([]railLine, int) { team: t.ID, handle: handle, last: last, divider: a.side.divider[t.ID], hot: hot, focus: focus, front: a.frontTabKey(), kind: kind, rows: len(rows), width: width, height: height, hotDoor: hotDoor, moved: a.side.moved, ascii: a.pal.ascii, held: a.railHold, minute: a.now().Unix() / 60, + wrap: a.teamWrapWords(t, a.now()), } a.trafficMarkSeen(t) if c := &a.side.traffic; c.lines != nil && c.key == key { return c.lines, -1 } s := &sideSheet{a: a, t: t, width: width, hot: hot, hotDoor: hotDoor, divider: key.divider} + // A TEAM THAT IS WRAPPING UP SAYS HOW LONG IT HAS, first, above its + // Traffic (teamwrapclock.go). + if key.wrap != "" { + s.lines = append(s.lines, railLine{text: a.pal.warn(ansi.Truncate(key.wrap, width, a.linearMark("…", "~"))), entry: -1}) + } if kind == sideKindManager { s.threads(kind, handle) } else { diff --git a/internal/tui3/teamcard.go b/internal/tui3/teamcard.go index ae0d27473..ca789d53d 100644 --- a/internal/tui3/teamcard.go +++ b/internal/tui3/teamcard.go @@ -211,3 +211,42 @@ func (a *app) teamCardRows(e entry, width int) []string { } return out } + +// asideShape is how a surface draws one session aside, decided once for the +// reopened conversation (replay.go) and the wall's tiles (wallmini.go, +// walltail.go), so a tile shows what the conversation shows. +type asideShape uint8 + +const ( + // asideLine is every other aside: its first line, in the session's lane. + asideLine asideShape = iota + // asideTeam is a team delivery: the lines as cards, never the sentence + // the session put above them for the model. + asideTeam + // asideHidden is a team wake with nothing delivered in it: the note that + // started a turn nobody typed, which the live conversation never draws + // (followup.go), so no surface draws it. + asideHidden +) + +// asideShapeOf is [asideShape] for one aside's entry. +func asideShapeOf(e session.DisplayEntry) asideShape { + text := strings.TrimSpace(e.Text) + switch { + case len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead): + return asideTeam + case session.TeamWakeNote(text): + return asideHidden + } + return asideLine +} + +// teamCardsOf is a team delivery's lines as cards: the session's own parse +// when the entry carries it, the note's text read back otherwise. +func teamCardsOf(e session.DisplayEntry, mark string) []teamCard { + cards, ok := teamLineCards(e.Team, e.Text, mark) + if !ok { + cards, _ = teamAsideCards(e.Text, mark) + } + return cards +} diff --git a/internal/tui3/teamclose.go b/internal/tui3/teamclose.go index 30761f596..38046102b 100644 --- a/internal/tui3/teamclose.go +++ b/internal/tui3/teamclose.go @@ -48,6 +48,9 @@ type teamsUndo struct { name string shut []string at time.Time + // said ties `harbor is closed` to the write that carried the close + // (teamwritesaid.go); a close the report's own door made is said already. + said teamWriteSaid } // teamsCloseKeys is every conversation a close of team id stops and whose tab @@ -192,7 +195,9 @@ func (a *app) teamsCloseNow(id, report string) tea.Cmd { a.touch() return nil } - return a.teamsAfterClose(t, shut, now, true) + cmd := a.teamsAfterClose(t, shut, now, true) + a.tp.undo.said = a.teamWriteWatch(nil) + return cmd } // teamsStopMembers is the interface's half of every close (DESIGN.md 8.5): @@ -268,7 +273,7 @@ func (a *app) teamsAfterClose(t team, shut []string, now time.Time, tell bool) t // teamsUndoing reports whether Undo is still offered for the last close. func (a *app) teamsUndoing() bool { u := a.tp.undo - return u.team != "" && a.now().Sub(u.at) < teamsUndoFor + return u.team != "" && u.said.said() && a.now().Sub(u.at) < teamsUndoFor } // teamsUndoClose takes the last close back: the team reopened, and the tabs it diff --git a/internal/tui3/teamcrew.go b/internal/tui3/teamcrew.go index ec485f80f..4269c4f29 100644 --- a/internal/tui3/teamcrew.go +++ b/internal/tui3/teamcrew.go @@ -236,6 +236,7 @@ func (a *app) teamsHeader(d *teamsDraw, t team, width, y int) string { dropSpend = 900 dropChip = 800 // minus the chip's place, so the last chip goes first dropBoss = 100 + dropWrap = 200 ) if !t.Closed() { crew := a.teamsCrew(t) @@ -294,6 +295,11 @@ func (a *app) teamsHeader(d *teamsDraw, t team, width, y int) string { if spend != "" { pieces = append(pieces, teamsHeadPiece{s: pal.dim(spend), w: ansi.StringWidth(spend), drop: dropSpend}) } + // A WRAP-UP'S TIME LEFT outlasts the spend and the members on a narrow + // line: it is the one fact here with a deadline behind it (teamwrapclock.go). + if wrap := a.teamWrapWords(t, a.now()); wrap != "" { + pieces = append(pieces, teamsHeadPiece{s: pal.warn(wrap), w: ansi.StringWidth(wrap), drop: dropWrap}) + } // WHAT FITS. The name and the buttons are the floor; the pieces are added // back from the one a narrow line keeps longest. bw := 0 diff --git a/internal/tui3/teammove.go b/internal/tui3/teammove.go index 22f967322..acdac55a1 100644 --- a/internal/tui3/teammove.go +++ b/internal/tui3/teammove.go @@ -100,6 +100,10 @@ type teamMoveUndo struct { word string from int at time.Time + // said ties the move's words to the write that carried it + // (teamwritesaid.go): Undo and the words wait for it, and a refusal + // replaces the words. + said teamWriteSaid } // teamMoveRow is one row of the picker: a parent (teamMoveTop for the top @@ -635,7 +639,7 @@ func (a *app) teamMoveApply(ids []string, parent string, from int) tea.Cmd { return nil } a.tmove.pend = teamMovePend{} - a.tmove.undo = teamMoveUndo{back: back, homes: homes, word: word, from: from, at: a.now()} + a.tmove.undo = teamMoveUndo{back: back, homes: homes, word: word, from: from, at: a.now(), said: a.teamWriteWatch(nil)} a.tp.msg = "" a.tp.top = teamsTopCache{} if a.tp.cur.act == teamsActMoveYes || a.tp.cur.act == teamsActMoveNo { @@ -690,7 +694,7 @@ func (a *app) teamMoveConfirm() tea.Cmd { // teamMoveUndoing reports whether Undo is offered for the last move. func (a *app) teamMoveUndoing() bool { u := a.tmove.undo - return len(u.back) > 0 && a.now().Sub(u.at) < teamsUndoFor + return len(u.back) > 0 && u.said.said() && a.now().Sub(u.at) < teamsUndoFor } // teamMoveUndo puts the last move back: every moved team under its parent @@ -778,7 +782,11 @@ func (a *app) teamsMoveRows(d *teamsDraw, width, y int) []string { return out } if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromPage { - word := " " + pal.dim(a.tmove.undo.word) + " " + ink := pal.dim + if a.tmove.undo.said.why != "" { + ink = pal.warn + } + word := " " + ink(a.tmove.undo.word) + " " s, _ := d.button("Undo", teamsTarget{act: teamsActUndo, x0: ansi.StringWidth(word), y: y, hint: "Put it back where it was" + hintSegment + "u"}, pal.ink) return []string{word + s} @@ -807,11 +815,8 @@ func (a *app) teamsCanNest(parent string) (bool, string) { } // teamsMoney is a cap as a sentence says it: `$3` for whole dollars, `$2.50` -// otherwise. A cap is a round figure a person chose, and `$3.00/day` reads as -// a bill. -func teamsMoney(usd float64) string { - if usd >= 1 && usd == float64(int64(usd)) { - return "$" + itoa(int(usd)) - } - return dollars(usd) -} +// otherwise, `$0.001` under a cent. A cap is a round figure a person chose, and +// `$3.00/day` reads as a bill. It is teams' own spelling ([teamstore.Money]), +// the one the session's cap packet and refusal use, so the screen and the +// packet never disagree about the same figure. +func teamsMoney(usd float64) string { return teamstore.Money(usd) } diff --git a/internal/tui3/teamorganize.go b/internal/tui3/teamorganize.go index 5d002a861..4eeef2bd8 100644 --- a/internal/tui3/teamorganize.go +++ b/internal/tui3/teamorganize.go @@ -85,6 +85,10 @@ type wallOrganize struct { // and added what the Teams row says while Undo is offered. undo []team doneAt time.Time + // said ties `Organized` to the write that carried the Apply + // (teamwritesaid.go): nothing is said until it is back, and a refusal is + // said in its place. + said teamWriteSaid // undoMade and undoJoins are what the last Apply did, the teams it made by // id and the members it added, so Undo can take exactly those back. undoMade []string @@ -718,6 +722,7 @@ func (a *app) wallOrganizeApply() { if err != nil { a.note("the teams are kept for this window, but " + err.Error()) } + o.said = a.teamWriteWatch(err) for _, id := range closes { if a.wall.activeID == id { a.wall.activeID = "" @@ -890,7 +895,18 @@ func wallOrgMarks(pal palette) (spark, check string) { // has changed. It is drawn nowhere else. func wallOrganizeButton(pal palette, g wallGlyphs, v wallView, y int) wallOrgPiece { o := v.org - if !o.doneAt.IsZero() && v.now.Sub(o.doneAt) < wallOrganizedFor && v.now.Sub(o.doneAt) >= 0 { + if o.said.said() && o.said.why != "" && teamSaidWithin(o.doneAt, v.now, wallOrganizedFor) { + // A REFUSED APPLY SAYS SO where `Organized` would have been, and keeps + // its Undo: the teams it made are still in this window, and taking them + // back is what a person who reads this may want. + word := teamNotSaved("", o.said.why) + " " + undo := wallButton{act: wallActOrgUndo, label: "Undo"} + hot := v.hover == wallHitRef{kind: wallHitAction, arg: int(wallActOrgUndo)} + ww, bw := ansi.StringWidth(word), wallButtonW(undo) + return wallOrgPiece{s: pal.warn(word) + wallButtonPaint(pal, undo, hot), w: ww + bw, + hits: []wallHit{{x0: ww, y0: y, x1: ww + bw, y1: y + 1, kind: wallHitAction, arg: int(wallActOrgUndo)}}} + } + if o.said.said() && teamSaidWithin(o.doneAt, v.now, wallOrganizedFor) { var said []string if o.made > 0 { said = append(said, strconv.Itoa(o.made)+" new "+wallPlural(o.made, "team")) diff --git a/internal/tui3/teamorganize_test.go b/internal/tui3/teamorganize_test.go index 29eddb4c6..fff36a58b 100644 --- a/internal/tui3/teamorganize_test.go +++ b/internal/tui3/teamorganize_test.go @@ -216,6 +216,8 @@ func TestOrganizeApplyThenUndoRestoresTheExactList(t *testing.T) { if hue := a.wall.teams[1].HueSpec(); hue == a.wall.teams[0].HueSpec() { t.Fatal("the new team took an existing team's colour") } + // `Organized` is said once the store took the Apply (teamwritesaid.go). + teamsFlush(t, a) frame := orgFrame(a) if !strings.Contains(frame, "Organized · 1 new team ") || !strings.Contains(frame, " Undo ") { t.Fatalf("the Teams row does not offer Undo:\n%s", frame) @@ -225,7 +227,18 @@ func TestOrganizeApplyThenUndoRestoresTheExactList(t *testing.T) { t.Fatalf("the Undo target lies on %q", got) } _, _ = a.wallPress(undo.x0+1, undo.y0) - if !reflect.DeepEqual(a.wall.teams, prior) { + // The same teams, with the times compared as instants: once the Apply's + // write is back the window holds times read from the file, whose location + // is UTC's and not the clock's (teamwritesaid.go makes `Organized` wait + // for that write). + instants := func(ts []team) []team { + out := teamsClone(ts) + for i := range out { + out[i].Made, out[i].ClosedAt = out[i].Made.UTC(), out[i].ClosedAt.UTC() + } + return out + } + if !reflect.DeepEqual(instants(a.wall.teams), instants(prior)) { t.Fatalf("Undo left %+v\nwant %+v", a.wall.teams, prior) } teamsFlush(t, a) diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index 14ba7f8f7..02709ff2c 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -350,7 +350,9 @@ func (a *app) teamEdit(change func(f *teamstore.File) error) error { // A Traffic read already out may carry the file from before this write; // counting the edit keeps it from being put back (teamtraffic.go). a.traffic.edits++ + a.teamsDisk.seq++ a.teamsDisk.queue = append(a.teamsDisk.queue, change) + a.teamsDisk.queueSeq = append(a.teamsDisk.queueSeq, a.teamsDisk.seq) return nil } diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 3d6f51e6f..613478b4f 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -101,6 +101,7 @@ type TeamsSeam struct { // Two more doors, OPTIONAL: a seam without them is a seam, and the page says // what it cannot show rather than reading this machine's files. Over --host // no wire answers them yet, so cmd/codeaf's hostTeams leaves them nil. + // The linked-local road sets History because its engine profile is here. // History is team's packets, decided ones included, oldest first: the // closed view reads its closing report from it (teamstore.Packets). @@ -239,8 +240,13 @@ type teamsDisk struct { // watch is the local seam's stat-before-read memory of the logs. watch teamstore.Watch // queue is every edit made to what this window holds and not yet handed - // to the store, in the order they were made. - queue []func(*teamstore.File) error + // to the store, in the order they were made, and queueSeq each one's + // number: seq counts every edit this window has made, so a notice that + // says an edit happened can ask whether the store took THAT edit + // ([app.teamsWriteSettled]). + queue []func(*teamstore.File) error + queueSeq []int + seq int // fetch says an opening found nothing held ([TeamsSeam.Load]'s known // false) and a read is wanted; fetching says it is out. fetch, fetching bool @@ -322,6 +328,11 @@ type teamsWrote struct { // was the opening's read rather than a write. covers int read bool + // seqs is the number of every edit this write carried, and refused the + // ones the store did not take, with why: an edit the file refused alone, + // or every edit when the whole write was refused. + seqs []int + refused map[int]error } // teamsWrite hands the queued edits to the store in one command on the door @@ -349,19 +360,23 @@ func (a *app) teamsWrite() tea.Cmd { })) } if len(a.teamsDisk.queue) > 0 { - changes := a.teamsDisk.queue - a.teamsDisk.queue = nil + changes, seqs := a.teamsDisk.queue, a.teamsDisk.queueSeq + a.teamsDisk.queue, a.teamsDisk.queueSeq = nil, nil seam, words, reserved, covers := a.teamsSeam(), a.teamTabWords(), teamReservedHues(a.pal), a.traffic.edits cmds = append(cmds, a.offLoop(func() func(bool) tea.Cmd { var refused error + var refusedBy map[int]error teams, stamp, err := seam.Update(func(f *teamstore.File) error { - refused = nil - for _, change := range changes { + refused, refusedBy = nil, map[int]error{} + for i, change := range changes { mine := &teamstore.File{Version: f.Version, Teams: teamsClone(f.Teams)} if err := change(mine); err != nil { if refused == nil { refused = err } + if i < len(seqs) { + refusedBy[seqs[i]] = err + } continue } f.Teams = mine.Teams @@ -370,11 +385,17 @@ func (a *app) teamsWrite() tea.Cmd { f.Colour(reserved) return nil }) - if err == nil { + if err != nil { + // THE WHOLE WRITE WAS REFUSED, so no edit in it was taken. + refusedBy = map[int]error{} + for _, seq := range seqs { + refusedBy[seq] = err + } + } else { err = refused } return func(bool) tea.Cmd { - a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers}) + a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) return nil } })) @@ -395,6 +416,7 @@ func (a *app) teamsTake(w teamsWrote) { a.teamsDisk.fetching = false } else { a.traffic.wrote = w.covers + a.teamsWriteSettled(w) } if w.err != nil { if !w.read { diff --git a/internal/tui3/teamsheet.go b/internal/tui3/teamsheet.go index ba4926647..ce826ca9e 100644 --- a/internal/tui3/teamsheet.go +++ b/internal/tui3/teamsheet.go @@ -187,7 +187,7 @@ func (a *app) teamSheetRows(t team) []teamSheetRow { } cap := "no cap" if e.CapUSDDay > 0 { - cap = dollars(e.CapUSDDay) + " a day" + cap = teamsMoney(e.CapUSDDay) + " a day" } depth := itoa(e.DepthLimit) + " levels" if e.DepthLimit == 1 { @@ -415,7 +415,11 @@ func (a *app) teamSheetInsideLines(t team, inner, labelW int) []wallCardLine { no, hn, _ := a.teamSheetButton("Cancel", "esc", tsMoveNo, labelW+wy+1, false) out = append(out, wallCardLine{s: strings.Repeat(" ", labelW) + yes + " " + no, hits: []wallHit{hy, hn}, bleed: true}) } else if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromCard { - said := pal.dim(a.tmove.undo.word) + " " + ink := pal.dim + if a.tmove.undo.said.why != "" { + ink = pal.warn + } + said := ink(a.tmove.undo.word) + " " x := labelW + 1 + ansi.StringWidth(a.tmove.undo.word) + 1 undo, hu, _ := a.teamSheetButton("Undo", "u", tsMoveUndo, x, false) out = append(out, wallCardLine{s: strings.Repeat(" ", labelW+1) + said + undo, hits: []wallHit{hu}, bleed: true}) diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index 3cc6678ca..c17675b09 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -310,7 +311,7 @@ func TestTeamsCardShowsProvenanceAndResets(t *testing.T) { t.Fatal("the card did not open") } text := teamsFrameText(a) - for _, want := range []string{"from Settings", "from harbor", "$5.00 a day"} { + for _, want := range []string{"from Settings", "from harbor", "$5 a day"} { if !strings.Contains(text, want) { t.Fatalf("the card lost %q:\n%s", want, text) } @@ -463,6 +464,78 @@ func TestTeamsClosedFoldReopensWithItsParent(t *testing.T) { } } +// Contract 6.1: A linked local team with a history door draws its closing report in the closed pane. +func TestLinkedLocalClosedTeamDrawsItsReport(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + p := teamstore.Packet{ID: "closing-report", Team: teamstore.Person, Origin: harbor, + Kind: teamstore.PacketClosing, Report: &teamstore.ClosingReport{ + Done: "the parser", Left: "the docs", Files: []string{"parser.go"}, SpendUSD: 1.5, + }} + if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(harbor, a.now(), p.ID) }); err != nil { + t.Fatal(err) + } + door := localTeams(a.profileDir, &a.teamsDisk.watch) + door.History = func(team string) ([]teamstore.Packet, error) { + if team == harbor { + return []teamstore.Packet{p}, nil + } + return nil, nil + } + a.teamsDisk.door = door + drive(t, a, runCmd(a.teamsRead(true))...) + drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActClosedFold, "")))...) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + text := teamsFrameText(a) + for _, want := range []string{"closing report", "done", "the parser", "left", "the docs", "files", "parser.go", "spent", "$1.50"} { + if !strings.Contains(text, want) { + t.Fatalf("the local closed pane lost %q:\n%s", want, text) + } + } + if strings.Contains(text, "not readable over this connection") { + t.Fatalf("the local pane claimed its report could not be read:\n%s", text) + } +} + +// Contract 4.3: The header, packet card, team card and Settings row agree on a sub-cent cap. +func TestTeamSurfacesSpellSubCentCapTheSameWay(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + cap := 0.001 + if err := a.teamEdit(func(f *teamstore.File) error { + return f.SetSettings(harbor, func(s *teamstore.Settings) { s.CapUSDDay = &cap }) + }); err != nil { + t.Fatal(err) + } + a.tp.defaultsOK = true + a.tp.spend = map[string]teamstore.Spend{harbor: {USD: cap}} + team, ok := a.teamByID(harbor) + if !ok { + t.Fatal("no team") + } + if words := a.teamsSpendWords(team); !strings.Contains(words, "$0.001 of $0.001 today") { + t.Fatalf("header cap: %q", words) + } + p := teamstore.Packet{ID: "cap", Team: teamstore.Person, Origin: harbor, Kind: teamstore.PacketCap, + Question: "harbor reached its $0.001 cap today", Cap: &teamstore.CapFacts{Team: harbor, CapUSD: cap, SpentUSD: cap}, + Options: []teamstore.Option{{ID: teamstore.OptionRaiseCap, Label: "Raise to $0.002", Consequence: "continue"}}} + card := plain(strings.Join(a.teamsCard(&teamsDraw{a: a}, p, 80, 0), "\n")) + if !strings.Contains(card, "spent $0.001 of $0.001 today") { + t.Fatalf("cap packet card: %s", card) + } + var settingsCard string + for _, row := range a.teamSheetRows(team) { + if row.code == tsCap { + settingsCard = row.value + } + } + if settingsCard != "$0.001 a day" { + t.Fatalf("team settings card: %q", settingsCard) + } + a.sheet.farTeams = &teamstore.Defaults{CapUSDDay: cap} + if settings, ok := a.sheet.farTeamValue(config.KeyTeamsCapUSDDay); !ok || settings != "$0.001" { + t.Fatalf("Settings Teams row: %q, %v", settings, ok) + } +} + // ── members ───────────────────────────────────────────────────────────────── // A MEMBER THIS WINDOW DOES NOT HOLD IS RESUMED BEHIND, in its own tab, and @@ -666,7 +739,7 @@ func TestTeamsCapPacketSaysItsFiguresAndRaisingWritesNoSetting(t *testing.T) { } drive(t, a, runCmd(a.teamsRead(false))...) text := teamsFrameText(a) - for _, want := range []string{"spent $5.20 of $5.00 today", "Raise to $10", "Stop for today"} { + for _, want := range []string{"spent $5.20 of $5 today", "Raise to $10", "Stop for today"} { if !strings.Contains(text, want) { t.Fatalf("the cap card lost %q:\n%s", want, text) } diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index aa4514a65..7f24e30d2 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -337,6 +337,9 @@ type teamsTopKey struct { undoing bool moving string dragging bool + // wrap is the selected team's wrap-up words, which move with the clock + // on a boundary of their own rather than the minute's. + wrap string } // teamsTopSig is a digest of the members' states this window can see change @@ -409,9 +412,9 @@ func (a *app) teamsSpendWords(t team) string { if s.USD <= 0 { return "" } - return dollars(s.USD) + " today" + return teamsMoney(teamstore.RoundMoney(s.USD)) + " today" } - words := dollars(s.USD) + " of " + teamsMoney(e.CapUSDDay) + " today" + words := teamsMoney(teamstore.RoundMoney(s.USD)) + " of " + teamsMoney(e.CapUSDDay) + " today" if owner != t.ID { if o, ok := a.teamByID(owner); ok { name := o.Name @@ -744,7 +747,7 @@ func (a *app) teamsCard(d *teamsDraw, p teamstore.Packet, width, y int) []string if t, ok := a.teamByID(c.Team); ok { pool = t.Name } - said := "spent " + dollars(c.SpentUSD) + " of " + dollars(c.CapUSD) + " today " + a.teamsDot() + " " + pool + "'s cap" + said := "spent " + teamsMoney(c.SpentUSD) + " of " + teamsMoney(c.CapUSD) + " today " + a.teamsDot() + " " + pool + "'s cap" out = append(out, " "+pal.dim(fit(said, max(width-4, 8)))) } if r := p.Report; r != nil { diff --git a/internal/tui3/teamspagehost.go b/internal/tui3/teamspagehost.go index 09cb78a95..d23630ff5 100644 --- a/internal/tui3/teamspagehost.go +++ b/internal/tui3/teamspagehost.go @@ -115,7 +115,7 @@ func (a *app) teamsHostTop(width int) []string { cur: a.tp.cur, hot: a.tp.hot, focus: a.tp.focus, sig: a.teamsTopSig(t), minute: a.now().Unix() / 60, answering: a.tp.answering, answer: string(a.tp.answer.value), expand: a.tp.expand, ascii: a.pal.ascii, linear: a.linear, undoing: a.teamsUndoing(), - moving: a.teamMoveSig(), dragging: a.tdrag.on, + moving: a.teamMoveSig(), dragging: a.tdrag.on, wrap: a.teamWrapWords(t, a.now()), } if c := &a.tp.top; c.ok && c.key == key { return c.rows @@ -167,6 +167,9 @@ func (a *app) teamsUndoRow(d *teamsDraw, width, y int) []string { } pal := a.pal word := " " + pal.dim(a.tp.undo.name+" is closed") + " " + if why := a.tp.undo.said.why; why != "" { + word = " " + pal.warn(teamNotSaved("the close of "+a.tp.undo.name, why)) + " " + } s, _ := d.button("Undo", teamsTarget{act: teamsActUndo, x0: ansi.StringWidth(word), y: y, hint: "Reopen " + a.tp.undo.name + " and its tabs" + hintSegment + "u"}, pal.ink) return []string{word + s} diff --git a/internal/tui3/teamwrapclock.go b/internal/tui3/teamwrapclock.go new file mode 100644 index 000000000..f3dce67a6 --- /dev/null +++ b/internal/tui3/teamwrapclock.go @@ -0,0 +1,42 @@ +package tui3 + +import "time" + +// ── A TEAM THAT IS WRAPPING UP SAYS HOW LONG IT HAS ───────────────────────── +// +// `Wrap up first` gives the manager a bounded time to bring its closing report +// (internal/session's team_wrapup.go), and the clock is kept ON THE TEAM in +// teams.json (teams' Wrap: when it began and how long it was given), so it +// survives a restart. Nothing drew it: a person who asked a team to wrap up +// could not see whether it had twelve minutes left or none. The teams page's +// header and the manager's side column now say it. +// +// IT IS READ FROM THE TEAM THIS WINDOW HOLDS, never from the store on the paint +// clock: the frame law holds here as everywhere. The time left is the start +// plus the bound less now, so a window opened after a restart says the same as +// the one that was closed. Both places are cached by the words, so they move +// on the clock alone. + +// teamWrapWords is what a team's wrap-up says at now: `wrapping up · 12m +// left`, `wrapping up · under a minute left`, `wrapping up · out of time`, and +// `wrapping up` alone for a clock from a file written before the bound was +// kept. "" for a team with no wrap-up, or a closed one: the emptiness law. +func (a *app) teamWrapWords(t team, now time.Time) string { + if t.Wrap == nil || t.Closed() || t.Wrap.Started.IsZero() { + return "" + } + words := "wrapping up" + if t.Wrap.Bound <= 0 { + return words + } + sep := " " + a.teamsDot() + " " + left := t.Wrap.Started.Add(t.Wrap.Bound).Sub(now) + switch { + case left <= 0: + return words + sep + "out of time" + case left < time.Minute: + return words + sep + "under a minute left" + } + minutes := int((left + time.Minute - 1) / time.Minute) + return words + sep + itoa(minutes) + "m left" +} diff --git a/internal/tui3/teamwrapclock_test.go b/internal/tui3/teamwrapclock_test.go new file mode 100644 index 000000000..8ae93f8f4 --- /dev/null +++ b/internal/tui3/teamwrapclock_test.go @@ -0,0 +1,97 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// setWrap puts a wrap-up that began at started with bound on team id, the way +// the manager's session writes it to teams.json. +func setWrap(t *testing.T, a *app, id string, started time.Time, bound time.Duration) { + t.Helper() + if err := a.teamEdit(func(f *teamstore.File) error { + i := teamIndex(f.Teams, id) + if i < 0 { + return nil + } + f.Teams[i].Wrap = &teamstore.Wrap{Started: started, Bound: bound} + return nil + }); err != nil { + t.Fatal(err) + } +} + +// Contract 5.1, 5.4 and 5.5: the teams page's header says how long a team that +// is wrapping up has left, moves on the clock alone, and says nothing for a +// team with no wrap-up. +func TestTheTeamsHeaderSaysHowLongAWrapUpHasLeft(t *testing.T) { + a, harbor, orbit := teamsPlaceLabIDs(t) + base := time.Date(2026, 9, 25, 15, 0, 0, 0, time.UTC) + now := base.Add(3 * time.Minute) + a.clock = func() time.Time { return now } + setWrap(t, a, harbor, base, 15*time.Minute) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · 12m left") { + t.Fatalf("the header does not say the wrap-up's time left:\n%s", text) + } + now = base.Add(14*time.Minute + 30*time.Second) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · under a minute left") { + t.Fatalf("the header did not move with the clock:\n%s", text) + } + now = base.Add(16 * time.Minute) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · out of time") { + t.Fatalf("the header does not say the wrap-up is out of time:\n%s", text) + } + drive(t, a, runCmd(a.teamsSelect(orbit))...) + if text := teamsFrameText(a); strings.Contains(text, "wrapping up") { + t.Fatalf("a team with no wrap-up says it is wrapping up:\n%s", text) + } +} + +// Contract 5.3: a wrap-up from a file written before the bound was stored says +// it is wrapping up and names no time. +func TestAWrapUpWithNoBoundNamesNoTime(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + setWrap(t, a, harbor, a.now().Add(-time.Minute), 0) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + text := teamsFrameText(a) + if !strings.Contains(text, "wrapping up") || strings.Contains(text, "left") || strings.Contains(text, "out of time") { + t.Fatalf("a boundless wrap-up named a time:\n%s", text) + } +} + +// Contract 5.2: the manager's side column says the same words as the header. +func TestTheManagersColumnSaysHowLongAWrapUpHasLeft(t *testing.T) { + a := managerColumnApp(t) + team, kind, _ := a.sideTeam() + if kind != sideKindManager { + t.Fatal("the manager is not in front") + } + base := time.Date(2026, 9, 25, 15, 0, 0, 0, time.UTC) + now := base.Add(3 * time.Minute) + a.clock = func() time.Time { return now } + draw := func() string { + lines, _ := a.sideTrafficView(30) + var rows []string + for _, l := range lines { + rows = append(rows, l.text) + } + return ansi.Strip(strings.Join(rows, "\n")) + } + if got := draw(); strings.Contains(got, "wrapping up") { + t.Fatalf("a team with no wrap-up says it is wrapping up:\n%s", got) + } + setWrap(t, a, team.ID, base, 15*time.Minute) + if got := draw(); !strings.Contains(got, "wrapping up · 12m left") { + t.Fatalf("the manager's column does not say the wrap-up's time left:\n%s", got) + } + now = base.Add(5 * time.Minute) + if got := draw(); !strings.Contains(got, "wrapping up · 10m left") { + t.Fatalf("the manager's column did not move with the clock:\n%s", got) + } +} diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go new file mode 100644 index 000000000..5c8755fed --- /dev/null +++ b/internal/tui3/teamwritesaid.go @@ -0,0 +1,100 @@ +package tui3 + +import "time" + +// ── A TEAM EDIT IS SAID DONE ONLY WHEN THE STORE TOOK IT ──────────────────── +// +// Every team edit is made to what this window holds at once and written to the +// store a moment later, off the loop ([app.teamEdit], [app.teamsWrite]). The +// notices that say an edit happened (`Made beta · 1` on the wall, `Organized · +// 1 new team` beside Organize, `harbor is closed` and a move's words on the +// teams page) used to be drawn at the keystroke, so a write the store refused +// (a lock another process held, a file that would not read, a far machine whose +// teams kept changing) still said it was done, and the refusal went into a note +// in the conversation behind the wall, where nobody was looking. THE NOTICE NOW +// WAITS FOR THE WRITE THAT CARRIED ITS EDIT, and a refusal takes the notice's +// place, in the warning ink, with the store's reason. The edit itself stays in +// this window, as it always did. + +// teamWriteSaid ties one notice to the edit it reports: the edit's number +// ([teamsDisk.seq]), whether its write is still out, and why the store refused +// it, "" when it took it. +type teamWriteSaid struct { + seq int + pending bool + why string +} + +// teamWriteWatch is the notice tie for the edit [app.teamEdit] just queued, or, +// when err says the edit never reached the queue, that refusal at once. +func (a *app) teamWriteWatch(err error) teamWriteSaid { + if err != nil { + return teamWriteSaid{why: err.Error()} + } + return teamWriteSaid{seq: a.teamsDisk.seq, pending: true} +} + +// said reports whether the notice may be drawn at all: its write is back. +func (s teamWriteSaid) said() bool { return !s.pending } + +// settle takes one write's answer, and reports whether it answered this +// notice's edit. +func (s *teamWriteSaid) settle(w teamsWrote) bool { + if !s.pending { + return false + } + for _, seq := range w.seqs { + if seq != s.seq { + continue + } + s.pending = false + if err := w.refused[seq]; err != nil { + s.why = err.Error() + } + return true + } + return false +} + +// teamsWriteSettled hands one write's answer to every notice waiting on it. +// A notice's clock starts when its write comes back, not when it was made, so +// a slow write over a connection still shows its notice for the whole time. +func (a *app) teamsWriteSettled(w teamsWrote) { + now := a.now() + if a.wall.madeSaid.settle(w) { + a.wall.madeAt = now + } + if a.wall.org.said.settle(w) { + a.wall.org.doneAt = now + } + if a.tp.undo.said.settle(w) { + a.tp.undo.at = now + a.tp.top = teamsTopCache{} + } + if a.tmove.undo.said.settle(w) { + a.tmove.undo.at = now + if why := a.tmove.undo.said.why; why != "" { + a.tmove.undo.word = teamNotSaved("the move", why) + } + a.tp.top = teamsTopCache{} + } + a.touch() +} + +// teamNotSaved is a refusal in the words that take its notice's place. +func teamNotSaved(name, why string) string { + s := "not saved" + if name != "" { + s = name + " was not saved" + } + if why != "" { + s += " · " + why + } + return s +} + +// teamSaidWithin reports whether a settled notice made at at is still inside +// its time d at now. +func teamSaidWithin(at, now time.Time, d time.Duration) bool { + return !at.IsZero() && now.Sub(at) >= 0 && now.Sub(at) < d +} diff --git a/internal/tui3/teamwritesaid_test.go b/internal/tui3/teamwritesaid_test.go new file mode 100644 index 000000000..d15e56981 --- /dev/null +++ b/internal/tui3/teamwritesaid_test.go @@ -0,0 +1,154 @@ +package tui3 + +import ( + "errors" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// refusingSeam is this machine's teams seam with its write swapped for one +// that hands every change the file as fake holds it and answers err, or, when +// err is nil, whatever the changes made of it. +func refusingSeam(a *app, err error) TeamsSeam { + seam := localTeams(a.profileDir, &a.teamsDisk.watch) + seam.Update = func(change func(*teamstore.File) error) ([]teamstore.Team, string, error) { + if err != nil { + return nil, "", err + } + f := &teamstore.File{} + if cerr := change(f); cerr != nil { + return nil, "", cerr + } + return f.Teams, "stamp", nil + } + return seam +} + +// wallMakeFromFront opens the wall, picks the first tile and makes a team +// called name from it, as the card's Create does, without running the write. +func wallMakeFromFront(t *testing.T, a *app, name string) { + t.Helper() + _ = a.openWall() + tiles := a.wallShown(a.now()) + if len(tiles) == 0 { + t.Fatal("no tiles") + } + a.wall.marked = map[string]bool{tiles[0].tab.key: true} + a.wall.name = name + _ = a.wallMakeTeam(a.wallShown(a.now())) +} + +// Contract 2.1 and 2.2: a team the store refused to save is never said to be +// made. Before the write comes back the row says nothing; after a refusal it +// says the team was not saved, in place of `Made`. +func TestAWallTeamTheStoreRefusedIsNeverSaidToBeMade(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, teamstore.ErrBusy) + wallMakeFromFront(t, a, "beta") + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Made beta") { + t.Fatalf("the wall said the team was made before the store took it:\n%s", frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + if strings.Contains(frame, "Made beta") { + t.Fatalf("the wall said a refused team was made:\n%s", frame) + } + if !strings.Contains(frame, "beta was not saved") { + t.Fatalf("the wall did not say the team was not saved:\n%s", frame) + } + // Contract 2.4: the team stays in this window. + if teamNamed(a.wall.teams, "beta") < 0 { + t.Fatal("the refused team left the window") + } +} + +// Contract 2.1: a team the store took says `Made` once the write is back. +func TestAWallTeamSaysMadeOnlyOnceTheStoreTookIt(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, nil) + wallMakeFromFront(t, a, "gamma") + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Made gamma") { + t.Fatalf("the wall said the team was made before the store took it:\n%s", frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); !strings.Contains(frame, "Made gamma · 1") { + t.Fatalf("the wall did not say the team was made:\n%s", frame) + } +} + +// Contract 2.3: an edit the store took is not blamed for a sibling edit the +// store refused in the same write. +func TestATakenTeamIsNotBlamedForASiblingEditsRefusal(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, nil) + wallMakeFromFront(t, a, "delta") + // This edit holds in the window (which is at the current version) and is + // refused by the store's file (a fake at version 0). + if err := a.teamEdit(func(f *teamstore.File) error { + if f.Version == 0 { + return errors.New("the file moved") + } + return nil + }); err != nil { + t.Fatal(err) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + if !strings.Contains(frame, "Made delta · 1") || strings.Contains(frame, "delta was not saved") { + t.Fatalf("the made team was blamed for another edit's refusal:\n%s", frame) + } +} + +// Contract 2.1 and 2.2: Organize's `Organized` is said only once the store took +// it, and a refused Apply says it was not saved instead. +func TestARefusedOrganizeIsNeverSaidToBeOrganized(t *testing.T) { + for _, refuse := range []bool{false, true} { + a, _, _ := tabApp(t) + var err error + if refuse { + err = teamstore.ErrBusy + } + a.teamsDisk.door = refusingSeam(a, err) + _ = a.openWall() + tiles := a.wallShown(a.now()) + o := &a.wall.org + o.on, o.props = true, []orgProp{{name: "sorted", keys: []string{tiles[0].tab.key}, names: []string{tiles[0].name}, take: true}} + a.wallOrganizeApply() + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Organized ·") { + t.Fatalf("refuse=%v: Organized was said before the store took it:\n%s", refuse, frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + switch { + case refuse && (strings.Contains(frame, "Organized ·") || !strings.Contains(frame, "not saved")): + t.Fatalf("a refused Organize said it was done, or not that it was refused:\n%s", frame) + case !refuse && !strings.Contains(frame, "Organized · 1 new team"): + t.Fatalf("a taken Organize did not say so:\n%s", frame) + } + } +} + +// Contract 2.1 and 2.2: the teams page says a team is closed only once the +// store took the close, and says the close was not saved when it refused. +func TestTheTeamsPageSaysAClosedTeamOnlyOnceTheStoreTookIt(t *testing.T) { + for _, refuse := range []bool{false, true} { + a, harbor, _ := teamsPlaceLabIDs(t) + if refuse { + a.teamsDisk.door = refusingSeam(a, teamstore.ErrBusy) + } + _ = a.teamsCloseNow(harbor, "") + if text := teamsFrameText(a); strings.Contains(text, "harbor is closed") { + t.Fatalf("refuse=%v: the page said harbor is closed before the store took it:\n%s", refuse, text) + } + drive(t, a, runCmd(a.teamsWrite())...) + text := teamsFrameText(a) + switch { + case refuse && (strings.Contains(text, "harbor is closed") || !strings.Contains(text, "the close of harbor was not saved")): + t.Fatalf("a refused close said it closed, or not that it was refused:\n%s", text) + case !refuse && !strings.Contains(text, "harbor is closed"): + t.Fatalf("a taken close was not said:\n%s", text) + } + } +} diff --git a/internal/tui3/treespend.go b/internal/tui3/treespend.go index 882aad0d4..59f08e911 100644 --- a/internal/tui3/treespend.go +++ b/internal/tui3/treespend.go @@ -130,11 +130,17 @@ func (a *app) treeLines() ([]session.UsageLine, bool) { // whose ledger was moved — has books the file cannot account for, and a figure // that dropped when a person opened yesterday's work would be worse than the // figure that was too small. -func (a *app) spendShown() float64 { - if total := a.tree.Folded(); total > a.cost { - return total +func (a *app) spendShown() float64 { return spendOf(a.cost, a.tree.Folded()) } + +// spendOf is [app.spendShown]'s rule on its own, the larger of a +// conversation's books and its tree on the ledger, so the wall's tile for a +// conversation this window holds behind the front says the figure its status +// line would (wallspend.go), by the same arithmetic and not a copy of it. +func spendOf(books, tree float64) float64 { + if tree > books { + return tree } - return a.cost + return books } // spendSplit is what /cost prints under the total: what the conversation itself @@ -164,8 +170,12 @@ func (a *app) spendSplit() (conversation, tasks float64, ok bool) { // this is. A session with no journal — and the legacy flat layout, where the // folder is the workspace and not an id — answers with something no ledger line // names, which sums to nothing and leaves [app.spendShown] on the books alone. -func (a *app) selfSessionID() string { - file := strings.TrimSpace(a.file) +func (a *app) selfSessionID() string { return sessionIDOf(a.file) } + +// sessionIDOf is the conversation id a journal at file is kept under: the name +// of its folder. "" for no file. +func sessionIDOf(file string) string { + file = strings.TrimSpace(file) if file == "" { return "" } diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index 630ac00b7..b61151c44 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -235,6 +235,7 @@ func (a *app) wallFrame(width, height int) []string { made: a.wall.made, madeN: a.wall.madeN, madeAt: a.wall.madeAt, + madeSaid: a.wall.madeSaid, hover: a.wall.hover, choices: a.wall.choices, choice: a.wall.choice, @@ -796,12 +797,15 @@ func (a *app) wallMakeTeam(tiles []wallTile) tea.Cmd { if err != nil { a.note("the team is kept for this window, but " + err.Error()) } + // `Made` WAITS FOR THE STORE: the row says it once the write that carried + // this team is back, and says it was not saved if that write was refused. + a.wall.madeSaid = a.teamWriteWatch(err) // THE VIEW STAYS WHERE IT WAS. A person making a team is usually sorting // several at once, and a wall that jumped into the new one would hide the // conversations they were about to sort next. The chip row names the team // and its chip is one press away. a.wall.marked = map[string]bool{} - a.wall.made, a.wall.madeN, a.wall.madeAt = made.Name, len(made.Members), time.Now() + a.wall.made, a.wall.madeN, a.wall.madeAt = made.Name, len(made.Members), a.now() return nil } diff --git a/internal/tui3/wallbar.go b/internal/tui3/wallbar.go index 727500018..8a67eb8a3 100644 --- a/internal/tui3/wallbar.go +++ b/internal/tui3/wallbar.go @@ -755,10 +755,18 @@ func wallTeamsSegments(pal palette, g wallGlyphs, v wallView, k wallKeys, y int, *hits = append(*hits, wallHit{x0: *x, y0: y, x1: *x + len(add), y1: y + 1, kind: wallHitAddTeam}) put(s, len(add)) } - // A team just made says so for a moment, in the row it now sits in. - if v.made != "" && !v.madeAt.IsZero() && v.now.Sub(v.madeAt) < wallMadeFor { - word := " Made " + v.made + " " + g.sep + " " + strconv.Itoa(v.madeN) - if fitsAt(ansi.StringWidth(word)) { + // A team just made says so for a moment, in the row it now sits in, once + // the store took it; a team the store refused says that instead. + if v.made != "" && v.madeSaid.said() && teamSaidWithin(v.madeAt, v.now, wallMadeFor) { + if v.madeSaid.why != "" { + word := " " + teamNotSaved(v.made, v.madeSaid.why) + if !fitsAt(ansi.StringWidth(word)) { + word = " " + teamNotSaved(v.made, "") + } + if fitsAt(ansi.StringWidth(word)) { + put(pal.warn(word), ansi.StringWidth(word)) + } + } else if word := " Made " + v.made + " " + g.sep + " " + strconv.Itoa(v.madeN); fitsAt(ansi.StringWidth(word)) { put(pal.dim(word), ansi.StringWidth(word)) } } diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index 9f7c61317..a58de9f56 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -92,6 +92,8 @@ func TestWallClickPicksTilesAndMakesATeam(t *testing.T) { if len(a.wall.marked) != 0 || a.wall.made != offered { t.Fatalf("after create: marked=%v made=%q", a.wall.marked, a.wall.made) } + // `Made` is said once the store took the team (teamwritesaid.go). + teamsFlush(t, a) if !strings.Contains(ansi.Strip(a.wallFrame(a.width, a.height)[a.wall.headRows+1]), "Made "+offered) { t.Fatal("the chips row does not say the team was made") } diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 948e0e6c9..d58116536 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -93,6 +93,10 @@ type wallTile struct { // teams is the id of every team this conversation is in. A conversation // may be in several. teams []string + // spent is what the conversation has spent, the figure its own status line + // would show (treespend.go's [spendOf]); 0 is nothing known, drawn as + // nothing. + spent float64 } // wallTailCap is the most logical lines a reading keeps per conversation. @@ -149,6 +153,9 @@ type wallView struct { made string madeN int madeAt time.Time + // madeSaid says whether the store took the team: `Made` waits for it, and + // a refusal is said in its place (teamwritesaid.go). + madeSaid teamWriteSaid // pointerOn says pointerY holds the row the pointer is on, in the painter's // own rows (the head's rows taken off). Some targets share a ref on two // rows, a waiting tile's Answer and its row's Answer, or a picked tile's ☐ @@ -347,6 +354,12 @@ type wallState struct { hits []wallHit // tails is the reading cache, by chatTab.key (walltail.go owns it). tails map[string]*wallTail + // treeAsking says a ledger reading for the held tiles is out, treeAt when + // the last one left, and treeCache the wall's own tail-reading cache of the + // ledger file, used only by that one reading (wallspend.go). + treeAsking bool + treeAt time.Time + treeCache *session.UsageCache // teams is the loaded set and activeID the id of the one the strip is // narrowed to, "" for none (teams.go owns both). teams []team @@ -380,6 +393,8 @@ type wallState struct { made string madeN int madeAt time.Time + // madeSaid ties `Made` to the write that carried it (teamwritesaid.go). + madeSaid teamWriteSaid // The motion and the pointer's memory (wall.go). revealAt is when the // opening's row-by-row reveal began, zero once it is done; zoomAt and @@ -423,12 +438,16 @@ type wallState struct { // wallTail is one conversation's cached reading (walltail.go fills it). type wallTail struct { - lines []wallLine - count int // transcript entries seen at the last reading - textLen int // total text length seen, so a growing last entry counts as activity - seen time.Time - fresh int - freshAt time.Time + // books is what the conversation's own books said at the last reading, + // and tree what its tree on the ledger came to at the last ledger reading + // (wallspend.go). Both only ever grow. + books, tree float64 + lines []wallLine + count int // transcript entries seen at the last reading + textLen int // total text length seen, so a growing last entry counts as activity + seen time.Time + fresh int + freshAt time.Time // spark is a ring of per-second activity, newest at sparkAt. spark [wallSparkLen]uint8 sparkAt time.Time diff --git a/internal/tui3/wallmini.go b/internal/tui3/wallmini.go index c3fc3d196..e074e464c 100644 --- a/internal/tui3/wallmini.go +++ b/internal/tui3/wallmini.go @@ -121,6 +121,18 @@ func (a *app) wallDraw(entries []session.DisplayEntry, width int) []string { case "tool": last := i == len(entries)-1 || entries[i+1].Role != "tool" rows = a.wallToolRows(e, last, width) + case "aside": + // AN ASIDE IS DRAWN AS THE CONVERSATION DRAWS IT (teamcard.go's + // [asideShapeOf]): a team delivery as its lines, a team wake with + // nothing in it not at all, anything else as its first line. + switch asideShapeOf(e) { + case asideTeam: + rows = a.wallTeamRows(e, width) + case asideLine: + if line := strings.TrimSpace(firstLine(e.Text)); line != "" { + rows = []string{a.pal.italic(a.pal.dim(ansi.Truncate(line, width, "…")))} + } + } default: if line := strings.TrimSpace(firstLine(e.Text)); line != "" { rows = []string{a.pal.italic(a.pal.dim(ansi.Truncate(line, width, "…")))} @@ -187,6 +199,27 @@ func (a *app) wallReplyRows(text string, width int) []string { return renderMarkdownWithCode(a.styler(), text, width, nil) } +// wallTeamRows is a team delivery as the conversation's card draws it +// (teamcard.go): each line headed by who said it to whom, its words under a +// bar, fit to the tile. It is the card's words without the card's memory of the +// conversation in front, which a tile of another conversation must not read. +func (a *app) wallTeamRows(e session.DisplayEntry, width int) []string { + pal := a.pal + arrow, bar := a.linearMark("→", "->"), a.linearMark("│", "|") + var out []string + for _, c := range teamCardsOf(e, a.teamManagerMark()) { + head := pal.muted(c.from) + pal.dim(" "+arrow+" ") + pal.muted(c.to) + if c.tag != "" { + head += " " + pal.dim(c.tag) + } + out = append(out, ansi.Truncate(head, width, "…")) + if text := strings.TrimSpace(firstLine(c.text)); text != "" { + out = append(out, pal.dim(bar+" ")+pal.ink(ansi.Truncate(text, max(width-2, 1), "…"))) + } + } + return out +} + // wallToolRows is one call on the rail: its name in muted, its target after // it, a shell command highlighted exactly as the transcript highlights it. func (a *app) wallToolRows(e session.DisplayEntry, last bool, width int) []string { diff --git a/internal/tui3/wallspend.go b/internal/tui3/wallspend.go new file mode 100644 index 000000000..1f10f32e0 --- /dev/null +++ b/internal/tui3/wallspend.go @@ -0,0 +1,113 @@ +package tui3 + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── A TILE SAYS WHAT ITS CONVERSATION SPENT ───────────────────────────────── +// +// The wall promised what each conversation spent and drew nothing. A tile now +// ends its state line in the figure that conversation's own status line shows: +// for the conversation in front, [app.spendShown] itself; for one this window +// holds behind it, the same rule ([spendOf]) over that conversation's books and +// its tree on the ledger. Nothing spent, or nothing known, draws nothing. +// +// THE FRAME READS ONLY THE CACHE. The books are asked in the command that +// already reads the transcript ([app.wallReadCmd]); the ledger is read by one +// command of its own, at most every [wallTreeEvery] while the wall is up and a +// held tile exists, through the two doors [app.treeLines] uses (the seam, or +// the file through a cache the wall owns), and summed per conversation with +// [session.UsageTree]. A frame never asks an agent, reads a file or crosses a +// wire (framedisk_law_test.go). + +// wallTreeEvery is the least time between two ledger readings for the held +// tiles. A task's spend moves on the ledger as it is made, and a tile is a +// glance: five seconds behind is still the right figure to decide on. +const wallTreeEvery = 5 * time.Second + +// wallTreeMsg is one ledger reading: each held conversation's tree, by key. +type wallTreeMsg struct { + trees map[string]float64 +} + +// wallSpent is tile key's figure, from memory only. +func (a *app) wallSpent(key string, tail *wallTail) float64 { + if key == a.frontTabKey() { + return a.spendShown() + } + if tail == nil { + return 0 + } + return spendOf(tail.books, tail.tree) +} + +// wallTreeCmd reads the ledger once, off the loop, for every conversation this +// window holds behind the front, and is nil when there is nothing to read or a +// reading is out or was made less than [wallTreeEvery] ago. +func (a *app) wallTreeCmd() tea.Cmd { + if !a.wall.on || a.wall.treeAsking || a.shared { + return nil + } + now := a.now() + if !a.wall.treeAt.IsZero() && now.Sub(a.wall.treeAt) < wallTreeEvery { + return nil + } + ids := map[string]string{} + for key, held := range a.behind { + if held == nil || held.conv.Agent == nil { + continue + } + if id := sessionIDOf(held.conv.SessionFile); id != "" { + ids[key] = id + } + } + ledger, path := a.ledger, strings.TrimSpace(a.usageLedger) + if len(ids) == 0 || (ledger == nil && path == "") { + return nil + } + if a.wall.treeCache == nil { + a.wall.treeCache = &session.UsageCache{} + } + cache, from := a.wall.treeCache, session.LastDays(now, spendWindowDays).From + a.wall.treeAsking, a.wall.treeAt = true, now + return func() tea.Msg { + var lines []session.UsageLine + known := true + if ledger != nil { + lines, _, known = ledger(from) + } else { + cache.Path = path + // A torn last line costs that line and never the figure, as it does + // for the status line ([app.treeLines]). + lines, _ = cache.Read(time.Time{}) + } + trees := map[string]float64{} + if known { + for key, id := range ids { + trees[key] = session.UsageTree(lines, id).Folded() + } + } + return wallTreeMsg{trees: trees} + } +} + +// wallTakeTree folds one ledger reading into the tiles' cache, on the loop. A +// figure only ever grows, as the status line's does. +func (a *app) wallTakeTree(msg wallTreeMsg) { + a.wall.treeAsking = false + for key, tree := range msg.trees { + tail := a.wall.tails[key] + if tail == nil { + continue + } + if tree > tail.tree { + tail.tree = tree + a.touch() + } + } +} diff --git a/internal/tui3/wallspend_test.go b/internal/tui3/wallspend_test.go new file mode 100644 index 000000000..58fee8ef5 --- /dev/null +++ b/internal/tui3/wallspend_test.go @@ -0,0 +1,182 @@ +package tui3 + +import ( + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// heldKeyOf is the key this window holds agent behind the front under. +func heldKeyOf(t *testing.T, a *app, agent Agent) string { + t.Helper() + for key, held := range a.behind { + if held != nil && held.conv.Agent == agent { + return key + } + } + t.Fatal("the agent is not held") + return "" +} + +// wallTileRowsFor is tile key's rows in the wall's frame, plain. +func wallTileRowsFor(t *testing.T, a *app, key string) string { + t.Helper() + frame := a.wallFrame(a.width, a.height) + for i, tile := range a.wallShown(a.now()) { + if tile.tab.key != key { + continue + } + // A tile is answered by several targets (its border, its body, its + // controls); its rectangle is all of them together. + x0, y0, x1, y1 := -1, -1, -1, -1 + for _, hit := range a.wall.hits { + if hit.arg != i || (hit.kind != wallHitTile && hit.kind != wallHitOpen) { + continue + } + if x0 < 0 || hit.x0 < x0 { + x0 = hit.x0 + } + if y0 < 0 || hit.y0 < y0 { + y0 = hit.y0 + } + x1, y1 = max(x1, hit.x1), max(y1, hit.y1) + } + if y0 >= 0 { + var rows []string + for y := y0; y < y1 && y < len(frame); y++ { + rows = append(rows, ansi.Strip(ansi.Cut(frame[y], x0, x1))) + } + return strings.Join(rows, "\n") + } + } + t.Fatalf("no tile for %q on the wall", key) + return "" +} + +// Contract 1.1 and 1.2: the tile in front says what the conversation spent, +// the status line's very figure and spelling. +func TestTheFrontTileSaysTheStatusLinesSpend(t *testing.T) { + a, _, _ := tabApp(t) + a.take(session.Usage{CostUSD: 0.0042}) + _ = a.openWall() + a.wall.revealAt = time.Time{} // the opening's motion, which any key cuts short + want := dollars(a.spendShown()) + if got := wallTileRowsFor(t, a, a.frontTabKey()); !strings.Contains(got, want) { + t.Fatalf("the front tile does not say %s:\n%s", want, got) + } +} + +// Contract 1.3: a held conversation's tile says what its own books say, read +// off the loop with its transcript. +func TestAHeldTileSaysWhatItsConversationSpent(t *testing.T) { + a, older, _ := tabApp(t) + older.usage = session.Usage{CostUSD: 1.2} + key := heldKeyOf(t, a, older) + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + if got := wallTileRowsFor(t, a, key); !strings.Contains(got, "$1.20") { + t.Fatalf("the held tile does not say $1.20:\n%s", got) + } +} + +// Contract 1.4: a conversation that spent nothing draws no money at all. +func TestATileThatSpentNothingDrawsNoMoney(t *testing.T) { + a, older, _ := tabApp(t) + key := heldKeyOf(t, a, older) + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + for _, k := range []string{key, a.frontTabKey()} { + if got := wallTileRowsFor(t, a, k); strings.Contains(got, "$") { + t.Fatalf("a tile that spent nothing drew money:\n%s", got) + } + } +} + +// Contract 1.5 and 1.6: the state words win. The figure sits at the right end +// of the state line with room before it, is dropped whole when it does not fit, +// and stands alone on an idle tile. +func TestTheSpendGivesWayToTheStateWords(t *testing.T) { + a, _, _ := tabApp(t) + now := time.Now() + v := wallView{now: now, spin: 0} + g := wallGlyphsFor(a.pal.ascii) + working := wallTile{signal: tabWorking, live: true, doing: "running bash", moved: now.Add(-2 * time.Minute), spent: 0.0042} + wide := ansi.Strip(wallMetaLine(a.pal, g, v, working, 40)) + if !strings.HasSuffix(wide, "$0.0042") || !strings.Contains(wide, "running bash") || !strings.Contains(wide, " $0.0042") { + t.Fatalf("a wide state line: %q", wide) + } + words := ansi.StringWidth(ansi.Strip(wallMetaLine(a.pal, g, v, wallTile{signal: tabWorking, live: true, doing: "running bash", moved: now.Add(-2 * time.Minute)}, 40))) + narrow := ansi.Strip(wallMetaLine(a.pal, g, v, working, words+3)) + if strings.Contains(narrow, "$") || !strings.Contains(narrow, "running bash") { + t.Fatalf("a narrow state line cut the words for the money: %q", narrow) + } + idle := ansi.Strip(wallMetaLine(a.pal, g, v, wallTile{live: true, spent: 0.5}, 30)) + if strings.TrimSpace(idle) != "$0.50" || ansi.StringWidth(idle) > 30 { + t.Fatalf("an idle tile's state line: %q", idle) + } +} + +// usageCounter is an agent whose books count how often they are asked. +type usageCounter struct { + *fakeAgent + asked *atomic.Int32 +} + +func (u usageCounter) Usage() session.Usage { u.asked.Add(1); return u.fakeAgent.Usage() } + +// Contract 1.3: a frame never asks a conversation what it spent; only the read +// the wall makes off the loop does. +func TestAWallFrameNeverAsksWhatAConversationSpent(t *testing.T) { + a, older, _ := tabApp(t) + key := heldKeyOf(t, a, older) + var asked atomic.Int32 + a.behind[key].conv.Agent = usageCounter{fakeAgent: older, asked: &asked} + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + before := asked.Load() + for i := 0; i < 5; i++ { + _ = a.wallFrame(a.width, a.height) + _ = a.wallShown(a.now()) + } + if asked.Load() != before { + t.Fatalf("a frame asked the books %d times", asked.Load()-before) + } +} + +// Contract 1.3: a held conversation whose work has spent more than its books +// know shows the ledger's figure, as its status line would, read off the loop. +func TestAHeldTileSaysWhatItsWorkSpentOnTheLedger(t *testing.T) { + a, older, _ := tabApp(t) + older.usage = session.Usage{CostUSD: 0.5} + key := heldKeyOf(t, a, older) + const id = "aaaa1111bbbb2222" + a.behind[key].conv.SessionFile = "/tmp/lab/" + id + "/transcript.jsonl" + asked := 0 + a.ledger = func(time.Time) ([]session.UsageLine, bool, bool) { + asked++ + return []session.UsageLine{ + {At: time.Now(), Session: id, Calls: 1, USD: 0.5}, + {At: time.Now(), Session: "task-node", Root: id, Calls: 1, USD: 1.75}, + }, true, true + } + // The opening asks for the transcripts and, once, for the ledger. + open := a.openWall() + for i := 0; i < 3; i++ { + _ = a.wallFrame(a.width, a.height) + } + if asked != 0 { + t.Fatalf("a frame read the ledger %d times", asked) + } + drive(t, a, runCmd(open)...) + if asked != 1 { + t.Fatalf("the opening read the ledger %d times", asked) + } + if got := wallTileRowsFor(t, a, key); !strings.Contains(got, "$2.25") { + t.Fatalf("the held tile does not say its tree's $2.25:\n%s", got) + } +} diff --git a/internal/tui3/walltail.go b/internal/tui3/walltail.go index 332bbb6b4..8c13604ac 100644 --- a/internal/tui3/walltail.go +++ b/internal/tui3/walltail.go @@ -57,6 +57,9 @@ type wallReadMsg struct { entries []session.DisplayEntry at time.Time live bool + // books is what the conversation's books said on the same trip + // ([Agent.Usage]), which takes the same lock the transcript does. + books float64 } // wallTickMsg is the wall's clock. @@ -136,6 +139,21 @@ func wallEntryLines(e session.DisplayEntry) []wallLine { } return out case "note", "aside": + // A team delivery is its lines and a team wake with nothing in it is + // nothing, as the conversation draws them (teamcard.go's + // [asideShapeOf]). + if e.Role == "aside" { + switch asideShapeOf(e) { + case asideHidden: + return nil + case asideTeam: + var out []wallLine + for _, c := range teamCardsOf(e, teamManagerGlyph) { + out = append(out, wallLine{kind: wallNote, text: c.from + " → " + c.to + " " + wallFirstLine(c.text)}) + } + return out + } + } // A compaction summary is pages long and a task's note a paragraph; the // tile says that one happened, in its first line. if text := wallFirstLine(e.Text); text != "" { @@ -333,7 +351,7 @@ func (a *app) wallReadCmd(keys ...string) tea.Cmd { key := key cmds = append(cmds, func() tea.Msg { at := time.Now() - return wallReadMsg{key: key, entries: agent.Transcript(), at: at, live: live} + return wallReadMsg{key: key, entries: agent.Transcript(), at: at, live: live, books: agent.Usage().CostUSD} }) } switch len(cmds) { @@ -370,6 +388,7 @@ func (a *app) wallTakeRead(msg wallReadMsg) { if !tail.seen.IsZero() && !msg.at.After(tail.seen) { return } + tail.books = max(tail.books, msg.books) tail.take(msg.entries, msg.at, msg.live) } @@ -419,6 +438,7 @@ func (a *app) wallTick() tea.Cmd { if a.wallSpinning() { next = tea.Batch(next, a.wake()) } + next = tea.Batch(next, a.wallTreeCmd()) if read := a.wallReadCmd(keys...); read != nil { return tea.Batch(read, next) } @@ -494,6 +514,7 @@ func (a *app) wallTiles(now time.Time) []wallTile { if tile.signal == tabNeedsPerson { tile.question = a.wallQuestion(tab, tail) } + tile.spent = a.wallSpent(tab.key, tail) tiles = append(tiles, tile) } return tiles diff --git a/internal/tui3/wallview.go b/internal/tui3/wallview.go index 7bf804edd..7f00a964a 100644 --- a/internal/tui3/wallview.go +++ b/internal/tui3/wallview.go @@ -860,6 +860,20 @@ func wallMetaLine(pal palette, g wallGlyphs, v wallView, t wallTile, w int) stri if ansi.StringWidth(s) > w { s = ansi.Truncate(s, w, g.more) } + // WHAT IT SPENT ENDS THE LINE, in the status line's own spelling, and + // GIVES WAY WHOLE to the state words when both do not fit with two cells + // between them: what the conversation is doing is the line's reason. + if t.spent > 0 { + money := dollars(t.spent) + sw, mw := ansi.StringWidth(s), ansi.StringWidth(money) + gap := 2 + if sw == 0 { + gap = 0 + } + if sw+gap+mw <= w { + s += strings.Repeat(" ", w-sw-mw) + pal.dim(money) + } + } return s } diff --git a/internal/tui3/wallwake_test.go b/internal/tui3/wallwake_test.go new file mode 100644 index 000000000..bcd13c261 --- /dev/null +++ b/internal/tui3/wallwake_test.go @@ -0,0 +1,85 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// managerWake is the note the session wakes a manager with, whole, as a +// transcript holds it when it carries no team lines. +const managerWake = "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands.\n\nWhat your members in \"harbor\" did:\n@api finished" + +// memberWake is the note the session wakes a member with, carrying the +// manager's directive as a delivery. +const memberWake = "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\nTeam traffic in \"harbor\" (@api), from your manager:\ndirective from manager: check the parser" + +func wallTileText(a *app, entries []session.DisplayEntry) string { + return ansi.Strip(strings.Join(a.wallDraw(entries, 60), "\n")) +} + +// Contract 3.1 and 3.2: a tile never draws a wake note's sentence. A manager's +// wake with no team lines draws nothing; the person's words and the reply stay. +func TestATileNeverDrawsAManagersWakeSentence(t *testing.T) { + a, _, _ := tabApp(t) + got := wallTileText(a, []session.DisplayEntry{ + {Role: "user", Text: "sort out the parser"}, + {Role: "aside", Text: managerWake}, + {Role: "assistant", Text: "api is done; nothing else is pending."}, + }) + if strings.Contains(got, "started this turn") || strings.Contains(got, "did not speak") { + t.Fatalf("the tile drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "sort out the parser") || !strings.Contains(got, "nothing else is pending") { + t.Fatalf("the tile lost the person's words or the reply:\n%s", got) + } +} + +// Contract 3.2: a member's wake that carries its manager's directive draws the +// directive, as the conversation's card does, and not the sentence above it. +func TestATileDrawsAMembersWakeAsItsDirective(t *testing.T) { + a, _, _ := tabApp(t) + lines := []session.TeamLine{{Team: "harbor", From: "manager", Kind: "directive", Text: "check the parser"}} + got := wallTileText(a, []session.DisplayEntry{{Role: "aside", Text: memberWake, Team: lines}}) + if strings.Contains(got, "started this turn") { + t.Fatalf("the tile drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "check the parser") || !strings.Contains(got, "manager") { + t.Fatalf("the tile did not draw the manager's directive:\n%s", got) + } +} + +// Contract 3.4: every other session aside draws as it did, its first line. +func TestATileStillDrawsATaskLanding(t *testing.T) { + a, _, _ := tabApp(t) + got := wallTileText(a, []session.DisplayEntry{{Role: "aside", Text: "Task 3 finished: the parser is fixed.\nmore"}}) + if !strings.Contains(got, "Task 3 finished") { + t.Fatalf("the tile lost a task landing:\n%s", got) + } +} + +// Contract 3.3: a reopened conversation draws a manager's wake note the way the +// live one does: not at all. A task landing still draws its line. +func TestAReopenedConversationDoesNotDrawAManagersWakeSentence(t *testing.T) { + a, _, _ := tabApp(t) + blocks, _ := a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: "sort out the parser"}, + {Role: "aside", Text: managerWake}, + {Role: "assistant", Text: "api is done."}, + {Role: "aside", Text: "Task 3 finished: the parser is fixed."}, + }, chatReplay(0)) + var all []string + for _, b := range blocks { + all = append(all, b.text) + } + got := strings.Join(all, "\n") + if strings.Contains(got, "started this turn") { + t.Fatalf("the reopened conversation drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "Task 3 finished") { + t.Fatalf("the reopened conversation lost a task landing:\n%s", got) + } +}