diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go
index ff77b5ba5f..471ccd1e65 100644
--- a/cmd/codeaf/chatv3.go
+++ b/cmd/codeaf/chatv3.go
@@ -2037,6 +2037,13 @@ func v3BuiltinApprovals() map[string]any {
"manual": "allow", "settings": "allow",
"team_status": "allow", "team_read": "allow", "team_send": "allow",
"team_stop": "allow", "team_post": "allow",
+ // A manager deciding or sending up a packet waiting on it, and bringing
+ // the person its closing report: each is a line in the team's own
+ // record, and none spends or starts anything.
+ "team_decide": "allow", "team_escalate": "allow", "team_close_report": "allow",
+ // A conflict raised to the manager above the parties: a packet and a
+ // line of Traffic, and nothing spent or started.
+ "team_raise": "allow",
}
}
diff --git a/cmd/codeaf/chatv3_approvalfloor_test.go b/cmd/codeaf/chatv3_approvalfloor_test.go
index 415443fd73..abcfc4dced 100644
--- a/cmd/codeaf/chatv3_approvalfloor_test.go
+++ b/cmd/codeaf/chatv3_approvalfloor_test.go
@@ -215,7 +215,7 @@ func TestTheFloorDoesNotWidenAnySafetyFloor(t *testing.T) {
func TestTheTeamVerbsAreAllowedAndTeamStartAsks(t *testing.T) {
dir := v3Profile(t, map[string]any{"tools.approvalMode": "prompt"})
policy := gateOf(t, dir)
- for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post"} {
+ for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise"} {
wantAction(t, policy, tool, `{"to":"web","text":"x","handle":"web"}`, approval.ActionAllow)
}
wantAction(t, policy, "team_start", `{"handle":"docs","brief":"write the README"}`, approval.ActionPrompt)
diff --git a/cmd/codeaf/chatv3_host_teams.go b/cmd/codeaf/chatv3_host_teams.go
index 3fab4b55fc..2905e76b41 100644
--- a/cmd/codeaf/chatv3_host_teams.go
+++ b/cmd/codeaf/chatv3_host_teams.go
@@ -33,6 +33,23 @@ type hostTeams struct {
write func(base string, teams []teamstore.Team) (remote.TeamsReading, error)
traffic func(team, after string, limit int) (remote.TeamsTraffic, error)
+ // The delegation doors, nil when the engine does not answer them
+ // ([remote.Welcome.Delegation]); the seam then hands the surface none.
+ defaults func() (teamstore.Defaults, error)
+ // applyDefault writes one `teams.` row ([remote.Welcome.TeamSettings]).
+ // Nil leaves the settings Teams tab read-only.
+ applyDefault func(key, raw string) (teamstore.Defaults, error)
+ packets func(scope, stamp string) (remote.PacketsReading, error)
+ raise func(p teamstore.Packet) (teamstore.Packet, error)
+ decide func(id, by, decision, reason string) (teamstore.Packet, error)
+ escalate func(id, by, to, reason string) (teamstore.Packet, error)
+ spend func(team, day, stamp string) (remote.SpendReading, error)
+ deleteOne func(team string) (remote.DeleteTeamReply, error)
+ // The wrap-up's two doors, nil when the engine does not answer them
+ // ([remote.Welcome.WrapUp]).
+ wrapUp func(team, text string) error
+ acceptClosing func(id string) (remote.AcceptClosingReply, error)
+
mu sync.Mutex
// teams and stamp are the last list the engine answered with and the
// file's stamp it was at; known says there has been one.
@@ -51,12 +68,24 @@ const hostTeamsTries = 3
// errHostTeamsBusy is a write that met another writer on every try.
var errHostTeamsBusy = errors.New("the teams on the far machine kept changing while this was written; try again")
-func newHostTeams(far hostFar) *hostTeams {
- return &hostTeams{
+func newHostTeams(far hostFar, delegation, wrapUp, settings bool) *hostTeams {
+ h := &hostTeams{
read: far.client.TeamsRead,
write: far.client.TeamsUpdate,
traffic: far.client.TeamsTraffic,
}
+ if delegation {
+ c := far.client
+ h.defaults, h.packets, h.raise = c.TeamsDefaults, c.TeamsPackets, c.TeamsRaise
+ h.decide, h.escalate, h.spend, h.deleteOne = c.TeamsDecide, c.TeamsEscalate, c.TeamsSpend, c.TeamsDelete
+ if wrapUp {
+ h.wrapUp, h.acceptClosing = c.TeamsWrapUp, c.TeamsAcceptClosing
+ }
+ }
+ if settings && far.client != nil {
+ h.applyDefault = far.client.TeamsApplyDefault
+ }
+ return h
}
// hostTeamsSeam is the seam the --host door hands the surface: the far
@@ -67,12 +96,80 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam {
if far.client == nil || !welcome.Teams {
return tui3.TeamsSeam{}
}
- return newHostTeams(far).seam()
+ return newHostTeams(far, welcome.Delegation, welcome.WrapUp, welcome.TeamSettings).seam()
}
-// seam is h as the surface's functions.
+// seam is h as the surface's functions. The delegation doors are handed only
+// when the engine answers them, so an older engine's window has a seam whose
+// delegation doors are nil, which the surface says rather than guesses at.
func (h *hostTeams) seam() tui3.TeamsSeam {
- return tui3.TeamsSeam{Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic}
+ s := tui3.TeamsSeam{Load: h.load, ReadSince: h.readSince, Update: h.update, Traffic: h.readTraffic}
+ if h.applyDefault != nil {
+ s.ApplyDefault = h.applyDefault
+ }
+ if h.defaults == nil {
+ return s
+ }
+ s.Defaults, s.Raise, s.Decide, s.Escalate = h.defaults, h.raise, h.decide, h.escalate
+ s.Packets, s.Spend, s.Delete = h.readPackets, h.readSpend, h.forget
+ if h.wrapUp != nil && h.acceptClosing != nil {
+ s.WrapUp, s.AcceptClosing = h.wrapUp, h.closeOnReport
+ }
+ return s
+}
+
+// closeOnReport is [tui3.TeamsSeam.AcceptClosing]: the engine closes the team
+// on its report, and the list held here is read again, because the file moved.
+func (h *hostTeams) closeOnReport(id string) (bool, error) {
+ reply, err := h.acceptClosing(id)
+ if err != nil {
+ return false, err
+ }
+ if reply.Closed {
+ if _, _, _, err := h.readSince("", h.reservedHues()); err != nil {
+ return true, err
+ }
+ }
+ return reply.Closed, nil
+}
+
+// readPackets is [tui3.TeamsSeam.Packets]: one round trip, a few bytes when
+// the engine's packet files have not moved.
+func (h *hostTeams) readPackets(scope, since string) ([]teamstore.Packet, string, bool, error) {
+ got, err := h.packets(scope, since)
+ if err != nil {
+ return nil, "", false, err
+ }
+ return got.Packets, got.Stamp, got.Same, nil
+}
+
+// readSpend is [tui3.TeamsSeam.Spend]: one round trip, a few bytes when
+// neither the engine's teams file nor its ledger moved.
+func (h *hostTeams) readSpend(team, day, since string) (teamstore.Spend, string, bool, error) {
+ got, err := h.spend(team, day, since)
+ if err != nil {
+ return teamstore.Spend{}, "", false, err
+ }
+ if got.Same {
+ return teamstore.Spend{}, got.Stamp, true, nil
+ }
+ if got.Spend == nil {
+ return teamstore.Spend{Team: team, Day: day}, got.Stamp, false, nil
+ }
+ return *got.Spend, got.Stamp, false, nil
+}
+
+// forget is [tui3.TeamsSeam.Delete]: the engine forgets the team and its
+// files, and the list held here is read again, because the file moved.
+func (h *hostTeams) forget(team string) ([]string, error) {
+ reply, err := h.deleteOne(team)
+ if err != nil {
+ return nil, err
+ }
+ if _, _, _, err := h.readSince("", h.reservedHues()); err != nil {
+ return reply.Gone, err
+ }
+ return reply.Gone, nil
}
// load is [tui3.TeamsSeam.Load]: what is held, now, and never the wire.
diff --git a/cmd/codeaf/chatv3_host_teams_test.go b/cmd/codeaf/chatv3_host_teams_test.go
index ff069256a2..611318ef31 100644
--- a/cmd/codeaf/chatv3_host_teams_test.go
+++ b/cmd/codeaf/chatv3_host_teams_test.go
@@ -105,3 +105,119 @@ func TestAnOlderEngineGetsNoTeamsSeam(t *testing.T) {
t.Fatal("no connection got a teams seam")
}
}
+
+// THE DELEGATION DOORS CROSS --host, AND AN ENGINE WITHOUT THEM GETS A SEAM
+// WITHOUT THEM. This build's engine says Delegation: the seam carries every
+// door, and a packet raised and a decision made through it land in the
+// ENGINE's profile, not this machine's. An engine that has the teams doors
+// and not these (Teams true, Delegation false) gets the teams seam with the
+// delegation doors nil, which the surface reads as "not over this
+// connection"; it is never handed doors onto this laptop's files.
+func TestTheDelegationDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) {
+ far := t.TempDir()
+ loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
+ return &remote.Engine{Agent: &quietAgent{}, ProfileDir: far}, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = loop.Close() })
+ if err := teamstore.Save(far, []teamstore.Team{{ID: "0a0a0a0a0a0a", Name: "harbor", Manager: "hm",
+ Members: []teamstore.Member{{Key: "hm", Handle: "boss"}, {Key: "k1", Handle: "web"}}}}); err != nil {
+ t.Fatal(err)
+ }
+ welcome := loop.Client.Welcome()
+ seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if seam.Defaults == nil || seam.Packets == nil || seam.Raise == nil || seam.Decide == nil ||
+ seam.Escalate == nil || seam.Spend == nil || seam.Delete == nil {
+ t.Fatal("an engine with the delegation doors got a seam without them")
+ }
+ p, err := seam.Raise(teamstore.Packet{Team: "0a0a0a0a0a0a", Kind: teamstore.PacketQuestion,
+ RaisedBy: "web", Question: "which port?"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ open, stamp, same, err := seam.Packets("0a0a0a0a0a0a", "")
+ if err != nil || same || len(open) != 1 || open[0].ID != p.ID {
+ t.Fatalf("packets over the seam: %+v %v %v", open, same, err)
+ }
+ if _, _, same, _ := seam.Packets("0a0a0a0a0a0a", stamp); !same {
+ t.Fatal("a quiet packet read over the seam was not same")
+ }
+ if _, err := seam.Decide(p.ID, "boss", "8080", "the default"); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := teamstore.PacketByID(far, p.ID); err != nil || got.Decision != "8080" {
+ t.Fatalf("the decision is not in the engine's profile: %+v %v", got, err)
+ }
+
+ welcome.Delegation = false
+ older := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if older.Load == nil || older.Update == nil {
+ t.Fatal("an engine with the teams doors and not the delegation doors lost its teams seam")
+ }
+ if older.Defaults != nil || older.Packets != nil || older.Raise != nil || older.Decide != nil ||
+ older.Escalate != nil || older.Spend != nil || older.Delete != nil {
+ t.Fatal("an engine without the delegation doors was handed them")
+ }
+}
+
+// THE WRAP-UP'S TWO DOORS CROSS --host WHEN THE ENGINE SAYS SO (DESIGN.md
+// 8.8): `Wrap up first` lands the one request line in the ENGINE's Traffic,
+// and accepting a closing report closes the team in the engine's profile. An
+// engine that does not say WrapUp hands no such doors, and the close card
+// offers `Close now` only.
+func TestTheWrapUpDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) {
+ far := t.TempDir()
+ loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
+ return &remote.Engine{Agent: &quietAgent{}, ProfileDir: far}, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = loop.Close() })
+ const harbor = "0a0a0a0a0a0a"
+ if err := teamstore.Save(far, []teamstore.Team{{ID: harbor, Name: "harbor", Manager: "hm",
+ Members: []teamstore.Member{{Key: "hm", Handle: "boss"}, {Key: "k1", Handle: "web"}}}}); err != nil {
+ t.Fatal(err)
+ }
+ welcome := loop.Client.Welcome()
+ seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if seam.WrapUp == nil || seam.AcceptClosing == nil {
+ t.Fatal("an engine with the wrap-up doors got a seam without them")
+ }
+ if err := seam.WrapUp(harbor, ""); err != nil {
+ t.Fatal(err)
+ }
+ log, err := teamstore.ReadTraffic(far, harbor, "", 10)
+ if err != nil || len(log) != 1 || !teamstore.IsWrapUp(log[0]) {
+ t.Fatalf("the engine's Traffic after a wrap-up: %+v %v", log, err)
+ }
+ p, err := seam.Raise(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"},
+ {ID: teamstore.OptionKeepGoing, Label: "Keep going", Consequence: "the team goes on"}},
+ Report: &teamstore.ClosingReport{Done: "the parser"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := seam.Decide(p.ID, teamstore.Person, teamstore.OptionClose, ""); err != nil {
+ t.Fatal(err)
+ }
+ if closed, err := seam.AcceptClosing(p.ID); err != nil || !closed {
+ t.Fatalf("accepting the report over --host: %v %v", closed, err)
+ }
+ f, err := teamstore.Load(far)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, ok := f.Team(harbor); !ok || !got.Closed() {
+ t.Fatalf("the engine's harbor is not closed: %+v", got)
+ }
+
+ welcome.WrapUp = false
+ older := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if older.Decide == nil || older.WrapUp != nil || older.AcceptClosing != nil {
+ t.Fatal("an engine without the wrap-up doors was handed them, or lost the others")
+ }
+}
diff --git a/cmd/codeaf/host_team_settings_test.go b/cmd/codeaf/host_team_settings_test.go
new file mode 100644
index 0000000000..6eb9818b29
--- /dev/null
+++ b/cmd/codeaf/host_team_settings_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/remote"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// THE SETTINGS WRITE CROSSES WHEN THE ENGINE SAYS SO. An edit through the
+// seam lands in the engine's profile. An engine that does not say
+// TeamSettings keeps the door nil, which is the Teams tab staying read-only.
+func TestHostTeamsCarriesASettingsWriteOnlyWhenTheEngineSaysSo(t *testing.T) {
+ far := t.TempDir()
+ loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) {
+ return &remote.Engine{Agent: &quietAgent{}, ProfileDir: far}, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = loop.Close() })
+ welcome := loop.Client.Welcome()
+ seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if seam.ApplyDefault == nil || seam.Defaults == nil {
+ t.Fatal("this engine's seam cannot read or change team defaults")
+ }
+ d, err := seam.ApplyDefault("teams.cap_usd_day", "6")
+ if err != nil || d.CapUSDDay != 6 {
+ t.Fatalf("apply: %+v, %v", d, err)
+ }
+ if teamstore.DefaultsAt(far).CapUSDDay != 6 {
+ t.Fatal("the engine profile did not take the cap")
+ }
+ welcome.TeamSettings = false
+ older := hostTeamsSeam(hostFar{client: loop.Client}, welcome)
+ if older.ApplyDefault != nil {
+ t.Fatal("an older engine was handed the settings write")
+ }
+ if older.Defaults == nil {
+ t.Fatal("an engine with the delegation doors lost the read")
+ }
+}
diff --git a/cmd/codeaf/team_conflict_test.go b/cmd/codeaf/team_conflict_test.go
new file mode 100644
index 0000000000..de3bea3671
--- /dev/null
+++ b/cmd/codeaf/team_conflict_test.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/remote"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// A CONFLICT BETWEEN MEMBERS OF TWO SIBLING SUB-TEAMS LANDS AT THEIR COMMON
+// PARENT'S MANAGER, WHICH RULES, AND BOTH PARTIES RECEIVE THE RULING, end to
+// end through the engine's own [bootEngine], with nothing set that a person
+// does not set. Three conversations run on the engine: @web in the sub-team
+// front, @api in its sibling back, and @boss, the manager of harbor, which
+// both sit under (each sub-team's own manager is a conversation nobody has
+// open). The model endpoint is scripted to do what each would: @web raises the
+// conflict with `team_raise`; @boss, woken by the packet, rules on it with
+// `team_decide`. Nobody types to @boss or @api, and the person is never
+// asked: both parties are woken by the ruling, marked as one.
+func TestTeamConflictBetweenSiblingSubTeamsIsRuledByTheirCommonManager(t *testing.T) {
+ root := resolvedTempDir(t)
+ _, workspace := wakeEnvironment(t, root)
+ script := newConflictModel(t)
+ t.Setenv("CODEAF_BASE_URL", script.server.URL)
+
+ boot := func() (*remote.Engine, string) {
+ t.Helper()
+ engine, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace, New: true}, "", "")
+ if err != nil {
+ t.Fatalf("the engine door did not open: %v", err)
+ }
+ t.Cleanup(func() { _ = engine.Agent.Close() })
+ file := strings.TrimSpace(engine.SessionFile)
+ if file == "" {
+ t.Fatal("the engine named no transcript")
+ }
+ return engine, file
+ }
+ web, webFile := boot()
+ _, apiFile := boot()
+ _, bossFile := boot()
+ if webFile == apiFile || apiFile == bossFile {
+ t.Fatal("the engine handed two hellos one conversation")
+ }
+
+ lead := filepath.Join(root, "elsewhere", "lead.jsonl")
+ chief := filepath.Join(root, "elsewhere", "chief.jsonl")
+ harbor, front, back := teams.NewID(), teams.NewID(), teams.NewID()
+ member := func(file, word, handle string) teams.Member {
+ return teams.Member{Key: filepath.Clean(file), File: file, Where: workspace, Word: word, Handle: handle}
+ }
+ err := teams.Update("", func(f *teams.File) error {
+ f.Teams = append(f.Teams,
+ teams.Team{ID: harbor, Name: "harbor"},
+ teams.Team{ID: front, Name: "front", Parent: harbor},
+ teams.Team{ID: back, Name: "back", Parent: harbor})
+ for _, add := range []struct {
+ team string
+ members []teams.Member
+ manager string
+ }{
+ {harbor, []teams.Member{member(bossFile, "harbor manager", "boss"), member(lead, "front lead", "lead"), member(chief, "back chief", "chief")}, bossFile},
+ {front, []teams.Member{member(lead, "front lead", "lead"), member(webFile, "web frontend", "web")}, lead},
+ {back, []teams.Member{member(chief, "back chief", "chief"), member(apiFile, "signup api", "api")}, chief},
+ } {
+ for _, m := range add.members {
+ if err := f.AddMember(add.team, m); err != nil {
+ return err
+ }
+ }
+ if err := f.SetManager(add.team, filepath.Clean(add.manager)); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("make the teams: %v", err)
+ }
+
+ events, err := web.Agent.Submit(t.Context(), "RAISE the conflict over the signup form")
+ if err != nil {
+ t.Fatalf("the turn did not start: %v", err)
+ }
+ for range events {
+ }
+
+ waiting, _, err := teams.OpenPackets("", harbor)
+ if err != nil || len(waiting) != 1 {
+ t.Fatalf("no conflict waits on harbor's manager: %+v %v\nthe model saw:\n%s", waiting, err, script.last())
+ }
+ p := waiting[0]
+ if p.Kind != teams.PacketConflict || p.Origin != front || len(p.Parties) != 2 || p.Parties[1].Team != back {
+ t.Fatalf("the packet: %+v", p)
+ }
+ if mine, _, _ := teams.OpenPackets("", teams.Person); len(mine) != 0 {
+ t.Fatalf("the conflict reached the person: %+v", mine)
+ }
+
+ // @boss is woken by the packet and rules; the script decides option 1.
+ deadline := time.Now().Add(40 * time.Second)
+ for {
+ got, err := teams.PacketByID("", p.ID)
+ if err == nil && got.State == teams.PacketDecided {
+ if got.DecidedBy != "boss" || got.Decision != "1" {
+ t.Fatalf("decided %+v", got)
+ }
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("harbor's manager never ruled; the model last saw:\n%s", script.last())
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+
+ // Both parties are woken with the ruling, each in its own team.
+ script.waitForBoth(t, `Team traffic in "front" for you (@web)`, "◆ ruling on the conflict "+p.ID, 30*time.Second)
+ script.waitForBoth(t, `Team traffic in "back" for you (@api)`, "◆ ruling on the conflict "+p.ID, 30*time.Second)
+ script.waitForBoth(t, `Team traffic in "back" for you (@api)`, "JSON: the handler changes", time.Second)
+}
+
+// conflictModel answers @web's turn with `team_raise`, @boss's woken turn with
+// `team_decide` on the packet it was handed, and everything else with "ok".
+type conflictModel struct {
+ server *httptest.Server
+ mu sync.Mutex
+ seen []recordedRequest
+}
+
+var conflictPacketID = regexp.MustCompile(`◆ conflict (p[0-9a-f]{12}) from @web, waiting on you`)
+
+func newConflictModel(t *testing.T) *conflictModel {
+ t.Helper()
+ m := &conflictModel{}
+ raise := `{"question":"which shape does the signup form send?","parties":["back/@api"],"context":"the form posts JSON",` +
+ `"options":[{"label":"JSON","consequence":"the handler changes"},{"label":"form data","consequence":"the form changes"}],"recommend":"1","reason":"the other endpoints take JSON"}`
+ m.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if !strings.HasSuffix(request.URL.Path, "/chat/completions") {
+ http.Error(writer, "no catalog for a test", http.StatusServiceUnavailable)
+ return
+ }
+ raw, _ := io.ReadAll(request.Body)
+ var envelope recordedRequest
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ http.Error(writer, err.Error(), http.StatusBadRequest)
+ return
+ }
+ m.mu.Lock()
+ m.seen = append(m.seen, envelope)
+ m.mu.Unlock()
+ all := strings.Join(envelope.texts(), "\n")
+ switch {
+ case len(envelope.Tools) == 0:
+ writeText(writer, envelope.Stream, "ok")
+ case strings.Contains(all, "RAISE the conflict") && !strings.Contains(all, "Raised conflict") && !strings.Contains(all, "could not be raised"):
+ writeToolCall(writer, envelope.Stream, "r1", "team_raise", raise)
+ case conflictPacketID.MatchString(all) && !strings.Contains(all, "Decided p"):
+ id := conflictPacketID.FindStringSubmatch(all)[1]
+ writeToolCall(writer, envelope.Stream, "d1", "team_decide", `{"packet":"`+id+`","answer":"1","reason":"the other endpoints take JSON"}`)
+ default:
+ writeText(writer, envelope.Stream, "ok")
+ }
+ }))
+ t.Cleanup(m.server.Close)
+ return m
+}
+
+// waitForBoth waits until one request carried both a and b.
+func (m *conflictModel) waitForBoth(t *testing.T, a, b string, within time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for time.Now().Before(deadline) {
+ m.mu.Lock()
+ for _, request := range m.seen {
+ if request.messageContaining(a) != "" && request.messageContaining(b) != "" {
+ m.mu.Unlock()
+ return
+ }
+ }
+ m.mu.Unlock()
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatalf("no request carried both %q and %q; the last was:\n%s", a, b, m.last())
+}
+
+func (m *conflictModel) last() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if len(m.seen) == 0 {
+ return "(nothing)"
+ }
+ return m.seen[len(m.seen)-1].allText()
+}
diff --git a/cmd/codeaf/team_questions_test.go b/cmd/codeaf/team_questions_test.go
new file mode 100644
index 0000000000..08385be12b
--- /dev/null
+++ b/cmd/codeaf/team_questions_test.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/remote"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// A MEMBER'S QUESTION GOES UP TO ITS MANAGER AND THE ANSWER COMES BACK, on the
+// ordinary launch and on the engine a window over --host talks to: the
+// engine's own [bootEngine] builds the member with nothing set that a person
+// does not set, the team is written where the ordinary launch keeps it, and the
+// model endpoint is scripted to do what a member does, load the questions
+// group and `ask`. Nothing reaches the person: the question is a packet for the
+// manager, the member is told so, and when the manager decides it the member
+// is woken with the answer marked `◆ answered`.
+func TestTeamQuestionsUpTheOrdinaryLaunchSendsAMembersQuestionToItsManager(t *testing.T) {
+ root := resolvedTempDir(t)
+ model, workspace := wakeEnvironment(t, root)
+ script := newScriptedModel(t)
+ t.Setenv("CODEAF_BASE_URL", script.server.URL)
+ _ = model
+
+ engine, err := bootEngine(remote.Hello{Version: remote.Version, Workspace: workspace}, "", "")
+ if err != nil {
+ t.Fatalf("the engine door did not open: %v", err)
+ }
+ defer func() { _ = engine.Agent.Close() }()
+ member := strings.TrimSpace(engine.SessionFile)
+ if member == "" {
+ t.Fatal("the engine named no transcript")
+ }
+ teamID := managedTeamWith(t, root, member, workspace)
+
+ events, err := engine.Agent.Submit(t.Context(), "build the signup form")
+ if err != nil {
+ t.Fatalf("the turn did not start: %v", err)
+ }
+ for range events {
+ }
+
+ waiting, _, err := teams.OpenPackets("", teamID)
+ if err != nil || len(waiting) != 1 {
+ t.Fatalf("no packet waits on the manager: %+v %v\nthe model saw:\n%s", waiting, err, script.transcript())
+ }
+ p := waiting[0]
+ if p.Kind != teams.PacketQuestion || p.RaisedBy != "web" || p.Question != "JSON or form data?" {
+ t.Fatalf("the packet: %+v", p)
+ }
+ if mine, _, _ := teams.OpenPackets("", teams.Person); len(mine) != 0 {
+ t.Fatalf("the member's question reached the person: %+v", mine)
+ }
+ script.waitFor(t, "went to your manager (@boss", 10*time.Second)
+
+ if _, err := teams.Decide("", p.ID, teams.FromManager, "1", "the other endpoints take JSON"); err != nil {
+ t.Fatalf("the manager could not decide: %v", err)
+ }
+ script.waitFor(t, "◆ answered: JSON (by ◆ @boss, because the other endpoints take JSON)", 20*time.Second)
+}
+
+// scriptedModel is an OpenAI-shaped endpoint that answers a member's first
+// turn with two tool calls (load the questions group, then `ask`) and every
+// other request with "ok", recording what it was sent.
+type scriptedModel struct {
+ server *httptest.Server
+ mu sync.Mutex
+ turn int
+ seen []recordedRequest
+}
+
+func newScriptedModel(t *testing.T) *scriptedModel {
+ t.Helper()
+ m := &scriptedModel{}
+ ask := `{"head":"JSON or form data?","kind":"clarification","reason":"the handler and the form disagree","stakes":"reversible",` +
+ `"options":[{"key":"1","label":"JSON","consequence":"the handler changes"},{"key":"2","label":"form data","consequence":"the form changes"}]}`
+ m.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ if !strings.HasSuffix(request.URL.Path, "/chat/completions") {
+ http.Error(writer, "no catalog for a test", http.StatusServiceUnavailable)
+ return
+ }
+ raw, _ := io.ReadAll(request.Body)
+ var envelope recordedRequest
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ http.Error(writer, err.Error(), http.StatusBadRequest)
+ return
+ }
+ m.mu.Lock()
+ m.seen = append(m.seen, envelope)
+ step := -1
+ if len(envelope.Tools) > 0 {
+ step = m.turn
+ m.turn++
+ }
+ m.mu.Unlock()
+ switch step {
+ case 0:
+ writeToolCall(writer, envelope.Stream, "c1", "load_capability", `{"group":"questions"}`)
+ case 1:
+ writeToolCall(writer, envelope.Stream, "c2", "ask", ask)
+ default:
+ writeText(writer, envelope.Stream, "ok")
+ }
+ }))
+ t.Cleanup(m.server.Close)
+ return m
+}
+
+func writeText(writer http.ResponseWriter, stream bool, text string) {
+ if stream {
+ writer.Header().Set("Content-Type", "text/event-stream")
+ _, _ = writer.Write([]byte(`data: {"id":"q","choices":[{"index":0,"delta":{"role":"assistant","content":` + strconv.Quote(text) + `},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
+ return
+ }
+ _, _ = writer.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":` + strconv.Quote(text) + `},"finish_reason":"stop"}]}`))
+}
+
+func writeToolCall(writer http.ResponseWriter, stream bool, id, name, arguments string) {
+ call := `{"index":0,"id":"` + id + `","type":"function","function":{"name":"` + name + `","arguments":` + strconv.Quote(arguments) + `}}`
+ if stream {
+ writer.Header().Set("Content-Type", "text/event-stream")
+ _, _ = writer.Write([]byte(`data: {"id":"q","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[` + call + `]},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"))
+ return
+ }
+ _, _ = writer.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[` + call + `]},"finish_reason":"tool_calls"}]}`))
+}
+
+// waitFor waits until some request carried a message holding want.
+func (m *scriptedModel) waitFor(t *testing.T, want string, within time.Duration) {
+ t.Helper()
+ deadline := time.Now().Add(within)
+ for time.Now().Before(deadline) {
+ m.mu.Lock()
+ for _, request := range m.seen {
+ if request.messageContaining(want) != "" {
+ m.mu.Unlock()
+ return
+ }
+ }
+ m.mu.Unlock()
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatalf("the model was never sent %q; it saw:\n%s", want, m.transcript())
+}
+
+func (m *scriptedModel) transcript() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if len(m.seen) == 0 {
+ return "(nothing)"
+ }
+ return m.seen[len(m.seen)-1].allText()
+}
diff --git a/docs/changes/unreleased/1429-chats-on-the-bar.md b/docs/changes/unreleased/1429-chats-on-the-bar.md
index 017bc0806e..58e257c00a 100644
--- a/docs/changes/unreleased/1429-chats-on-the-bar.md
+++ b/docs/changes/unreleased/1429-chats-on-the-bar.md
@@ -4,5 +4,5 @@ title: the places bar has a chats word that goes back to the conversation
pr: 1429
surface: [chat, docs]
invalidates:
- - "The places bar read `home tasks spend settings` and the digits were home 1, tasks 2, spend 3, settings 4, standing 5, memory 6, search 7. It reads `home chats tasks spend settings` and the digits follow it: chats 2, tasks 3, spend 4, settings 5, standing 6, memory 7, search 8. A click on `chats`, `alt+2` or `enter` on it returns to the conversation in front, or opens a new chat when none is open; `tab` steps over it."
+ - "The places bar read `home tasks spend settings` and the digits were home 1, tasks 2, spend 3, settings 4, standing 5, memory 6, search 7. It reads `home teams chats sessions spend settings` and the digits follow it: teams 2, chats 3, sessions 4, spend 5, settings 6, standing 7, memory 8, search 9. A click on `chats`, `alt+3` or `enter` on it returns to the conversation in front, or opens a new chat when none is open; `tab` steps over it."
---
diff --git a/docs/changes/unreleased/1494-one-side-column.md b/docs/changes/unreleased/1494-one-side-column.md
new file mode 100644
index 0000000000..7a1c65c73e
--- /dev/null
+++ b/docs/changes/unreleased/1494-one-side-column.md
@@ -0,0 +1,9 @@
+---
+kind: changed
+title: one side column holds Tasks and Traffic in every chat
+pr: 1494
+surface: [chat, docs]
+invalidates:
+ - "The right of a chat had a separate tasks rail and a Traffic pane. Both now sit in one side column under one header: a needs-you band, tasks grouped by state, and compact Traffic threads that read who each message is from and who it is for."
+ - "The chat tab strip was on every page. It is now drawn only inside a chat; the places (home, teams) moved onto the wordmark row."
+---
diff --git a/docs/changes/unreleased/1494-standing-card-words.md b/docs/changes/unreleased/1494-standing-card-words.md
new file mode 100644
index 0000000000..1521892b31
--- /dev/null
+++ b/docs/changes/unreleased/1494-standing-card-words.md
@@ -0,0 +1,9 @@
+---
+kind: changed
+title: a standing card names its kind, and each button says what it will do
+pr: 1494
+surface: [chat, engine]
+invalidates:
+ - "A proposal card offered a bare just-once choice that read like a confirmation of the reminder. The card now says whether it is a reminder, a repeating check, a watch or a rule, and each button names its outcome, for example `Set it up · every 3 hours` or `Only now, don't repeat`."
+ - "The completion check did not see the person's card answer and could keep asking for the repeating version after the person chose only now. The answer is now on the page the check reads, and an only-now answer tells the model to do the step and stop."
+---
diff --git a/docs/changes/unreleased/1494-team-wrapup-durable.md b/docs/changes/unreleased/1494-team-wrapup-durable.md
new file mode 100644
index 0000000000..3ae233db9a
--- /dev/null
+++ b/docs/changes/unreleased/1494-team-wrapup-durable.md
@@ -0,0 +1,9 @@
+---
+kind: fixed
+title: a team's wrap-up survives a restart, and a cap packet is raised once
+pr: 1494
+surface: [engine]
+invalidates:
+ - "A restart lost a running wrap-up's start and bound. They are now kept in teams.json and resumed, and a busy decisions file leaves the wrap-up due instead of dropping it."
+ - "Two processes crossing a team cap each raised a packet. One packet is raised per crossing."
+---
diff --git a/docs/design/conversations-and-teams/DESIGN.md b/docs/design/conversations-and-teams/DESIGN.md
index 6435813db1..f10358e917 100644
--- a/docs/design/conversations-and-teams/DESIGN.md
+++ b/docs/design/conversations-and-teams/DESIGN.md
@@ -34,13 +34,17 @@ ones, and the owner's screen read `test 1` over three tabs. A team's whole membe
or not, belongs to the teams page on home (section 7).
**Doors in.** `alt+v`, `/wall`, the dock under the input box, and `▦ All` beside the tab
-strip's `+`. The dock is a one-row map of every open conversation coloured by state, with
-a dim word `chats` in front of the squares. A click on a square switches to that
-conversation without opening the wall. A click on `chats` or on `▦` opens the wall. The
+strip's `+`. The dock is a one-row map of every open conversation coloured by state, led
+by `▦ All`, the strip's own door spelled the same way and drawn as one button (one hover
+ground over the glyph and the word). It used to lead with a dim `chats`, which is the
+nav's word for the way back to the conversations: one word on one frame led two places,
+so the dock now says what it opens. A click on a square switches to that conversation
+without opening the wall. A click anywhere on `▦ All` opens the wall. The
pointer explains the piece it rests on: `Go to
· running · click` (or `waiting on
-you`, or `idle`), ` · you are here` on the square in front, and `All conversations
-· alt+v` on the word and on `▦`. The word is the first thing dropped when the row is too
-narrow. The squares stay, then fewer of them, then the dock is not drawn. Amber is only
+you`, or `idle`), ` · you are here` on the square in front, and `The grid of your
+open tabs, and your teams · alt+v` on both `▦ All` doors: with no team yet it is the only
+door on the strip that leads to making one. The word `All` is the first thing dropped when
+the row is too narrow. The squares stay, then fewer of them, then the dock is not drawn. Amber is only
the square that is waiting on you. The dock is laid out last on the keys row: the project
holds the row's right end (dev's foot since 2026-09-22) with the low-credit line beside it,
and the dock takes what the keys and they leave, ending a gap short of them.
@@ -69,7 +73,10 @@ toggles. The wheel scrolls the view by one tile row per notch, clamped, and neve
unless focus would leave the screen.
**Discovery.** Every control explains itself in the toolbar while hovered
-(`Add this conversation to teams · m`). `?` opens a sheet of everything the page can do,
+(`Add this conversation to teams · m`). The sentence outranks the buttons: while it shows,
+`Columns` and then the acts on the right step aside until it is whole (never the button under
+the pointer, never `Help`), and come back when the pointer leaves. Before, it was cut between
+`Back` and the buttons and read `Resume the …` at 80 columns and lost its key at 110. `?` opens a sheet of everything the page can do,
every row clickable. Next-needing-you is `n`.
**Sizes.** A tile is at least 44 columns and 12 rows; past that the grid scrolls rather than
@@ -119,30 +126,77 @@ things by the name they remember.
**Where teams show.** Dots before a tile's title; a segmented Teams row on the wall; when a
team is shown, a `● name ▾` chip on the tab strip and the rule under it in the team's
-hue. The strip reads `home ● name ▾ ◆ Manager tabs… + ▦ All`: home is a fixed door and
-stands first, the chip filters the tabs so it sits right before them, and the manager's place
-follows it; as the row narrows Home goes first, then the chip, never the tab in front (ruled
-2026-09-24). Showing a team narrows the strip to its members open in this window, plus the tab in
+hue. The strip reads `● name ▾ ◆ Manager tabs… + ▦ All`: the chip filters the tabs so it
+stands first, right before them, and the manager's place follows it; as the row narrows the
+chip goes, never the tab in front. The strip's `home` piece is gone: home is the first word of
+the nav on the row above (the top nav, below). Showing a team narrows the strip to its members open in this window, plus the tab in
front (section 1). The chip is the switcher: teams, All, add or remove this conversation, new team, team
settings. New conversations started while a team is shown join it.
-**The way back from the places.** The places bar reads `home teams chats tasks spend
-settings` (ruled 2026-09-24); until a teams place exists it is `home chats tasks spend
-settings`, and the list is data (`placeOrder` in `pages.go`) so teams takes its slot between
-home and chats. Digits follow the order, so `chats` is `alt+2` today and `alt+3` once teams
-lands. `chats` is not a room: a click, its digit or `enter` on it returns to the conversation
-in front, or opens a new chat when none is open. It never wears the current band, `tab` steps
-over it, and `alt+k` stays the switcher that chooses a conversation.
-
-**One top bar.** The places bar and the chat strip are one row (ruled 2026-09-24): row 1 under
-the top line, the first word at column 3, two cells of air between two items (four between two
-words, counting each chip's pad), the current item on the strip's front-tab ground, then the
-same rule. Measured before the change at 80, 110 and 160 columns: both on row 1; the strip's
-first word at column 3 with 2 cells between chips, the places' at column 2 with 1, and the
-current place on the ground the strip gives a tab that is not in front. The strip's geometry
-was kept and the places bar moved to it, because the strip is the row a person lives on, its
-grounded chips and close marks need the air to read as separate things, and five short words
-have the room. `TestOneTopBarPlacesAndChatsShareGeometry` pins the row, column and gap on both.
+**The way back from the places.** The places bar reads `home teams chats sessions spend
+settings` (the owner's order, ruled 2026-09-24; `sessions` is the tasks place's word), then
+standing, memory and search off the bar. The list is data (`placeOrder` in `pages.go`), and
+the digits follow it: teams `alt+2`, chats `alt+3`, sessions `alt+4`, spend `alt+5`, settings
+`alt+6`, standing `alt+7`, memory `alt+8`, search `alt+9`. `chats` is not a room: a click, its digit or `enter` on it returns to the conversation
+in front, or opens a new chat when none is open. It is the lit word inside a conversation, `tab`
+steps over it, and `alt+k` stays the switcher that chooses a conversation. Its hint says
+`every conversation, one at a time`, and `▦ All` says `The grid of your open tabs`: the place
+and the grid are two doors, one row apart, and they say so.
+
+**The top nav (ruled 2026-09-24, replacing "one top bar"; the strip ruled 2026-09-25).**
+The places sit on the wordmark's row on every page, and that row never moves. The strip
+is a chat's own row. On a place the head is three rows: the nav, the rule, a blank. In a
+chat it is four: the nav, the strip, the rule, a blank.
+
+```
+ >● codeaf home teams chats sessions spend settings 3 moving · $1.20 thu 10:31pm
+ ● harbor ▾ ◆ Manager × Refactor the rail… × openrouter price scrape × + ▦ All
+──────────────────────────────────────────────────────────────────────────────────────────
+```
+
+- Row 0 (`navRow`, topnav.go): one cell of inset, the wordmark, two cells of air, then the place
+ words as buttons that touch (each ` word ` with a one-cell pad either side, so two blank cells
+ between two words), at least two cells, the pulse, one cell of inset. The gaps never change
+ with the width. The current place is lit in the accent (`chats` in a conversation, on the wall
+ and on the work tab); the rest are muted; on a no-colour terminal the lit word wears brackets
+ in its pads. The hover ground covers the pads, the press target is exactly that ground, and a
+ terminal that cannot show a ground puts `·` in the leading pad. The hint line says
+ `alt+N word · what it opens`, read off each place's `about()`.
+- Row 1 (`tabStripRow`): the chat strip, only while a conversation is in front (a chat, a
+ room inside one, the grid, the work tab). On a place this row is the rule, and a click
+ there is the page's. There is no unlit strip and no place-only press path. The strip
+ keeps its own narrowing.
+- The strip's spacing (2026-09-25): every piece carries its own one-cell pad and ONE blank cell
+ separates any two pieces (chip and first tab, tab and tab, tab and `+`, `+` and `▦ All`, the
+ left arrow and what stands before it); the first ground is at column 2 with or without a
+ chip. A tab is symmetric: inset, status cell and pad before the name, pad, `×` cell and inset
+ after it (` ◐ name × `); the close target is the `×` and its inset, two cells, where it was
+ three with a blank of its own that made every tab one cell wider on the right. The reserved
+ status and `×` cells stay (no re-pack when work starts or the pointer arrives). Names are cut
+ at a word where one is near. The names shrink, down to 16 cells a tab, before the strip
+ scrolls; a scrolling strip draws both arrows, the one with nowhere to go dim and inert.
+ `▦ All` is always spelled with its word (no glyph-alone rung between 60 and 80 columns).
+ Every piece says what it does on the hint line: a tab says its dock square's sentence, `×`
+ `Close this tab · the work keeps running`, `+` `New chat · ctrl+t`, the arrows which way.
+ Where colour cannot show a ground the pointer's tab wears `·` in its leading inset, the
+ nav's rule.
+- The ladder on row 0, in the owner's order: the clock (and `on ` over `--host`), then
+ `moving`, then the words of `2 want you` shorten to `2 ?` (same count, same amber, the mark
+ a waiting tab wears), then the allowance, then the trailing places fold one at a time into
+ `more ▾`, then the day's figure. The needs-you count never folds: it was allowed to for one
+ wave, and at 80 columns a conversation's top line then carried no count of what was waiting
+ on the person, the one fact a glance at that row is for. At 80 the row is all six places
+ and `2 ? · $1.20 / $20`. The wordmark, the lit place, the
+ bar cursor's word and a counted place never fold. `more ▾` opens a small menu of exactly the
+ folded places with their keys (navmore.go): hover ground, enter or click goes, esc or a press
+ off it closes, and focus never moves anywhere else.
+- Measured on Spark after the change, with a conversation of three tabs in team harbor: at 80
+ columns all six places fit beside `$1.20 / $20`; at 60 `spend` and `settings` fold into
+ `more ▾` beside `$1.20`; at 110 the counts and the money fit and the clock does not; at 160
+ everything fits. The scroll frame's allocation count fell from 229 to 210 under the 230
+ ceiling, because the row is memoised whole (`navMemo`), pulse included.
+- `TestTheHeadIsTheSameFourRowsOnEveryPage`, `TestOneTopNavOnAChatAndOnAPlace` and
+ `TestTheTopLineGivesThingsUpInTheOwnersOrder` pin the rows, the cells and the order.
## 3. Organize: one button, a proposal, never a silent change
@@ -177,16 +231,11 @@ and removing it returns it to an ordinary chat with its history. What makes it s
- it is the team's first tab, pinned at the left like a browser's pinned tab (`◆ Manager`),
and the pinned first tile on the wall (`◆ Manager · `); until one exists, that tab is
a `+ Manager` placeholder, and nothing costs anything;
-- its layout is split: the person's conversation with the manager on the left, a **Traffic**
- rail on the right. Whatever the person types goes to the manager, always, and the composer
- says so (`to ◆ manager`, on the box's rule once there is text). Traffic is drawn as
- threads (below, **Traffic is threaded**). With the manager in front the
- right-hand column is the Traffic (ruled 2026-09-24): the task column is not drawn and not
- reserved whatever the saved `ctrl+g` answer says. When the manager has live tasks the
- header reads `Traffic · Tasks 2`, and Tasks (a press or `ctrl+g`) lays them in the same
- column at the same width, and back; with none there is no word. The rail
- is put away with `hide alt+l` to a `Traffic` edge that counts what arrived, and on a narrow
- window it is that edge and a card laid over the lower conversation;
+- its layout is split: the person's conversation with the manager on the left, the column
+ on the right open on its **Traffic** (below, **One side column**). Whatever the person types
+ goes to the manager, always, and the composer says so (`to ◆ manager`, on the box's rule
+ once there is text; on the teams page `to ◆ harbor manager`). Both read the team's name when
+ they are drawn, so a rename does not leave the old name in the box. Traffic is drawn as threads (below, **Traffic is threaded**);
- each turn it carries a small team digest (members, handles, states, questions waiting,
files touched, recent traffic), never whole transcripts;
- its messages reach members marked `◆ from manager`, never as if the person had typed them;
@@ -218,6 +267,7 @@ split read from write, because the approval gate keys on tool names:
| `team_stop` (ends a member's current turn, logged in Traffic) | manager | allow |
| `team_start` (a new member chat with a brief and handle) | manager | ask |
| `team_post` (to the room, a handle, or the manager) | members | allow |
+| `team_raise` (a conflict, to the lowest manager above every party; 8.9) | members and managers | allow |
`team_stop` exists because a message only lands at a turn boundary: a member stuck in a long
tool call reads nothing until it returns, and a directive is advice a model may misread. Stop
@@ -258,8 +308,12 @@ task link's own pass (`markdown.go`, `teamlink.go`): columns recorded on the row
resolved before the row's own answer, the hover held as (block, ordinal) with team
references numbered from their own offset. The hover is a ground; the hint line says
`Open @security · santosh dev2 branch… · click` (`Resume` when it is not open here). A press
-opens the member through the strip's door, resuming it first; a team's name opens the wall
-on that team. They are resolved from memory only; an `@word` that is no member's handle stays
+opens the member through the strip's door, resuming it first; a team's name opens the teams
+page with that team selected (the rail's cursor on its row, the pane showing it, the keyboard
+left on the row). A closed team is selected inside `Closed`, and that fold is opened. The
+hint is `Open harbor on the teams page · click`. Over `--host` against an engine without the
+teams doors (`teamsOff`) the page cannot open, so the press still opens the wall on that team
+and the hint says the conversations view. They are resolved from memory only; an `@word` that is no member's handle stays
text.
**Where it lives.** The manager is an ordinary session file. Traffic is
@@ -294,6 +348,68 @@ first read is asked off the loop). The welcome's `Teams` flag says an engine has
engine without it gets no seam, and the window turns teams and the manager off with the line
it has always said rather than reading the laptop's file, which the far session never sees.
+**One side column (ruled and built 2026-09-24).** The right of every conversation is one
+component (`tui3/sidecol.go`, `sidetraffic.go`), replacing the task column, the Traffic rail,
+the bordered Traffic card under 84 columns, and the `Traffic · Tasks 2` special case between
+them.
+
+- **The header is two words**, `Tasks 14 · Traffic 3 new`, with the hide key (`alt+l`,
+ `ctrl+g` its older name) at the right. A chat in no team has only `Tasks`. The word in front
+ is bold ink and the other dim; each is a door with a hover ground and a hint; the other word
+ keeps its count and says `N new` for Traffic that arrived behind it. A zero is dim.
+- **The view defaults by kind of chat**: Traffic in a manager's chat, Tasks in a member's and
+ everywhere else, and the person's choice is remembered per kind for the session. `←` `→`
+ switch words while the column holds the keyboard (`alt+t`); nothing here takes the focus.
+- **The needs-you band** is under the header, from both sources: a member's pending question
+ (its latest asking event, until anything later from it, from the person, or a wake), a
+ packet put to the person, and a task whose next step is the person's, all in the needs-you
+ amber with their own mark (`?` for a question); then a failure this window watched happen
+ and the person has not opened, its `✕` in ordinary ink. At most three rows and `+N more` (a
+ fourth item is drawn rather than counted); a rule under it; nothing at all when empty. An
+ item in the band is not drawn again below it; opening a failure acks it into Done.
+- **Tasks** are grouped by state, newest first in each: Running always open, Queued, Waiting
+ and Done folded to `Done 7 ▸` until pressed, and remembered open for the session. One line a
+ task: its state glyph, its name cut with `…`, its time in muted ink at the right (dropped
+ before it would cut a name that fits). What used to hang under a row (what it is doing,
+ `waits:`, merge word, cost, branch, the reason for a your-call) is the hint line's. The
+ family forest, its per-row disclosure and `view more` are gone; the margin under the list
+ (`+ /task`, standing, jobs, `ctrl+. earlier`) and a run's plan rows stay.
+- **Traffic in a manager's chat is work**: one row per thread (`teams.Threads`), and every row
+ reads `from → to words`. The manager is `◆`, several recipients are `@scrape +2`, and the
+ words follow (`◆ → @scrape +2 Please provide a st… ▸`). The state its answers leave it in
+ and its message count give way to the words when the column is narrow; the hint says them.
+ The age is never on the row, at any width: the hint line says it before its click, and the
+ band says it the same way, so the arrow and the names keep their cells. A narrow column cuts
+ only the words, at a word, with `…`. A band row is `? @model → ◆ keep the old schema?`,
+ amber, and a task row in the band is still cut at a clause, never leaving ` · …`. `▸`
+ lays the replies open as `↳ @model → ◆ ✓ done, 3 files changed` (events folded into the
+ member's line, the clock on the hint), and every unthreaded line under one `General` thread,
+ its open lines in the same `from → to`. A thin `new` line
+ marks what arrived since the Traffic was last in front and holds still while it is read. A
+ handle opens its member at the message; the rest of a row brings the message into view in
+ the chat in front. **In a member's chat** it is the messages to or from that member, or to
+ everyone, one line each, and that member is `you` (`◆ → you`, `you → ◆`, `you → @gravity`).
+- **Geometry.** The column is a quarter of the frame, 28 to 40 columns (30 at 120), from 100
+ columns up while the conversation keeps 56; `alt+w` adds 16 from 120 up. It is the same in
+ both views and every kind of chat, so switching, folding, the band and new rows never move
+ the conversation or the header. Under 100 columns there is no column and no edge; `alt+l` or
+ `alt+t` lay it over the body. Put away, it is an edge carrying the running or asking mark and,
+ in a team chat, the count of new Traffic.
+- **The frame reads memory**: tasks are the roster's nodes, Traffic the cache the Traffic clock
+ keeps, packets the teams page's last read; the Traffic rows and the band's asks are cached on
+ the log's tail, width, pointer, minute and what is laid open.
+
+**Where it departs, and why.** The owner's sketch showed the band only for needs-you; a
+failure is in it too, in ink, because a failure nobody opened was the one thing the old column
+kept at the top and dropping it would have hidden it under a folded `Done`. The column's width
+was the Traffic's three tenths and is a quarter now, because it is every chat's column and
+three tenths cost the conversation up to fourteen columns against the old task column; at 110
+columns a Traffic row's title is short, and its hint carries the rest. The margin under the
+task list stays, so a Tasks view with no tasks still shows `+ /task`.
+
+**Known gaps.** Packets reach the band only as fresh as the teams page's last read of them.
+Plan rows are spliced only into the Tasks view.
+
**Traffic is threaded (ruled and built 2026-09-24).** Measured on the owner's screen, one
question to three members was twelve rows at the bottom of an empty column: the question three
times, three wakes, three replies cut to two words, the manager's wake and two finishings, with
@@ -309,7 +425,8 @@ nothing saying which reply answered which question. So:
asking, the wake that started it, a failure to wake it) answer the same line. `team_send`'s
answer carries the number too. Entries written before this answer nothing and read as
threads of their own; the field travels in the entry's JSON, so `--host` needs nothing new.
-- **The rail is threads, the newest activity at the top**, straight under the header with no
+- **The rail was threads, the newest activity at the top** (superseded by **One side
+ column** above, where a thread is one row), straight under the header with no
space above; inside a thread everything is in the order it happened. A thread is a header
(`◆ manager → @agent @checking @review do 2m`, `+2` for handles that do not fit, never the
tag), the message on its own dim line, and the answers as a tree (`├ @checking ✓ Status
@@ -317,6 +434,16 @@ nothing saying which reply answered which question. So:
until it answers; a finishing is the `✓` on its answer or its own `✓ finished` line, a failure
`✗`, and a member asking the person is the one line in the needs-you amber. Stops, starts,
handle changes and unthreaded entries stay one line each.
+- **Delegation's entries are threads too** (section 8). A ruling (`teams.IsRuling`) heads its
+ own thread as `◆ manager ruling → @web` (or `you ruling`, the one entry of the person's the
+ rail draws), the ruling's words under it with the conflict's packet named in their hint; a
+ start that made a sub-team reads `◆ manager started @api to run backend`; a member's
+ clarifying question is a header tagged `asks`, and the manager's answer (a `KindAnswer`,
+ whose `Reply` stands in for `Answers`) is a line of the tree under it; a packet raised,
+ decided or answered (`codeaf answered @web: JSON`), a closing and a reopening are one line
+ each, like an event. `team_send`'s directive to everyone, when some members report to
+ another team's manager, is one entry to the several who report here. The teams page's
+ hosted manager draws this same rail and takes the same jump.
- **Handles are links there too**, inked and grounded as in the chat, hint `Open @x at this
message · title · click` (`Resume` when not open here), a press opening or resuming the member
scrolled to the message: a header's handle at the directive as delivered, an answer's handle at
@@ -328,6 +455,11 @@ nothing saying which reply answered which question. So:
chat's history` in the hint line. A message's words are a door: hover puts them whole in the
hint line, a press lays them out under the row and brings its thread card in the manager's
conversation into view, a second folds them. Rows are cached on the entries, width, pointer, minute and what is laid out.
+- **A manager's steps are captioned as team work.** The fold over a manager's turn read
+ `▾ team_send 1 call … 1 call`, the tool's own name and the count twice; the team tools now
+ gloss like the others: `messaging @scrape @model` (past, `messaged`), `posting to manager`,
+ `starting @lexer`, `stopping @web`, `reading @web`, `checking the team`, and a run of sends
+ `sending 3 messages`. The tool row under it keeps the tool's name, as every tool row does.
- **The manager's chat has the thread where it asked.** A `team_send` row reads
`team_send ◆ to @agent @checking @review · do`, the words quoted under it, and each member's
answer attached under that in muted ink as the Traffic cache brings it, one line each; the
@@ -383,12 +515,14 @@ the person to type again is not running a team, so the lines that ask for an ans
- **Visibility.** Every wake is a Traffic event, `◆ woke @web` and `@web woke ◆`, and every
refusal is one too. The rail draws a member's wake as `working…` in the thread it answers and
does not draw the manager's; a refusal is the member's line in that thread.
-- **Off switch.** A team's `wake` field in `teams.json`, on when absent and written only as
- `"wake": false`. With it off, a directive, a reply and a `team_start` start no turn: the
- new member is opened and reads its brief on the first turn something else starts, and the
- Traffic says `opened @handle; this team's auto-wake is off, so no turn was started.` The
- settings to turn it off from the interface come with the delegation work; until then it is
- the field and the manual's line.
+- **Off switch.** `wake` is one of the inheritable team settings (8.2): a team's own `wake`
+ in `teams.json` (a file from before it was inheritable wrote only `"wake": false`, which
+ reads unchanged as an override to off), else the nearest ancestor's, else the profile's
+ `teams.wake` (on). The settings tab's Teams group carries the default as its own row, `team
+ messages wake`. With it off, a directive, a reply and a
+ `team_start` start no turn: the new member is opened and reads its brief on the first turn
+ something else starts, and the Traffic says `opened @handle; this team's auto-wake is off, so
+ no turn was started.`
**Mentioning a team or a chat from the composer.** `@` is still the one list
(`internal/tui3`'s `files.go`, `mention.go`). Its first row is the words team, chat
@@ -409,7 +543,9 @@ move. Choosing a conversation keeps `@` and writes the handle, or `TaskSlug` of
title when the conversation has no handle. The row's note is the title.
After send, the same link pass inks those tokens on the person's own message
-(`mentionLinkPass`). A `●slug` opens the wall on that team. An `@handle` or `@slug`
+(`mentionLinkPass`). A `●slug` opens the teams page on that team, the same door a team's
+name uses, including the wall fallback over `--host` when the engine has no teams doors.
+An `@handle` or `@slug`
of a conversation this window can name, including one in no team, opens it through
the tab strip. Model prose keeps the older door: an `@handle` of a member of a team
this conversation is in, and a team name written as a team.
@@ -434,8 +570,9 @@ the Traffic's asking row and a note, not a question on the manager's tab. Over `
are off and say so. An unreadable teams file on the engine is not moved aside from a window over
`--host`; the window holds no teams until it can be read.
-**Not in v1.** Collision flags when two members touch the same files, nested managers (a sub-team's manager is a member
-of the parent team; reports flow up, directives down), and dispatch of whole plans.
+**Not in v1.** Collision flags when two members touch the same files, and dispatch of whole
+plans. (Nested managers, a sub-team's manager a member of the parent team with reports flowing
+up and directives down, were built later: 8.9.)
## 6. The laws this design leans on
@@ -445,6 +582,16 @@ of the parent team; reports flow up, directives down), and dispatch of whole pla
- One accent on the screen; state claims one step of the ladder each.
- An empty profile directory is the ordinary launch.
- A block's live edge settles through `livestate.go`; nothing else names a field `edge`.
+- An untitled conversation is called `new conversation` on home's sessions list and on
+ the sessions place, through one helper (`homeName`). It is never the session id.
+- A resize drops hover that pointed at a door the new layout may not draw (`more ▾`,
+ a nav word), so the hint line does not keep naming it.
+- A person's answer on a card this turn is part of what the completion check reads,
+ after the clipped account, as `the person answered the card "": `. A
+ `just once` answer reads `only now, don't repeat`, so the check does not treat
+ "nothing was set up" as work still owed. The tool result for that answer tells
+ the model to do the step now and report it, and not to set it up again or to
+ investigate codeaf.
## 7. Later, each needing its own go
@@ -453,3 +600,901 @@ of the parent team; reports flow up, directives down), and dispatch of whole pla
- A Teams place on home showing the tree and each team's whole membership, open or not;
nesting in the UI; drag a tile onto a team.
- The manager waking on events, collision flags, nested managers, dispatch.
+
+## 8. Delegation (ruled 2026-09-24; store and contract built, session and screens next)
+
+**The goal.** The person talks to the top manager and steps away. Work is handed down a tree
+of teams; questions and decisions travel up it; only what no manager may or can decide reaches
+the person, and it reaches them as a card they can answer without reading a transcript.
+
+This section is the contract two builders work from at once: the session side (the team
+tools, delivery, caps and wrap-up, in `internal/session`) and the interface side (the teams
+page, the settings tab, the cards, in `internal/tui3`). Both meet only in `internal/teams`
+and its Traffic, as section 5 already requires; everything named here exists at the
+foundation commit and is tested.
+
+### 8.1 The rulings, as built
+
+- **Home and links.** Every conversation that sits in a team with a manager somewhere above
+ it reports to exactly one manager, its **home**. The home is auto-picked, stored, never asked
+ and never moved by itself: nearest manager up the chain first, else the membership the
+ manager's `team_start` made, else the first managed team it joined. Every other manager it
+ can be reached by is a **link**: it may read the conversation and send it an fyi, nothing
+ else. The person can move the home (`Reports to ◆ harbor ▾`).
+- **Questions go up.** With `questions_up` on (the default), a member's clarifying question
+ goes to its home manager first, logged in Traffic; the manager answers it or sends it up.
+ Permission prompts never go up: they are the person's, always (section 5, "What it may not
+ do").
+- **Decision packets.** Everything that must be decided above the conversation that met it
+ travels as one self-contained packet (question, parties and each side's context, options
+ each with its consequence, a recommendation with its reason). The same packet is answered
+ by a manager or by the person.
+- **Conflicts go to the LCA of all n parties**, in one hop: the lowest open team above every
+ party that has a manager who is not one of them. No such team: the person. Parties declare
+ a conflict (`team_raise`); nothing detects one.
+- **Orders go one level down, reports one level up.** A manager directs its own team's members,
+ a sub-team's manager among them; it does not reach past them. Managers of different teams
+ talk only through the tree.
+- **The optional global manager is the manager of the root.** With several top-level teams
+ there is no top manager until the person makes one on the `All teams` row (8.2, "The root").
+- **Caps.** A team may have a daily cap; reaching it raises a cap packet to the person.
+- **Lifecycle (c-9).** A team is open or closed. Closing is a card (8.5); a closed team is
+ folded away, can be reopened, and only a closed team can be deleted.
+
+### 8.2 The store: the exact API
+
+Everything below is `internal/teams` unless named otherwise.
+
+**Per-team settings** (`teamsettings.go`). Five optional overrides, stored flat on the team in
+`teams.json`, each unset meaning inherit:
+
+| teams.json field | Go | Meaning | Band |
+|---|---|---|---|
+| `questions_up` | `*bool` | members' questions go to the home manager first | |
+| `cap_usd_day` | `*float64` | dollars per local day for the team and everything under it; `0` is an explicit no cap | `>= 0` |
+| `depth_limit` | `*int` | levels of teams, the top counting as one | 1 to 10 |
+| `sub_share` | `*float64` | fraction of this team's cap a new sub-team is made with | (0, 1] |
+| `wake` | `*bool` | team traffic wakes idle conversations (section 5); the old `"wake": false` reads as off | |
+
+- `type Settings struct{ QuestionsUp *bool; CapUSDDay *float64; DepthLimit *int; SubShare *float64; Wake *bool }`,
+ `Team.Settings`, `(Settings).Empty()`.
+- `(*File).SetSettings(id, func(*Settings)) error`: set a field to override, nil it to
+ reset; out of band is `ErrSetting` and nothing changes. tidy drops an out-of-band value in a
+ hand-edited file (it reads as inherit).
+- `type Defaults struct{ QuestionsUp bool; CapUSDDay float64; DepthLimit int; SubShare float64; Wake bool }`
+ and `DefaultsAt(profileDir) Defaults`, from config.json in one read.
+- `(*File).Effective(id, Defaults) Effective`: each value with its `Origin`
+ (`QuestionsUpFrom`, `CapFrom`, `DepthFrom`, `SubShareFrom`). `Origin{Kind, Team, Name}`,
+ Kind one of `OriginTeam`, `OriginAncestor`, `OriginSettings`, `OriginClosed`;
+ `(Origin).Inherited()`, `(Origin).Words()` = `""`, `from Settings`, `from harbor`,
+ `closed`. The walk skips closed ancestors; a closed team's own cap is none.
+- `(*File).Depth(id) int` (the root is 0, top level 1), `(*File).CanNest(parent, Defaults) bool`
+ (parent open and one more level inside its effective limit), `(*File).SubTeamCap(parent,
+ Defaults) float64` (parent's effective cap times its share, to the cent; 0 when the parent
+ has none). The session writes that figure on the new sub-team with `SetSettings`, so a later
+ change of the share moves no team that exists.
+
+**A cap is a pool.** A team's spend counts every team under it, so an inherited cap is the
+ancestor's one pool, shared, never a second allowance of the same size. `Effective.CapFrom.Team`
+names the pool's owner; when the cap comes from Settings, the owner is the top of the chain
+(the root when there is one). The spend drawn beside a cap is always the owner's.
+
+**Config keys** (`internal/config/teamdefaults.go`, flat dotted keys, category `teams`, one
+settings tab `Teams`, each a row with a named reader `TeamDefaultsAt` and a ledger line):
+
+| key | kind | default | registry label |
+|---|---|---|---|
+| `teams.questions_up` | on/off | on | questions go to the manager |
+| `teams.cap_usd_day` | dollars, `no cap` at 0 | 0 | team daily cap |
+| `teams.sub_share_pct` | whole %, 1 to 100 | 50 | sub-team share |
+| `teams.depth_limit` | levels, 1 to 10 | 3 | team depth |
+| `teams.wake` | on/off | on | team messages wake |
+
+All five are guarded from model self-service (`teams.wake` as pressure: a wake starts a turn
+nobody typed), the first four (`selfservice.go`): a manager is a model, and
+each is a rail on managers (money, pressure, consent).
+
+**Home and links** (`home.go`).
+
+- `Member.Home bool` (`home`): the one membership whose chain names the manager the
+ conversation reports to. `Member.Started bool` (`started`): the membership a manager's
+ `team_start` made; the interface sets it when it carries out a `KindStart`.
+- `(*File).Home(key) (Report, bool)`, `(*File).Links(key) []Report`,
+ `(*File).SetHome(key, teamID) error` (`ErrNoManagerAbove` for a chain with no manager).
+ `Report{Via, Team, Manager, Distance}`: the membership's team, the managed team, its
+ manager's key, levels between.
+- tidy (every load and write) gives every conversation with something to report to exactly
+ one flag and takes it from any other; a valid flag is never moved. The pick order: nearest
+ manager (own team before one a level up, then the deeper team), then `Started`, then file
+ order (which is join order at the first write that gives a conversation a manager).
+- A manager is never its own home: a sub-team's manager reports one level up.
+- The flag is stable; the manager at the end of its chain is whoever the person made manager
+ there. A conversation in an unmanaged sub-team reports to the manager above; when the
+ person gives that sub-team a manager, the same flag resolves to it. That is the person's
+ act, not a home changing by itself, and a sub-team manager nobody reported to would manage
+ nothing.
+
+**LCA** (`home.go`). `(*File).LCA(keys...) (Team, bool)`: the deepest open team at or above
+some membership of every key, with a manager who is not one of the keys (a party never judges
+its own case). false is the person. One key gives its nearest manager.
+
+**Lifecycle** (`lifecycle.go`).
+
+- `Team.State` (`state`: `TeamOpen` or `TeamClosed`; empty reads open), `Team.ClosedAt`
+ (`closed_at`), `Team.ClosedWith` (`closed_with`: the team whose close closed it),
+ `Team.Report` (`report`: the closing report's packet id). `(Team).Closed()`.
+- `(*File).Close(id, at, reportID) error`: closes the team and every open team under it;
+ a sub-team closed earlier on its own keeps its own close. Closing the root is `ErrRoot`.
+- `(*File).Reopen(id) error`: reopens the team and exactly the teams its own close closed;
+ under a closed parent it is `ErrParentClosed`.
+- `(*File).Open() []Team`, `(*File).ClosedTeams() []Team` (newest close first),
+ `(*File).Descendants(id) []Team`.
+- `Delete(profileDir, id) ([]string, error)`: only a closed team (else `ErrOpen`), with every
+ team under it; the file first, then `TeamDir(profileDir, id)` (Traffic and packets).
+ Conversations are never touched.
+- `Quiet(profileDir, f, now, idle) ([]string, error)`, `QuietAfter` (7 days): open teams with
+ no Traffic, packet or member-transcript activity for idle and no packet waiting on or raised
+ from them. Organize's input; it closes nothing.
+- A closed team is outside every walk: never a home, never found by `managerUp`, never an LCA,
+ skipped by `Effective`, no cap. A conversation whose home closes is given the next home on the
+ same write, or none (it is then an ordinary chat).
+
+**The root** (`root.go`). `Team.Root` (`root`), `RootName` (`All teams`),
+`(*File).Root() (Team, bool)`, `(*File).MakeRoot(at) string` (moves every top-level team under
+it; then `AddMember` and `SetManager` in the same write), `(*File).DissolveRoot()`. tidy keeps one
+open root at the top and puts any later top-level team under it. The root is not a level
+(`Depth`), cannot close or move (`ErrRoot`). Its override is what `from All teams` means.
+
+**Decision packets** (`decision.go`). One file per team, `/teams//decisions.jsonl`,
+append-only JSON events (`raise` with the whole packet, then `decide` and `escalate` naming it by
+id), folded by id. A packet lives in the file of the team it was raised from and never moves;
+escalating changes who decides and adds a hop. Writes are under the file's lock and re-check the
+fold under it; reads are stat-first and read only the bytes appended since the last read.
+
+```go
+type Packet struct {
+ ID string // "p" + 12 hex, minted by Raise
+ Team string // who decides now: a team id (its manager), or Person ("you")
+ Origin string // the team it was raised from; its file holds it
+ Kind string // question | conflict | cap | judgement | closing
+ RaisedBy string // raiser's handle, "manager", or "you"
+ Parties []Party // {Key, Handle, Team, Context}: each side in its own words
+ Question string
+ Options []Option // {ID, Label, Consequence}; ID defaults to "1", "2", ...
+ Recommendation *Recommendation // {Option, Reason}, optional
+ Report *ClosingReport // {Done, Left, Files, SpendUSD, Incomplete}, closing only
+ State string // open | decided | escalated (escalated still waits, at Team)
+ DecidedBy string // the deciding manager's handle, or "you"
+ Decision string // an option id, or the person's own words
+ Reason string // the decider's, or the last escalation's
+ Trail []Hop // {From, To, By, Reason, At}, oldest first
+ Raised, At time.Time
+}
+```
+
+- `Raise(profileDir, Packet) (Packet, error)`. Required: a kind, the question, the raiser, and
+ on every option a label and a consequence; a question may have no options, every other kind
+ needs one; a recommendation names an option and gives a reason. `Team` is found by the caller
+ (`LCA` for parties, `Home` for a member's question) and must be open; `Origin` defaults to
+ `Team` and must be `Team` or under it (`ErrSideways` otherwise).
+- `Decide(profileDir, id, by, decision, reason) (Packet, error)`. `by` is the handle of the
+ manager the packet waits on (or `"manager"`, recorded as the handle), or `Person`, who may
+ decide any packet. `ErrNotDecider`, `ErrDecided`, `ErrNoPacket`.
+- `Escalate(profileDir, id, by, to, reason) (Packet, error)`. `to` is an open team strictly
+ above the one it waits on, or `Person`; down, sideways, closed or past the person is
+ `ErrSideways`.
+- `OpenPackets(profileDir, scope) ([]Packet, stamp, error)`: waiting packets for a team id,
+ `Person`, or `ScopeAll` (`""`). `Packets(profileDir, teamID)`: a team's whole history,
+ decided included (the closed view and its reports). `PacketByID`. `PacketsStamp(profileDir)`:
+ one stamp over every packet file (a directory listing and a stat per team).
+ `DecisionsPath(profileDir, teamID)`.
+- Option ids the two sides act on: `OptionClose` (`close`), `OptionCloseNow` (`close-now`),
+ `OptionKeepGoing` (`keep-going`), `OptionRaiseCap` (`raise`), `OptionStopToday` (`stop`).
+- Every raise, decision and escalation also appends a `KindPacket` Traffic entry to the origin's
+ log and to every team asked to decide it, so a window tailing Traffic learns of it without
+ polling, and a manager's session is handed it by ordinary delivery.
+
+**Spend** (`spend.go`). The usage ledger (`/v3/usage.jsonl`, internal/session's
+`usage_ledger.go`) already records every call's local day, cost, conversation id (`session`)
+and, for work a conversation started, that conversation's id again (`root`). A member's id is
+its transcript folder's name. So:
+
+- `TeamSpend(profileDir, teamID, day) (Spend, error)`: the day's lines whose session or root
+ is a member of the team or any team under it, each line once, each conversation once.
+ `Spend{Team, Day, USD, Calls, ByMember map[key]float64}`. `TeamSpendIn(profileDir, ledger,
+ ...)` for tests. `Today()`, `UsageLedgerPath()`, `SpendStamp(profileDir, ledger)`,
+ `TeamSpendStamp(profileDir, teamID, day)`.
+- The ledger is folded once per process into totals by day and (session, root), then only
+ appended bytes are read; a quiet ledger costs a stat. `internal/session`'s
+ `TestTeamSpendReadsTheSessionsLedger` pins the path and field names.
+- A sub-team closed today still counts toward its parent's day: the money was spent.
+- Nothing here enforces a cap; the session does (8.8).
+
+**Traffic kinds added** (`traffic.go`):
+
+| Kind | From → To | Meaning |
+|---|---|---|
+| `question` | member handle → `manager` | a clarifying question to the home manager |
+| `answer` | `manager` → member handle | the answer; `Entry.Reply` is the question's entry id |
+| `packet` | `system` → `manager` | a packet was raised, decided or escalated; `Entry.Packet` is its id, `Entry.State` its new state |
+| `close` | `you`, `manager` or `system` → `everyone` | the team was closed |
+| `reopen` | `you` → `everyone` | the team was reopened |
+
+`ToYou` (`you`) is added as an address for the person.
+
+**Over `--host`** (`internal/remote`'s `wire_delegation.go`, `delegation.go`). Seven additive
+methods, answered from the engine's profile and ledger: `Teams.Defaults`, `Teams.Packets(scope,
+stamp)` (answers `same` in a few bytes), `Teams.Raise`, `Teams.Decide`, `Teams.Escalate`,
+`Teams.Spend(team, day, stamp)` (answers `same`), `Teams.Delete(team)`. `Welcome.Delegation`
+says the engine has them. Close, reopen, overrides, homes and the root are edits to the teams
+file and cross by `Teams.Update` like every other edit.
+
+**The seam** (`tui3.TeamsSeam`). Beside `Load`, `ReadSince`, `Update` and `Traffic`:
+`Defaults`, `Packets(scope, since)`, `Raise`, `Decide`, `Escalate`, `Spend(team, day, since)`,
+`Delete(team)`, every one asked off the loop. `localTeams` wires all of them to this profile;
+cmd/codeaf's `hostTeams` wires them to the wire only when the welcome says `Delegation`, and an
+engine with the teams doors and not these gets a seam whose delegation doors are nil
+(`TeamsSeam.delegation()` false). The interface then says the inbox and the spend are not
+available over that connection; it never reads the laptop's packet files. The frame law
+forbids every new store door in a frame (`framedisk_law_test.go`).
+
+### 8.3 What the session side builds on this (d1)
+
+- **Identity** is section 5's: a session's key is its transcript path; its home is
+ `Home(key)`; it manages team T where `T.Manager == key`.
+- **Questions up.** When `Effective(team).QuestionsUp` and `Home(key)` exists, a member's
+ clarifying question is a `KindQuestion` entry to `manager` in the home team's log. The manager
+ answers with `KindAnswer` (`Reply` set), delivered like any message. A manager that cannot
+ answer raises a `question` packet to its own home's team, or to `Person` when it has none
+ (the top manager's own questions reach the person). Permission prompts never take this road.
+- **Conflicts.** `team_raise` takes the parties' handles, finds `LCA(keys...)`, and raises a
+ `conflict` packet there (or to `Person`). The LCA manager is woken by the `packet` entry, may
+ read and ask the parties, rules with `Decide` and directives to every party (logged in each
+ involved team's Traffic), or `Escalate`s up. Never sideways.
+- **One level.** `team_send`, `team_stop` and `team_start` reach only the manager's own team's
+ members (a sub-team's manager is one). Links may send fyi notes (`KindNote`) and nothing else.
+- **Caps.** Before each model request of a member, the session compares
+ `TeamSpend(owner, Today())` with the pool owner's effective cap (`Effective.CapFrom.Team`, or
+ the top of the chain). Reached: it raises one `cap` packet to `Person` for that team and day
+ (not one per request; look for an open one first) with options `raise` (`Raise to $10`,
+ twice the cap) and `stop` (`Stop for today`) and a recommendation, and holds new member turns
+ in that pool until it is decided. Managers never raise a cap: money is the person's.
+- **Sub-teams** (nesting step): `CanNest(parent)` gates `team_start` of a team; the new team
+ gets `SubTeamCap(parent)` written as its own `cap_usd_day`, and its manager is a member of the
+ parent.
+- **Wrap-up** (c-9): when the person picks `Wrap up first`, the manager tells members to finish
+ and commit, answers what it can, and raises a `closing` packet to `Person` with a
+ `ClosingReport` and options `close` / `keep-going`, bounded by time and spend; out of bound, it
+ raises it with `Incomplete` and the option `close-now`. The interface closes the team only when
+ the person picks close.
+- **Closed** teams spend nothing: a session whose only teams closed takes no team turns.
+
+### 8.4 The interface (d2)
+
+The house rules hold everywhere below: every clickable thing grounds on hover; actions are
+word buttons with a dim hint line and a key; one accent on the screen; padding around every
+block; nothing moves the person's focus; every panel closes (`esc` and a word); everything is
+findable from `?`. Amber is used for one thing only: something needs the person (a packet
+waiting on them, a member's permission prompt). A packet waiting on a manager is not amber:
+somebody is on it.
+
+**The settings tab `Teams`** (built at the foundation commit, between `Tasks` and `Providers`).
+Four rows, in this order, each with the registry's hint:
+
+```
+ questions go to the manager on
+ daily cap per team no cap
+ sub-team share 50%
+ team depth 3 levels
+```
+
+The tab is titled `Teams` and is the one-to-one reading of the `teams` category, like
+Spending, Safety and Tasks. A line under the rows, dim: `a team can override any of these on
+its card`.
+
+**The teams page.** A main tab `teams`, right after `home` (the digits after it shift by one).
+
+- **Left rail: the tree.** One row per open team, indented by level, each with its colour dot,
+ its name, and at most one mark: `●` working (dim, a member is running), `◆ needs you`
+ (amber, a packet or a prompt waits on the person). Above the teams, the `All teams` row: with
+ no root team it is the whole list and carries `+ Manager` (the optional global manager; the
+ click makes the root, 8.2); with one it is the root team and selects like any team. Below
+ them: `+ New team` and `Organize`, word buttons. At the bottom, folded: `Closed · N`
+ (8.5). `↑↓` walks the rail, `enter` selects; the selection never moves focus out of the
+ composer on its own.
+- **Right pane: the selected team's REAL manager conversation** (the full chat of section 5:
+ typing steers it, its prompts are approved here), under a compact header:
+
+ ```
+ ◆ harbor @web ● running @api idle @docs ◆ asking $1.20 of $5 today Settings Open ▦
+ ```
+
+ members with their states (each clickable, opening the member), today's spend against the
+ effective cap (`$1.20 today` with no cap; the pool owner's figure with `· harbor's cap` when
+ the cap is inherited), `Settings` (the team card), `Open ▦` (the team on the wall). A member's
+ permission prompt shows as a needs-you row in the header with `Allow once` / `Always` /
+ `Deny`, the person's own gate (a manager never answers it).
+- **The inbox.** Above the conversation, the packets waiting on the person or on this team's
+ manager, one card each, newest last, folded to one line each beyond three. Hosted, the
+ cards also have a height: about a third of the frame (at least 8 rows); the newest, or the
+ one a press unfolded, is always whole, older ones stay whole while they fit, the rest fold
+ to `▸ kind · question waiting on you`. Measured at 110x34 with three packets before the
+ change: the cards took every pinned row and left the manager's conversation one row, the
+ Traffic edge cut to `T`. A card leads with whose it is: `?` in the needs-you amber when it
+ waits on the person, `◆` when it waits on a manager (every card led with `◆` before, which
+ read as the manager's own question):
+
+ ```
+ ? conflict · raised by @web waiting on you
+ which shape does the signup form send?
+ @web the form posts JSON
+ @api the endpoint takes form data
+ [ JSON ] @api changes the handler; the form stays recommended: matches the rest
+ [ form data ] @web rewrites the submit; the handler stays
+ [ Your own answer… ] [ Send up ▴ ]
+ ```
+
+ Options are word buttons with their consequence as the dim line; the recommended one says
+ so beside it; `Your own answer…` opens a one-line box (`enter` decides with the words);
+ `Send up ▴` escalates to the next manager up, or to the person (shown only when the viewer is
+ a manager's page and there is somewhere up to go). A packet waiting on a manager is shown
+ dim with `waiting on ◆ dock`, and the person may still decide it (authority: person first).
+ Decided packets leave the inbox and stay in the team's history.
+- **No manager:** the right pane is the members (states, open) and one `+ Manager` button with
+ the line `a manager takes your messages to the team and asks you only what it cannot decide`.
+- **No teams at all:** an explainer (two sentences: what a team is, what a manager does) and
+ `Organize` / `New team`.
+- **A cap reached** is a `cap` packet, drawn like any card:
+
+ ```
+ ◆ harbor reached its $5 cap today waiting on you
+ [ Raise to $10 ] harbor and its sub-teams go on until $10 today
+ [ Stop for today ] members finish their current turn and start no new one recommended: …
+ ```
+
+ The team's rail row carries `◆ needs you` until it is decided.
+
+**The team settings card** (from `Settings` in the header). Overrides only: each of the four
+values on its own row, the effective value in ink when the team overrides it, with `reset`
+beside it; an inherited value dim with its origin, `$5/day · from Settings`,
+`on · from harbor`, `3 levels · from All teams` (`Origin.Words()`). Editing a dim value makes
+it an override. Below the four: `Reports to` for a selected member (its home and `▾` to move
+it), and `Close team…`. Closable with `esc`.
+
+### 8.5 Closing, reopening, deleting (c-9)
+
+- **`Close team…`** (card from the team settings card, the rail's context menu, and `?`):
+ - with work running: `Wrap up first` (default, the accent), `Close now`, `Cancel`. The hint
+ under `Wrap up first`: `the manager asks everyone to finish and commit, then brings you a
+ closing report`. The team closes only when the person picks `Close` on the report.
+ - `Close now` stops every member turn (the person's own Stop), ends the manager's turn,
+ closes the team's tabs and moves it to Closed, in one step with `Undo` on the notice line.
+ - with nothing running: one `Close` with `Undo`.
+ - Closing a team closes its sub-teams; their reports roll up into the parent's. A sub-team
+ closing alone reports to its parent's manager.
+ - A conversation that is also in another open team is never stopped; its home moves by the
+ auto rule, or it becomes an ordinary chat.
+- **The closing report** is a `closing` packet: `done`, `left`, where the files are, what it
+ spent, and `Close` / `Keep going` (or `Close now` when the wrap-up ran out of time or money,
+ marked `wrap-up incomplete`).
+- **`Closed · N`**, folded at the bottom of the rail and of the team switcher, never on the
+ strip or the wall. Opening a closed team shows its closing report, members, spend, opened and
+ closed dates, and `Reopen` (tabs reopen, the manager resumes) and `Delete…` (confirm; it
+ forgets the grouping, the Traffic and the packets; conversations stay in history). Delete
+ exists only here.
+- **Organize** adds one proposal kind: `Close N quiet teams` (`Quiet`, 7 days without activity
+ and no open packet), each named, with `Undo`, never applied without the person. Its row is
+ the sentence whole and then the teams (`☑ Close 2 quiet teams harbor, orbit`): it is not a
+ team, so it wears no colour dot and no second count (it read `☑ ● Close 2 quiet t… 2`).
+- **A team's cards stand over the pane.** The settings, close and move cards are centred over
+ the teams page's pane while that page stands, so the rail beside them still says which team
+ they are about; they covered the rail before. Elsewhere they are centred on the frame.
+
+### 8.6 Where this departs from the brief, and why
+
+- **The home flag resolves to the nearest manager up its chain**, so giving an unmanaged
+ sub-team a manager takes its members with it. The alternative (store the manager's team,
+ never move) leaves a new sub-team manager managing nobody; making a manager is the person's
+ act, so this is not a home "changing by itself".
+- **`decided_by` is `you`, not `person`**, because Traffic already spells the person `you`
+ (`FromYou`, now `ToYou`); one word for one party.
+- **The settings group is its own tab, `Teams`.** The settings tabs are one-to-one with their
+ categories for the four newer tabs; a group inside Tasks would be a row filed under one
+ category and drawn under another.
+- **A cap is a pool, and the header says whose.** `$1.20 of $5 today · from harbor` beside a
+ sub-team would read as a second $5; the header shows the pool owner's spend and says
+ `harbor's cap`. The settings card still says `from harbor`, which is true of the value.
+- **Cap packets always go to the person.** A manager that could raise its own cap would make
+ the cap advice. Managers may stop their own team early; they may not spend more.
+- **The global manager is a real root team**, not a special case beside the tree, so every
+ rule above holds for it unchanged; the root is not a level and cannot close.
+- **Team defaults are rails**, refused to model self-service like the spending and consent
+ rows. A team's own overrides are written only by the interface for the person; the team
+ tools must not write them (d1).
+- **Packets waiting on a manager are not amber**, only those waiting on the person. The brief
+ said "amber only for needs-you"; this is that rule applied to packets.
+
+### 8.7 Open questions
+
+- Over `--host` the `Teams` tab reads and writes the engine's `teams.` defaults
+ (`Teams.Defaults`, `Teams.ApplyDefault`), the same registry write the local tab makes.
+ A value is drawn with `from Settings` (`Origin.Words`). An engine without
+ `Welcome.TeamSettings` keeps the tab read only and says
+ `changing them is not available over this connection`. The other settings tabs still
+ write this laptop's config.json. The note on those tabs says
+ `these rows belong to this machine; the Teams tab is saved on the other one.`
+ On the Teams tab, when the seam can write, it says `these rows are saved on .`
+- Spend over `--host` is the engine machine's ledger only. A conversation whose model calls
+ were made on another machine (a laptop-run member of a far team) is not counted; no such
+ arrangement exists today.
+- (Settled by d1, 8.8: the packet file now rotates.) A packet file was never rotated. A team that raises thousands of packets grew it without
+ bound; if that happens, rotate like Traffic and keep undecided packets in the new file.
+- The ruling does not say who may reopen a team whose parent is closed; the store refuses it
+ (`ErrParentClosed`) and the card should offer `Reopen harbor` instead.
+
+### 8.8 What the session built (d1)
+
+Everything below is `internal/session` unless named otherwise, on branch `task/deleg-session`.
+The interface side (d2) meets it only through `internal/teams` and its Traffic.
+
+**Wake is an inherited setting.** `teams.Settings.Wake`, `Effective.Wake`/`WakeFrom`,
+`Defaults.Wake`, config key `teams.wake` (8.2). A conversation's roles are resolved against the
+profile's `teams.` rows, which are read again only when `config.json` moves (one stat per
+boundary). A closed team gives no role at all: no verb, no delivery, no team turn.
+
+**Home and links (routing).** A conversation's home is `File.Home(key)`. In a managed team whose
+manager is not its home it is **shared**: that manager is a link.
+
+- `team_send` kind `directive` and `team_stop` from a link are refused, in words that name the
+ home team (`@web reports to the manager of "harbor", not to you: here you are a link …`); a
+ note goes through. A directive to `everyone` is written once per member who reports here
+ (`To` = handle) and names the shared ones it left out; with none left it is refused.
+- A member reads a link's directive (from an older writer) as `◆ fyi from the manager of …`
+ and is not woken by it.
+- `team_status` and the digest mark a shared member `reports to `, and `busy for `
+ in place of `running` (`teams.MemberState.ReportsTo`). `team_status` also lists the packets
+ waiting on the team.
+
+**Questions up.** `ask` of kind clarification, choice or confirmation from a member whose home
+team has `questions_up` effective raises a `question` packet (`Team` = home team, `Origin` = the
+membership, `RaisedBy` = handle, one party with the asker's reason as context, the options with
+their consequences, the pick as the recommendation) and returns at once, saying so; nothing is
+shown to the person, and the manager is roused if nobody holds it. A permission, landing,
+assumption or ratify never goes up. The manager's own clarifying question (and its judgement
+calls) is a packet too: to its home team when it has one and questions go up there, otherwise
+to `you`. New manager verbs, all on the approval floor (allow):
+
+| Verb | What it does |
+|---|---|
+| `team_decide` | `Decide(id, "manager", answer, reason)` on a packet waiting on a team it manages; an option may be named by its label; refuses cap and closing packets (the person's) |
+| `team_escalate` | `Escalate` to its own home team (`to: up`, the default) or to `you` |
+| `team_close_report` | raises the `closing` packet to `you` (done, left, files, the team's spend today) |
+
+Delivery reads `KindPacket` lines by packet id: a manager is handed a packet newly waiting on
+its team whole (question, contexts, `[id] label: consequence`, recommendation, trail), and is
+woken by it; the raiser (member or manager) is handed `◆ answered: (by ◆
+@boss, because …). Your question was: …` and woken; an escalation of its question is told
+without waking. `Decide` on a question now logs `answered @web: ` (the rail draws it
+after the decider's mark).
+
+**Caps.** At every point the team would START something (a member or manager wake, a new
+member's brief, `team_start`), the pool (owner = `CapFrom.Team`, or the top of the chain for a
+Settings cap) is checked: at or over the ceiling, the start is held and the Traffic says `held
+@web: harbor reached its $5 cap today …` once per reason; a running turn is never cut; the
+person's own typing is never held. The first holder raises ONE `cap` packet to `you` for the
+pool and ceiling (`harbor reached its $5 cap today`; `raise` = `Raise to $10`, twice the
+ceiling; `stop` = `Stop for today`; recommended `stop`), carrying `Packet.Cap` =
+`CapFacts{Team, Day, CapUSD, SpentUSD, RaiseTo}`. The raise is idempotent across processes:
+under the decisions file lock, the crossing is the pool team id, the local day and the ceiling
+(`CapUSD`). A second raiser, another window or a headless wake, finds that packet and writes
+nothing. A later day, or the same day after a `raise` when the new ceiling is crossed, is a
+different crossing and a new packet. A decided `raise` lifts the ceiling to
+`RaiseTo` for that local day; meeting it raises one more packet at the new ceiling; `stop` or
+the person's own words hold until the day turns or the cap is changed. Spend is read through
+`TeamSpend` only when `TeamSpendStamp` moved (per pool, per session), never per model request.
+
+**Wrap up first: the door and the marker (for d2).** The interface appends ONE Traffic entry to
+the team's log:
+
+```go
+teams.AppendTraffic(profile, teamID, teams.WrapUpRequest("")) // or the person's own words
+// = Entry{Kind: KindDirective, From: FromYou, To: ToManager, State: StateWrapUp ("wrap-up"), Text: …}
+```
+
+`teams.IsWrapUp(e)` is the only reader. The manager is woken by it and handed an instruction
+(tell every member to finish and commit, answer what it can, start nothing new, then
+`team_close_report`), bounded by `wrapUpFor` (15 minutes) and `wrapUpSpendUSD` ($2 of the
+team's spend since the clock's first look). The start and the bound are written on the team
+(`teams.Wrap`, through `Change` / `ChangeIf`) when the clock starts, and cleared when the
+report goes out. A session that opens reads it back (`teamWrapUpResume`): the time left is
+the bound minus how long since the start, and a wrap-up already past its bound raises the
+incomplete report on that start, once. Past either with no report, codeaf raises the
+`closing` packet itself with `Report.Incomplete`, question `close harbor? (wrap-up incomplete)`,
+options `close-now` / `keep-going`, recommended `keep-going`. A complete report has `close` /
+`keep-going`, recommended `close`. At most one closing packet waits per team. The in-memory
+clock is taken out before `Raise` so a second look during the write cannot raise another
+report. A `Raise` that fails (the decisions lock is busy, `ErrBusy` after its wait) puts
+that same clock back; the next ordinary due check tries again, and there is no retry loop
+inside the failed check. A raise that lands clears the clock in memory and on disk, once.
+
+**Accepting closes.** `teams.AcceptClosing(profile, packet)` closes `packet.Origin` with the
+packet as its report and appends a `KindClose` line, only for a decided closing packet whose
+decision is `close` or `close-now`; it is idempotent. The interface calls it after the person's
+`Decide`; the manager's session calls it again when its delivery reads the decision (and tells
+the manager). `Close now` stopping member turns and closing tabs stays the interface's (8.5).
+
+**Packet file rotation.** `decisions.jsonl` rotates past 1 MB (`decisionsRotateBytes`) to
+`decisions.1.jsonl`, and the new file opens with one `carry` line per packet still waiting (the
+packet whole, trail and escalated state included). The reader folds the rotated file, then the
+current one; a carry replaces what the older file said of that id. A waiting packet is never
+lost; a decided one stays readable for one more rotation.
+
+**Over `--host`.** All of the above runs where the conversations run, the engine: questions,
+caps, the wrap-up clock and the verbs are the engine's session reading the engine's profile and
+ledger, so a window over `--host` needs nothing new. The clock travels with the team:
+`Teams.Read` and `Teams.Update` already return `[]teams.Team`, and `wrap` is a field of that
+record, so a restarted engine resumes the same countdown. It sees packets through `Teams.Packets`
+and decides through `Teams.Decide`. Traffic has no general writer over the wire, so the wrap-up
+has two narrow doors of its own, said by `Welcome.WrapUp`: `Teams.WrapUp` (`WrapUpArgs{Team,
+Text}`) appends exactly `teams.WrapUpRequest(Text)` to the engine's log, and
+`Teams.AcceptClosing` (`AcceptClosingArgs{ID}` → `AcceptClosingReply{Closed, Stamp}`) runs
+`teams.AcceptClosing` on the engine. Client: `(*remote.Client).TeamsWrapUp(team, text)`,
+`TeamsAcceptClosing(id)`. The interface wires them into its seam (d2); an engine without the
+flag gets `Close now` only, said as such.
+
+**Known gaps.** A conversation in an unmanaged sub-team whose home
+manager is a level up gets no member verbs and no questions-up (its membership has no manager). (Closed by 8.9: such a member answers to that manager for
+questions and `team_post`.)
+A person's decision on a packet whose raiser nobody holds is delivered when that conversation
+next runs; only a manager is roused.
+
+### 8.9 What the interface built (d2), and where it departs from 8.4 and 8.5
+
+Everything below is `internal/tui3` unless named otherwise, on branch `task/teams-page`. The
+person's guide is `internal/manual/chat/teams-page.md`.
+
+**As specified.** The `teams` place right after home (`placeOrder`, one entry, so the bar is
+data and not restyled); the rail with `All teams`, the tree, `+ New team`, `✦ Organize` and
+`▸ Closed · N`; the pane with the header (spend against the pool, `Settings`, `Close…`,
+`Open ▦`), the members line, the inbox cards and the manager's real conversation under them;
+the team card with provenance and `reset`; the close card; the Closed fold with `Reopen` and
+`Delete…`; Organize's quiet-team proposal with `Undo`; the `Teams` settings tab's dim line.
+The session's contract (8.8) is used as built: `teams.WrapUpRequest` through the seam's
+`WrapUp` door (locally `teams.AppendTraffic`, over `--host` `Client.TeamsWrapUp`), and
+`teams.AcceptClosing` after the person's Decide on a closing packet (over `--host`
+`Client.TeamsAcceptClosing`), both only when `Welcome.WrapUp` says so. Cap packets draw
+`Packet.Cap`; Raise and Stop are the person's alone. Shared members read `reports to `
+or `busy for ` (`MemberState.ReportsTo`).
+
+**Where it departs, and why.**
+
+- **The marks are `⠿` (a member working, dim) and `? N` (amber, N things wait on you from the
+ team or a team under it)**, not `●` and `◆ needs you`. `●` is already every team's colour
+ dot and `◆` already marks a manager; a count says how much waits, which the rail row had no
+ room to say in words.
+- **The team card is the one settings surface.** The wall's `e`, the chip menu and the
+ switcher's `Team settings…` all open it, so the wall's old name-and-colour popover is gone
+ from use, and its delete with it.
+- **Delete exists only on a closed team** (8.5 said so), so the wall's `D` now closes the shown
+ team instead of deleting it, with Undo when nothing runs and the close card when something
+ does.
+- **The keyboard reaches the page's buttons by `alt+↑` `alt+↓`**, and `esc` gives it back to
+ the message box. The pane hosts a real conversation whose box takes every plain key, so the
+ page's letters (`s c w n o m r d u`) and arrows work only once the person has stepped onto
+ the buttons; without a manager in the pane they work at once.
+- **The side column is folded on this page** (`alt+l` unfolds it), because the teams rail has
+ the left edge and the pane is narrower than a conversation's own screen.
+- **Choosing a team with a manager brings that manager's conversation in front.** It is the
+ person's own selection, so this is not focus moving by itself; the conversation they were in
+ stays open behind on the strip. Resuming a member not open opens it behind, without moving.
+- **The manager comes into the pane on every road, and the pane says why when it cannot**
+ (`teamsopen.go`). The attempt is state the page holds: `teamsSync`, run after every message,
+ starts one whenever the selected team's manager is not in front and none was made for it, so
+ teams arriving after the page opened or a manager set on the file by a session are brought
+ in too. A manager this window is not holding is opened off the loop in its own folder, held
+ behind, and brought forward only if the page still wants it, so the person stays on the page
+ (the switcher's door it used before steps off any place standing). A refusal, or an open with
+ no answer after 4 seconds, reads `couldn't open ◆ 's manager: ` with `Retry` and
+ `Open in chats`; a manager whose transcript is gone offers `+ Manager`, which replaces it.
+ Before this the pane read `opening ◆ 's manager…` whenever the manager was not in front,
+ with nothing behind the word (reported by the owner, 2026-09-24).
+ One open per manager is out at a time: choosing the team again while it is out takes that
+ open up instead of asking the door twice, an answer for the manager the page is asking about
+ is the page's whichever attempt carried it, and a refusal about a conversation the window now
+ holds is no refusal, and the swap uses that same check: a lock on a transcript this window
+ already holds brings that manager forward and says nothing. Only `Retry` asks again while an
+ open is out. Over a connection that holds one conversation at a time the swap is asked on the
+ ordered door line, never from Update.
+- **How the page loads.** The rail, the header and the pane's frame are drawn from memory on the
+ opening frame (the teams file is the one read made on the loop, and it does not block), and
+ every reading fills in place: the spend is the header's last piece, members start as members
+ and become chips, and nothing on the page says it is loading. The reads are one command off
+ the loop, on the opening, a choice, a gesture and the router's beat while a team has a
+ manager. It asks the packets, the defaults, the selection's pool spend and the members' rows
+ side by side, because over `--host` each is a round trip. The rows are the open teams'
+ members only: read by name off this machine's disk (`session.ReadRows`), or cut out of the
+ world a connection already holds. The page used to walk every session on the machine on its
+ opening and every beat to find them. One read is out at a time and none is dropped: a read
+ asked while one is out is made when that one is folded, for the selection as it then stands.
+ Measured on Spark over a 320-session, 5-team, 25-member fixture (2026-09-24): opening to the
+ first full reading 33 ms before, 3.3 ms after (the walk was 22 to 29 ms of it, the member
+ rows are 2 ms); a team chosen while the beat's read was out never showed its spend until the
+ next beat, and shows it in 3 to 12 ms now; a double press on a cold manager's team left the
+ pane saying `open in another window` in 2 of 5 runs, and in none now. The store's own reads
+ are microseconds locally and were left alone; so was the in-process door's resume, whose
+ bucket scan costs 3 to 5 ms of a 10 to 40 ms open.
+- **A message handed to the hosted manager that puts another conversation in front takes the
+ person to it.** A Traffic row goes to its member through the chat surface's own door
+ (`trafficGo`), and the page steps down for it as a press on a member row does.
+- **`Close now` keeps the front tab.** Every other member's tab closes, but the conversation the
+ person is looking at stays, so a close never moves them.
+- **Over `--host` the seam's `History` and `Append` doors are nil**: a closed team's report is
+ not read over the connection, and close and reopen write no Traffic lines there. The page
+ says so where the report would be. Organize's quiet-close proposal is offered locally only,
+ because quietness is read from the Traffic log.
+- **The `Teams` settings tab edits the engine's defaults over `--host`** when the engine says
+ `TeamSettings` (8.7's first question). The write is the registry's own `Apply` on that
+ machine. The settings note on that tab says `these rows are saved on .` The
+ other tabs say `these rows belong to this machine; the Teams tab is saved on the other one.`
+ An older engine keeps the tab read only, with
+ `changing them is not available over this connection`.
+- **Wake is on the card** (`team messages wake`, with its provenance), which 8.4's four rows
+ predate; the settings tab's row order is questions, wake, cap, depth, share.
+- **The interface writes no cap raise.** The person's `Raise to $10` is a Decide; the session
+ applies it (8.8), so the setting has one writer.
+- **Under 72 columns the rail stacks above the pane**, because a 24-column rail beside a
+ conversation leaves the conversation too narrow to read. A hosted manager at that width is
+ shown without the rail.
+- **The manual's digits follow the whole order** (`home teams chats sessions spend
+ settings`, `alt+1` … `alt+9`): when `chats` landed third, every digit after `teams` in the
+ manual and the help moved by one.
+
+- **The header is one line, and the members are a card** (owner feedback 2026-09-24, built on
+ `task/nest-ui`, `teamcrew.go`). The two wrapped rows of every member as prose
+ (`@review not open, reports to test 1d · …`) are gone. The line is the name, `◆ Manager` (a
+ door to the manager), a chip for each member with something happening (`⠿ working`, `?
+ asking` in the needs-you amber, `✗ failed` from its newest Traffic event), one quiet word for
+ everyone else (`+4 idle`, or `6 members` when nobody is doing anything), the spend (only with
+ a spend or a cap to set it against, the cap in whole dollars, `$0.42 of $5 today`), and the
+ three buttons. Narrow, it drops the idle word first, then the spend, then the chips from the
+ last, then `◆ Manager`, then the buttons from the right, never the name. The idle word (or
+ `p`) opens the **members card**, titled with the header's own count (`harbor · ◆ Manager ·
+ 1 member`, where it used to say `2 members` beside a header saying `1 member`): handle,
+ title, state, last active, `also in test` for a
+ shared member (its hint says whose manager it reports to) and `Open` or `Resume`. `not open`
+ is said nowhere on the page: it is a fact about the window, and the button carries it. The
+ card hangs over the pane, clear of the rail, because its rows are the drag source for adding
+ a member to another team (8.11). On the root with a manager the header and the card list
+ `TopManagers()` (8.10), never their members.
+
+**Known gaps.** A switcher row `Closed · N` has no hover hint. The wall popover's delete code
+is unreached and kept until the wall is next reworked. Over `--host` the Settings tab shows
+and edits the engine's `teams.` rows when the engine has the door.
+
+### 8.10 Nesting, as the session built it
+
+Everything below is `internal/session`'s `team_nest.go` unless named otherwise, on branch
+`task/nest-session`. The interface meets it only through `internal/teams` and its Traffic; it
+needs nothing new to carry any of it out.
+
+**A sub-team is started like a member.** `team_start` takes `kind` (`member`, the default, or
+`team`), and for a team `name` and `members` (handles of the manager's own members to move in):
+
+```json
+{"handle":"api","brief":"…","kind":"team","name":"backend","members":["parser"]}
+```
+
+In one `teams.Update` it checks the manager still manages the team, that the team is open and
+`CanNest` (refused with the level, the limit and its origin: `A team under "harbor" would be
+level 3, past its depth limit of 2 (from Settings)`), that the handle and the name are free and
+that each member to move reports here; it makes the child under the manager's team, writes
+`SubTeamCap(parent)` on it as its own `cap_usd_day` when that is above zero (a parent with no cap
+gives none, and the child spends from the pool above), and moves the members (added to the
+child with their handles, removed from the parent). Then it writes ONE start to the parent's
+Traffic, `Entry{Kind: start, From: manager, To: , Text: , Team: }`
+(`teams.Entry.Team` is new), and a `note` from `system` to `room` in the child's Traffic naming
+who made it and who runs it. The approval is `team_start`'s (ask), and the card's gloss reads
+`a new team "backend" under yours, managed by it.` before the brief. The cap holds it like any
+start.
+
+The interface carries the start out as it carries out every start: it opens the conversation
+behind the one in front and adds it to the PARENT team under its handle (`Started`). The new
+conversation reads its brief at its first boundary (`team_wake.go` starts that turn, as for any
+start); a start that names a team is handed as `◆ you were started to manage the team
+"backend", under "harbor": …` above the brief, and the session then makes itself the child's
+manager (`Agent.claimSubTeams`: `AddMember` with its parent record, `SetManager`, in one
+`Update`; a child gone, closed or already managed is left alone and the parent's Traffic says
+`could not make the new conversation a team's manager: …`). This happens before the role note is
+composed, so the request that carries the brief also says it is the manager. Its manager is a
+member of the parent, so its home is the parent's manager by the ordinary rule (8.2): it reports
+up and takes the parent manager's orders.
+
+**Conflicts (`team_raise`, members and managers, allow).**
+
+```json
+{"question":"…","parties":["@api","back/@api"],"context":"my side",
+ "options":[{"label":"JSON","consequence":"…"},{"label":"form data","consequence":"…"}],
+ "recommend":"1","reason":"…","team":"optional"}
+```
+
+A party is a handle, looked for in the raiser's own teams first and then in every open team, or
+`team/@handle` (a team by name or id); one handle answering to two conversations is refused
+with both spelled `team/@handle`. The raiser is a party already (from its membership where it
+is not the manager, the first). The decider is `LCA(all party keys)`; none is `you`. The origin
+is the raiser's membership at or under the decider, and each party's `Team` is the membership
+it was found in, or its membership under the decider (not one it manages, the deepest, first),
+so every party's line runs through a team under the decider. At least two options, each with a
+consequence. The LCA manager is roused if nobody holds it.
+
+In the store (`teams` `decision.go`): every packet line now goes to the origin, the decider and
+every party's team (`involved`), so the other parties are told `◆ @web raised a conflict naming
+you (p…), for a manager above to decide: …` (not woken). A decided conflict appends to each
+party's team log a ruling, `Entry{Kind: directive, From: manager | you, To: , Member:
+, Packet: , State: decided, Text: "ruling on the conflict p…, by ◆ @boss (manager of
+"harbor"): JSON: . Because: … The conflict was: …"}`, whoever decided it (the
+manager's `team_decide`, a manager above after `team_escalate`, or the person on the teams
+page, here or over `--host` through the engine's own `Decide`). `teams.IsRuling(e)` is the one
+reader. The session delivers a ruling to the party it names (`Member`, else `To`) whatever it
+is in that team, member or manager, shared or not, as `◆ ruling on … Follow it unless the
+person said otherwise in this conversation.`, and it wakes that party (`teamWakes`). The
+decided packet line hands the parties nothing more, so a ruling is delivered once. `mayDecide`
+refuses a manager who is one of the parties (`ErrNotDecider`); the person still may. A
+manager handed a conflict sees `parties: @web, @api` and may `team_read` a party in a team
+under its own as `team/@handle` (a read reaches down the tree; it changes nothing).
+
+**One level.** `team_send` (directive or note) and `team_stop` naming a handle that is not the
+manager's own member but a member of a team under it are refused with the sentence that names
+whom to send to: `@parser is in "backend", a team under yours, and orders go one level down: it
+takes them from the manager of "backend", not from you. Nothing was sent. Send a message to
+@api, which passes on what it should.` A sub-team's manager who is not a member of the parent
+(a tree the person nested by hand) is named as such; a member of a sub-team with no manager is
+told to ask the person for one. `everyone` was already the manager's own members.
+
+**The global manager** (`teams` `root.go`). While the root has a manager, tidy seats every open
+top-level team's manager as a member of the root with its own handle when free (only ever
+added), and `(*File).TopManagers()` is the root's members who manage an open top-level team now.
+The session's view of the root for its manager (`managedView`) is its manager and
+`TopManagers()`, never their members: the roster, the digest, `team_status`, `team_send`,
+`team_stop` and `everyone` all read it. The top-level managers therefore take its directives by
+ordinary delivery, report to it by the ordinary home rule (their questions reach it before the
+person), and conflicts across trees meet at it. `team_start` of kind team at the root makes a
+top-level team. With no root manager nothing changes.
+
+**The unmanaged sub-team.** A member of a team with no manager whose nearest open ancestor has
+one (`bossOf`) is a managed member (`teamRole.boss`, `bossName`): it has `team_post` and
+`team_raise`, its clarifying questions go up as packets to that manager (origin its own team),
+and its `team_post` to the manager is written to that manager's team log, marked `(from
+"dock", a team under yours with no manager of its own)`. Its room posts stay in its own team.
+That manager does not direct it.
+
+**Roles.** A sub-team manager's role note says `Your team is a team under "harbor": you report
+to its manager, @boss. Post to it with team_post; your questions go to it before the person.`,
+a top-level manager under a global manager `Your team is a top-level team, under the global
+manager of "All teams": …`, and a manager with sub-teams `Teams under yours: "backend" (run by
+@api). Direct their managers, never their members.` The global manager is told `You are the
+global manager: "All teams" holds every team, and your members are the managers of the top-level
+teams, never their members. …`. A top-level manager's membership of the root is told it reports
+to the global manager. A member of an unmanaged sub-team is told whom it answers to. The laws are
+seven: law 4 says one level down, law 7 says how a conflict travels. Members' notes gained one
+sentence (`A conflict you cannot settle with another member or team goes up with team_raise.`).
+None of this is in the fixed prefix: the verbs arm at a boundary and the role rides a note, so
+`TestTheFixedPrefixStaysUnderItsBudget` and `TestTheLeanPrefixStaysUnderItsBudget` are
+unchanged.
+
+**Over `--host`.** Nothing new crosses: every step above runs where the conversations run, and
+the window's `Teams.Raise` / `Teams.Decide` are the engine's store calls, so a conflict the
+person rules over `--host` writes its rulings on the engine
+(`TestAConflictRuledOverTheWireReachesEveryPartyOnTheEngine`).
+
+**For the interface (d3u).** A start line may carry `team` (a sub-team start: draw `◆ started
+@api to run backend`); carrying it out is unchanged. A ruling is a directive with `packet` set
+(`teams.IsRuling`), in each party's team: draw it as the ruling on that packet, not as that
+team's manager's own directive (`From` is `manager` for any deciding manager, `you` for the
+person). Packet lines now also appear in every party's team. The root's members include the
+top-level managers once it has a manager; the teams page's `All teams` header should list
+`TopManagers()`.
+
+**Known gaps.** A root membership seated for a manager who later stops managing a top-level team
+stays until the person removes it (the session's view ignores it). A sub-team start whose new
+conversation never runs (the window refused the start) leaves an unmanaged child team; the
+parent's Traffic shows the refused start only in the window. The root's folder for a
+top-level start is whatever the interface's `teamWhere` gives the root.
+
+### 8.11 Nesting, as the interface built it (d3u)
+
+Built on `task/nest-ui` from rulings c-12 and c-13, in `internal/tui3` (`teammove.go`,
+`teamdrag.go`, `teamcrew.go`) and one store file, `internal/teams/move.go`. The person's guide
+is `teams-page.md`, *Moving a team inside another team, and adding a chat to a team*.
+
+**The store answers two questions** (`move.go`), because the session's own restructuring tools
+will ask them too. `(*File).MoveCheck(ids, parent, d)` says whether teams may go inside a
+parent ("" is the top level, which is the root when there is one, `MoveTarget`) and when not a
+`MoveBlock` with its facts: `self`, `inside` (a team under the moved one), `closed`, `depth`
+(the target's depth, the levels the moved subtree takes, the target's effective limit and its
+origin), `here` (already there), `root`, `gone`. A team picked together with its parent rides
+inside it (`MoveRoots`). `(*File).MoveEffects(ids, parent, d)` is what the move changes, on
+tidied copies of the file: every carried conversation whose `Home` changes, every moved team
+whose capped pool above it changes (the pool owner by the `teamsPool` rule), and every moved
+team whose conflicts `LCA` over its own conversations changes. `(*File).Move` writes it through
+`SetParent`.
+
+**Move into…** (`m` on the rail, or `Inside: harbor ▾` on the team's card) opens one picker: a
+card with a filter box (every printable key filters; only the arrows walk), `Top level`, then
+the open tree indented. Every row is drawn; a row that cannot take the move is dim and its
+reason is the card's foot and the hint line, in the ruling's words
+(`orbit is 2 levels deep · limit 2 · Settings`, `set on harbor` when a team set the limit).
+`enter` on a dim row moves nothing. `space` on a rail row picks teams (the wall's `☑` in place
+of the dot) and `m` moves them all; `esc` clears the picks.
+
+**The drag** is the rail's shortcut. A press on a team row selects it as a click always did; it
+becomes a drag only after two cells of held movement. A member's chip or a members card row is
+a drag source too, and its press waits for the release so a drag never opens it. During a drag
+only a target that takes the drop is grounded, the dragged row is dim, the first blank row under
+the tree reads `↳ Top level` (the empty rail under the tree is the top level), and the hint
+says `Drop to move api into harbor` or the block's reason. A member dropped on a team is
+ADDED (`AddMember` with what its own team kept of it) and never removed from where it came
+from. `esc`, or a second press before the release, drops the drag.
+
+**The consequence line.** Every move goes through one door (`teamMoveAsk`): with no changes it
+is written at once; otherwise one line on the pane (or on the card) says
+`api will report to harbor's manager · its $3/day becomes part of harbor's $10 pool` with
+`Move` (the accent, where the keyboard lands) and `Cancel` (`esc`). A conflicts clause is said
+only when the conflicts go somewhere the report clause has not named. Every move, confirmed or
+not, is offered back for `teamsUndoFor` (six seconds) with `Undo` or `u`: the old parents are
+written back, and every carried conversation's home flag is put back where the membership can
+still hold one. The pane shows one Undo at a time, the newer of a close and a move.
+
+**`+ New team in harbor`.** With a team chosen the rail's `+ New team` makes the team inside it
+(the wall's naming card says `New team in harbor`), and writes `SubTeamCap(parent)` as its own
+cap when that is above zero, as the session's `team_start` of kind team does. A rail too narrow
+for the words draws two rows rather than cutting the name. A team at its depth limit dims the
+row and its hint and press say why.
+
+**The switcher and the wall.** The strip's switcher lists the open tree, each sub-team indented
+under its team. The wall's Teams row stays flat and names a nested team `harbor › api`, each
+part cut on its own so the team's own name is never the part lost.
+
+**Traffic (8.10's asks).** A start carrying `Team` reads `◆ started @api to run backend`,
+and the name is read from the teams held in memory when the rail is drawn, so a rename
+does not leave `to run` on the old name. A
+ruling (`teams.IsRuling`) reads `◆ ruling → @web` (or `you ruling`) with the decision's words
+and the packet named in the hint, never as the team's own manager's `do`.
+
+**Where it departs, and why.**
+
+- **`m` is Move into…, so starting a manager moved to `M`.** The ruling names the letter; a
+ manager is started once per team, a move whenever the tree is reshaped.
+- **The picker is a card over the frame, not a menu hung from the row**, because the same
+ picker opens from the team's card, which is itself a card in the middle of the frame.
+- **A drag from a team row still selects it on the press.** The shared place laws hold that a
+ press on a row does what `enter` on it does; a team's select is harmless under a drag, while a
+ member's door (which opens a conversation, possibly leaving the page) waits for the release.
+- **The members card's rows, not the header's chips, are the everyday member drag source**,
+ because idle members (most of them) are only on the card.
+- **A consequence line wraps on a narrow pane** rather than cutting the thing being decided; the
+ buttons follow the last line, or stand on their own row when it is full.
+
+**Traffic of a move.** A committed move appends one `KindEvent` to each affected team's log,
+through the store, beside the write (`MoveNotices`, `MemberMoveNotices`, `WriteMoveNotices`).
+The team left and the team that moved say `@handle moved to harbor`. The team joined says
+`@handle joined from ops`. For a team moved under another, that is the moved team and its new
+parent (and the parent it left, when it had one). A refused move appends nothing. The lines are
+from `system` to `everyone` with no member state, so the existing Traffic read hands them to a
+manager on its next wake and they do not start a wake of their own.
+
+**Known gaps.** The picker has no pointer scroll; a list longer than the frame scrolls with the
+cursor only. Multi-select is keyboard only (`space`); there is no pointer gesture for picking. Over `--host`
+the move writes through the seam like every edit, but the defaults the picker reads for the
+depth limit are the engine's only once the page has read them (`Teams.Defaults`); before that a
+depth block is not shown and the store's own `SetParent` is the only check.
+
+## The standing card says what it is
+
+A proposal card names its kind on the first line, then what it does, then when and what one time costs. The words live in `session.StandingOptions` so the conversation, home, `--host` and the recorded `labels` say the same thing.
+
+- A reminder (`when.at`): `wants to remind you`. `Remind me `, `Change…`, `Don't remind me`. No once.
+- A repeating check (`when.every`): `wants to set up a repeating check`. `Set it up · `, `Change…`, `Only now, don't repeat`, `Don't set it up`.
+- A watch (file, idle, probe): `wants to watch for something`. `Watch for it`, `Change…`, `Check once now`, `Don't watch`.
+- A rule (`when.hold`): `wants to keep a rule`. `Keep this rule`, `Change where…`, `Don't keep it`. No once.
+
+Keys stay `1` yes, `3` once, `0` no. Once is never the cursor rest. On a narrow row the cadence drops off the yes before the label is cut, and the no is never the chip that is dropped.
diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go
index c0728fe6cb..70f0cd7ac4 100644
--- a/internal/config/derivation_test.go
+++ b/internal/config/derivation_test.go
@@ -325,6 +325,14 @@ var settingReaders = map[string]string{
// and lanes.go). The chooser reads the pin too, through the same reader.
LaneSettingKey(LaneSlotTalk): "LaneAt",
KeyLaneGuard: "LaneGuardAt",
+ // The five team defaults are read together by internal/teams' DefaultsAt,
+ // which hands them to the resolver that walks a team's parent chain; they
+ // are one snapshot for [KeySSHControlPersist]'s reason.
+ KeyTeamsQuestionsUp: "TeamDefaultsAt",
+ KeyTeamsCapUSDDay: "TeamDefaultsAt",
+ KeyTeamsDepthLimit: "TeamDefaultsAt",
+ KeyTeamsSubSharePct: "TeamDefaultsAt",
+ KeyTeamsWake: "TeamDefaultsAt",
}
// Every persisted row names a reader, and every named reader is really there.
diff --git a/internal/config/selfservice.go b/internal/config/selfservice.go
index eae09440b9..ec0fe2c835 100644
--- a/internal/config/selfservice.go
+++ b/internal/config/selfservice.go
@@ -102,6 +102,18 @@ var selfServiceGuards = map[string]string{
KeyTaskMinFreeMB: guardPressure,
KeyBashBackgroundAfter: guardPressure,
+ // The team defaults: a team's day and the share a sub-team is handed are
+ // money; how deep a manager may build teams is how much unattended work it
+ // may start; and whether a member's question goes to its manager rather
+ // than to you is the consent question asked one level up. A manager is a
+ // model, and each of these is a rail on managers.
+ KeyTeamsCapUSDDay: guardSpending,
+ KeyTeamsSubSharePct: guardSpending,
+ KeyTeamsDepthLimit: guardPressure,
+ KeyTeamsQuestionsUp: guardConsent,
+ // Waking starts a model turn nobody typed: unattended work, so pressure.
+ KeyTeamsWake: guardPressure,
+
KeyTaskAudit: guardProof,
// The signature itself has no row; what is left of it is whether the
// `Assisted-by` line names the model, and that is the person's to decide.
diff --git a/internal/config/selfservice_test.go b/internal/config/selfservice_test.go
index 52b3916090..446faf836b 100644
--- a/internal/config/selfservice_test.go
+++ b/internal/config/selfservice_test.go
@@ -40,6 +40,8 @@ func TestTheRestraintRowsAreNotSelfService(t *testing.T) {
KeyTaskRepairRounds, KeyWorkingSet, KeyContextReuse,
// How hard the machine may be worked.
KeyTaskParallel, KeyTaskMaxLoad, KeyTaskMinFreeMB, KeyBashBackgroundAfter,
+ // What every team inherits: a manager is a model.
+ KeyTeamsCapUSDDay, KeyTeamsSubSharePct, KeyTeamsDepthLimit, KeyTeamsQuestionsUp, KeyTeamsWake,
// Whether the work is checked, and how it is signed.
KeyTaskAudit, KeyAttributionModel,
// The credentials.
diff --git a/internal/config/settings.go b/internal/config/settings.go
index ffe531bd45..e31752debc 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -76,6 +76,10 @@ const (
// CategoryTasks is how work you can walk away from is run — how it starts,
// how it is checked, how much of it happens at once, and on whose hands.
CategoryTasks = "tasks"
+ // CategoryTeams is what every team inherits when it says nothing of its
+ // own: who answers a member's question, what a team may spend in a day,
+ // and how deep teams may nest (teamdefaults.go).
+ CategoryTeams = "teams"
// CategoryPractice is what codeaf does with its own time, and what it
// remembers of yours.
CategoryPractice = "memory & practice"
@@ -98,7 +102,7 @@ const (
// safety and tasks follow it in the order the design's own hierarchy names.
var SettingCategories = []string{
CategoryModels, CategorySpending, CategorySafety, CategoryTasks,
- CategoryPractice, CategoryInterface,
+ CategoryTeams, CategoryPractice, CategoryInterface,
}
// Persisted keys are also the json field names in the profile's config.json.
@@ -2762,6 +2766,7 @@ func (s *Settings) build() []Setting {
write: func(raw string) error { return writeChoice(dir, KeySSHIPQoS, raw, SSHIPQoSChoices) },
},
)
+ rows = append(rows, teamRows(dir)...)
return rows
}
diff --git a/internal/config/teamdefaults.go b/internal/config/teamdefaults.go
new file mode 100644
index 0000000000..6a372ae90e
--- /dev/null
+++ b/internal/config/teamdefaults.go
@@ -0,0 +1,195 @@
+package config
+
+import (
+ "fmt"
+ "strconv"
+)
+
+// ── THE TEAMS GROUP: WHAT EVERY TEAM INHERITS WHEN IT SAYS NOTHING ─────────
+//
+// A team may override five things about how work is delegated to it
+// (internal/teams' teamsettings.go): whether team traffic wakes its idle
+// conversations, whether its members' questions go up to its manager first, what it may spend in a day, how deep teams may nest under
+// it, and what share of its own cap a new sub-team is handed. A team that sets
+// none of them inherits each from its parent, and the top of every chain
+// inherits from these five rows. So these are DEFAULTS and nothing else: no
+// session reads them to decide anything except through internal/teams'
+// resolver, which reports beside every value where it came from, so a card can
+// say `$5/day · from Settings` without guessing.
+//
+// THEY ARE FLAT KEYS UNDER `teams.`, because config.json is a flat dotted map
+// ([readProfileConfig]); a nested `teams` object would read as unset with no
+// error and every team would fall to the built-in default.
+
+// The five rows' keys.
+const (
+ KeyTeamsQuestionsUp = "teams.questions_up"
+ KeyTeamsCapUSDDay = "teams.cap_usd_day"
+ KeyTeamsDepthLimit = "teams.depth_limit"
+ KeyTeamsSubSharePct = "teams.sub_share_pct"
+ KeyTeamsWake = "teams.wake"
+)
+
+// The built-in defaults.
+//
+// QUESTIONS GO UP by default, because that is the ruling's goal: a person who
+// talks to the top manager and steps away should be asked only what no manager
+// could answer. A day's cap is OFF by default, for the reason every rail in
+// this build ships large or off ([DefaultSpendRailUSD]): a cap nobody chose
+// would stop work nobody asked to stop. Depth is three levels, the ruling's
+// "about three". A new sub-team gets half its parent's cap.
+const (
+ DefaultTeamsQuestionsUp = true
+ DefaultTeamsCapUSDDay = 0.0
+ DefaultTeamsDepthLimit = 3
+ DefaultTeamsSubSharePct = 50
+ // DefaultTeamsWake is on: a directive that waited for the person to type
+ // in the member's tab would make a manager a mailbox.
+ DefaultTeamsWake = true
+)
+
+// The bands the two counts are kept inside. A depth of 1 is teams with no
+// sub-teams at all; ten levels is past anything a person could follow. A
+// share is a whole percentage of the parent's cap, and 0 would be a sub-team
+// that can spend nothing, which is a stopped team rather than a share.
+const (
+ teamsDepthMin = 1
+ teamsDepthMax = 10
+ teamsShareMin = 1
+ teamsShareMax = 100
+)
+
+// TeamDefaults is the five rows resolved: the persisted value where there is
+// one inside its band, and the built-in default everywhere else.
+type TeamDefaults struct {
+ QuestionsUp bool
+ // CapUSDDay is dollars per local day for a team and everything under it;
+ // 0 is no cap.
+ CapUSDDay float64
+ // DepthLimit is how many levels a chain of teams may have, the top
+ // counting as one.
+ DepthLimit int
+ // SubSharePct is the whole percentage of its parent's cap a new sub-team
+ // is given.
+ SubSharePct int
+ // Wake is whether team traffic wakes an idle conversation: a directive its
+ // member, a member's reply its manager.
+ Wake bool
+}
+
+// TeamDefaultsAt resolves the five rows in one read of the profile, for
+// internal/teams' [teams.DefaultsAt]. A value outside its band reads as the
+// default rather than refusing a team over a hand-edited file.
+func TeamDefaultsAt(profileDir string) TeamDefaults {
+ out := TeamDefaults{
+ QuestionsUp: DefaultTeamsQuestionsUp,
+ CapUSDDay: DefaultTeamsCapUSDDay,
+ DepthLimit: DefaultTeamsDepthLimit,
+ SubSharePct: DefaultTeamsSubSharePct,
+ Wake: DefaultTeamsWake,
+ }
+ if value, ok := persistedBool(profileDir, KeyTeamsQuestionsUp); ok {
+ out.QuestionsUp = value
+ }
+ if value, ok := persistedFloat(profileDir, KeyTeamsCapUSDDay); ok && value >= 0 {
+ out.CapUSDDay = value
+ }
+ if value, ok := persistedInt(profileDir, KeyTeamsDepthLimit); ok && value >= teamsDepthMin && value <= teamsDepthMax {
+ out.DepthLimit = value
+ }
+ if value, ok := persistedInt(profileDir, KeyTeamsSubSharePct); ok && value >= teamsShareMin && value <= teamsShareMax {
+ out.SubSharePct = value
+ }
+ if value, ok := persistedBool(profileDir, KeyTeamsWake); ok {
+ out.Wake = value
+ }
+ return out
+}
+
+// teamRows are the Teams group, in the order a person reaches for them: who
+// answers a question and whether messages wake, then money, then the shape of
+// the tree (how deep it may go, then what a new branch of it is given), which
+// is the order a team's own card lists its overrides in (DESIGN.md section 8).
+func teamRows(dir string) []Setting {
+ return []Setting{
+ {
+ Key: KeyTeamsQuestionsUp, Category: CategoryTeams, Kind: SettingBool,
+ Label: "questions go to the manager",
+ Hint: "when a team has a manager, a member's clarifying question goes to that " +
+ "manager first and reaches you only if the manager cannot answer it. " +
+ "Permission prompts always come to you. A team can override this.",
+ read: func() string { return formatBool(TeamDefaultsAt(dir).QuestionsUp) },
+ write: func(raw string) error { return writeBool(dir, KeyTeamsQuestionsUp, raw) },
+ },
+ {
+ Key: KeyTeamsWake, Category: CategoryTeams, Kind: SettingBool,
+ Label: "team messages wake",
+ Hint: "a manager's directive starts an idle member's turn, and a member's " +
+ "reply starts the manager's. Off, everything still arrives, at the next " +
+ "turn each conversation takes. A team can override this.",
+ read: func() string { return formatBool(TeamDefaultsAt(dir).Wake) },
+ write: func(raw string) error { return writeBool(dir, KeyTeamsWake, raw) },
+ },
+ {
+ Key: KeyTeamsCapUSDDay, Category: CategoryTeams, Kind: SettingDollars,
+ Label: "team daily cap", EmptyLabel: "no cap",
+ Hint: "what a team and every team under it may spend in a day before its " +
+ "manager asks you whether to go on. 0 is no cap. A team can set its own.",
+ read: func() string { return moneyValue(TeamDefaultsAt(dir).CapUSDDay) },
+ write: func(raw string) error { return writeDollars(dir, KeyTeamsCapUSDDay, raw) },
+ },
+ {
+ Key: KeyTeamsDepthLimit, Category: CategoryTeams, Kind: SettingCount,
+ Label: "team depth", Unit: "levels", UnitOne: "level",
+ Hint: "how many levels of teams a manager may build by starting sub-teams, " +
+ "the top team counting as one. 1 means no sub-teams.",
+ read: func() string { return strconv.Itoa(TeamDefaultsAt(dir).DepthLimit) },
+ write: func(raw string) error {
+ return writeTeamsBand(dir, KeyTeamsDepthLimit, raw, teamsDepthMin, teamsDepthMax)
+ },
+ },
+ {
+ Key: KeyTeamsSubSharePct, Category: CategoryTeams, Kind: SettingCount,
+ Label: "sub-team share", Unit: "%",
+ Hint: "the share of its parent's daily cap a new sub-team starts with. It is " +
+ "written on the sub-team when it is made, so changing this later moves " +
+ "no team that already exists.",
+ read: func() string { return strconv.Itoa(TeamDefaultsAt(dir).SubSharePct) },
+ write: func(raw string) error {
+ return writeTeamsBand(dir, KeyTeamsSubSharePct, raw, teamsShareMin, teamsShareMax)
+ },
+ },
+ }
+}
+
+// ApplyTeamDefault writes one `teams.` row in profileDir the way the settings
+// tab writes it: the registry row's own Apply, so the bands, the empty cap and
+// the refusal words are the row's and not a second copy. A key that is not one
+// of the five defaults is refused and nothing is written. The settings tab
+// over --host asks the engine to call this on the engine's profile, which is
+// the file the far session's teams actually inherit.
+func ApplyTeamDefault(profileDir, key, raw string) error {
+ switch key {
+ case KeyTeamsQuestionsUp, KeyTeamsWake, KeyTeamsCapUSDDay, KeyTeamsDepthLimit, KeyTeamsSubSharePct:
+ default:
+ return fmt.Errorf("%s is not a team default", key)
+ }
+ row, ok := NewSettings(SettingsOptions{ProfileDir: profileDir}).Row(key)
+ if !ok || row.Category != CategoryTeams {
+ return fmt.Errorf("%s is not a team default", key)
+ }
+ return row.Apply(raw)
+}
+
+// writeTeamsBand persists a whole number inside [low, high], refusing in the
+// row's own words anything outside it.
+func writeTeamsBand(profileDir, key, raw string, low, high int) error {
+ value, err := parseCount(raw)
+ if err != nil {
+ return err
+ }
+ if value < low || value > high {
+ return fmt.Errorf("that's not between %d and %d", low, high)
+ }
+ return writeProfileValue(profileDir, key, value)
+}
diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger
index 4d6c828b2f..b7bd45ff4d 100644
--- a/internal/config/testdata/profile-keys.ledger
+++ b/internal/config/testdata/profile-keys.ledger
@@ -90,6 +90,11 @@ task.parallel
task.repair_rounds
task.settle
task.start
+teams.cap_usd_day
+teams.depth_limit
+teams.questions_up
+teams.sub_share_pct
+teams.wake
telemetry
tenure_after
tools.approval
diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go
index 44adc00f78..8a9e5a346c 100644
--- a/internal/e2e/tui_e2e_test.go
+++ b/internal/e2e/tui_e2e_test.go
@@ -693,7 +693,7 @@ func testAskHere(t *testing.T) {
time.Sleep(1500 * time.Millisecond)
settled = r.capture()
t.Logf("the settled exchange, reopened:\n%s", settled)
- if !strings.Contains(settled, say(t, "standYesWord")+" · "+say(t, "standSetWord")) {
+ if !strings.Contains(settled, say(t, "standRemindYes")) || !strings.Contains(settled, say(t, "standSetWord")) {
t.Errorf("the settled card does not carry the answer and its verdict:\n%s", settled)
}
if !strings.Contains(settled, say(t, "homeAskStoodWord")) {
diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go
index ea31e38e55..cbb691368d 100644
--- a/internal/e2e/tuiwords_test.go
+++ b/internal/e2e/tuiwords_test.go
@@ -300,8 +300,9 @@ var tuiWords = map[string]tuiWord{
why: "the pane saying the exchange is filed under what it made",
},
"exchangeAnswerHint": {
- screen: "1 yes, set it up · 0 no · o other",
- source: "yes, set it up",
+ screen: "Don't remind me",
+ source: "Don't remind me",
+ pkg: "internal/session",
why: "the answers a ONE-OFF REMINDER's card offers, spelled in full under the box at every width. " +
"There are two rows and the key that asks for the box: a reminder has no `3 just once` to give, " +
"and since #189 the line is built from the answers the question carries rather than typed out, so " +
@@ -321,9 +322,10 @@ var tuiWords = map[string]tuiWord{
screen: "enter or tab answer this ",
why: "the hint while the cursor stands on an errand row that is asking something",
},
- "standYesWord": {
- screen: "yes, set it up",
- why: "the first chip on a standing card, and half of the settled card's `yes, set it up · set up`",
+ "standRemindYes": {
+ screen: "Remind me",
+ pkg: "internal/session",
+ why: "the yes on a one-off reminder, which is what the errand in this suite asks for",
},
"standSetWord": {
screen: "set up",
@@ -378,7 +380,7 @@ var tuiWords = map[string]tuiWord{
// THE PHASE CLOCK COMPOSES BOTH OF ITS SENTENCES AT THE DRAW, out of halves
// two packages own (internal/tui3's phase.go, and the clock that feeds it in
// internal/provider). So each half is its own row and the suite asserts the
- // join, which is the shape `standYesWord` and `standSetWord` already have.
+ // join, which is the shape `standRemindYes` and `standSetWord` already have.
// What varies is not a needle: the machine that went quiet is whatever this
// run pinned, and the provider a rescue would go to is whatever the frontier
// named. What stands still is the clause around them.
diff --git a/internal/manual/chat/asking-from-home.md b/internal/manual/chat/asking-from-home.md
index f749576b35..f7d4405577 100644
--- a/internal/manual/chat/asking-from-home.md
+++ b/internal/manual/chat/asking-from-home.md
@@ -199,8 +199,10 @@ the list`, with the card's own answers in front of it when a card is up and
**The answers in that line are the ones the card actually drew, and never one more.** A
card that offers all three reads
-`1 yes, set it up · 3 just once · 0 no · o other`; a one-off reminder's card,
-which has no `just once` to give, reads `1 yes, set it up · 0 no · o other`.
+`1 Set it up · Mondays at 9am · 3 Only now, don't repeat · 0 Don't set it up · o Change…`;
+a one-off reminder's card, which has no once to give, reads
+`1 Remind me at 6 · 0 Don't remind me · o Change…`. The once button used to say
+`just once`, which also read as "set it up once".
The line is built from the question rather than written out, so it cannot name a digit that
would do nothing.
@@ -275,7 +277,7 @@ window is in no project at all. A reminder belongs to no repository; a watch on
row is drawn at the top of the list whichever project it ended up in, and the project it
belongs to is what the errand's own record says.
-## How do I answer the card, or say no to it — 1 yes, o other when or where, 3 just once, 0 no
+## How do I answer the card, or say no to it. 1 yes, o Change, 3 once, 0 no
When the exchange gets far enough to propose something that keeps working, a card appears in
the pane with your own words, when it would wake, and what it would cost per run. Nothing is
@@ -286,10 +288,11 @@ created until you answer it:
your own words ("make it 8pm", "every weekday") and the model proposes again. Nothing is
created by a change. (This was `2` before the card's answers moved onto the question every
screen here draws; `c` is that question's own key for "not as it stands".)
-- `3` — once. The action runs now and nothing standing is created. **Not every card offers
- it**: a one-off reminder draws no `3 just once` chip, because doing "remind me at six"
- now says the wrong thing hours early. The hint under the box names the digit only where
- the chip is on the card.
+- `3`. Once. The action runs now and nothing standing is created. On a repeating
+ check the button reads `Only now, don't repeat`. **Not every card offers it**: a
+ one-off reminder draws no once chip, because doing "remind me at six" now says the
+ wrong thing hours early. It used to say `just once`, which also read as "set it up
+ once". The hint under the box names the digit only where the chip is on the card.
- `0` — no. Nothing is created and nothing is run, the card settles as `not set up`, and the
keyboard goes back to the list. **This is the only way to say no in this pane**: `esc` here
hands the keyboard back to the list without answering anything, and a card left standing on
@@ -304,9 +307,9 @@ on home says `? waiting on you` for as long as it does.
**The card stays after you answer it.** It does not disappear — it settles in place, greys
out, and its bottom edge carries what was decided in the same words a card in a conversation
-uses: `yes, set it up · set up`, `just once · done now, nothing kept`,
-`change when or where · you asked for something different`,
-`not set up`, `ended · nothing was set up`. The answers go, so `1`, `3`, `0` and `c` are
+uses: `Set it up · Mondays at 9am · set up`, `Only now, don't repeat · done now, nothing kept`,
+`Change… · you asked for something different`,
+`not set up`, `ended · nothing was set up`. The answers go, so `1`, `3`, `0` and `o` are
ordinary characters again and can be typed into a follow-up. The only card that ever
replaces it is the new one the model sends after a change.
diff --git a/internal/manual/chat/attaching-files.md b/internal/manual/chat/attaching-files.md
index 35a7dcde81..7320478ebf 100644
--- a/internal/manual/chat/attaching-files.md
+++ b/internal/manual/chat/attaching-files.md
@@ -562,7 +562,9 @@ and the hint on the row is the full title. A conversation that is in no team is
still on the list.
After you send, that team mark and that `@handle` stay clickable. A press on the
-team opens the conversations view on it. A press on the conversation opens it. The
+team opens the teams page with it selected. A press on the conversation opens it. Over
+`--host`, against an engine with no teams doors, a press on the team opens the
+conversations view on it instead. The
model is handed a short digest of each one, not the transcript, and the other
conversation is not messaged and not woken. The words in your transcript are the
words you typed.
diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md
index c7188f5ace..7fa69f74bf 100644
--- a/internal/manual/chat/commands.md
+++ b/internal/manual/chat/commands.md
@@ -192,9 +192,10 @@ Canonical word, the other words it answers to, its argument form, and what it do
| `/history` | — | — | opens the full-screen sessions place — every task this machine has run, filterable (also ctrl+.) |
| `/status` | `/info`, `/context` | — | prints every fact the status line knows, one per line |
| `/status` | `/info`, `/context` | `--json` | prints the same facts as one JSON object, keys in the same order |
-| `/search` | none | none | opens the search place, everything said on this machine (also `alt+8`) |
-| `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+4`) |
+| `/search` | none | none | opens the search place, everything said on this machine (also `alt+9`) |
+| `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) |
| `/wall` | | | every open conversation at once, as a grid of live tiles, and the teams you group them into (also `alt+v`, or `▦` under the box) |
+| `/teams` | | | the teams page: your teams as a tree, what waits on you, and the selected team's manager conversation (also `alt+2`, or `teams` on the tab bar) |
| `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what |
| `/budget` | `/limits` | — | what codeaf may spend · every limit on one tab |
| `/budget` | `/limits` | `` | sets the day's limit · `none` removes it |
@@ -227,16 +228,16 @@ Under the table `/help` prints the keys that have no slash command, including
`alt+enter`, and `d` inside `/permissions`. The keys page covers those in full. The
`ctrl+c` line reads `ctrl+c quits everything · mid-turn it interrupts instead, like esc`.
-**It also names the way into the seven places**, which it did not for a long while — three
+**It also names the way into the eight places**, which it did not for a long while: three
rows, directly under the `tab` row:
```
-alt+1…8 go to a place · in the tab bar's own order: home chats tasks spend settings standing memory search
+alt+1…9 go to a place · in the tab bar's own order: home teams chats sessions spend settings standing memory search
alt+. on a place: what else is here · every key that place has, drawn
on a place, tab is the next place · esc back
```
-On a Mac those read `opt+1…8` and `opt+.`; the substitution happens once, at the moment of
+On a Mac those read `opt+1…9` and `opt+.`; the substitution happens once, at the moment of
drawing, and the words are the same.
**One gesture, one spelling.** Wherever the sheet names the escape key it writes `esc
@@ -759,13 +760,13 @@ and the note's leading `· `; strip those before feeding it to a parser.
## /search and /spend — the typed doors onto those two places
`/search` opens the **search place** — everything that has been said on this machine,
-found by the words you remember of it. It is the same place `alt+8` opens and the same
+found by the words you remember of it. It is the same place `alt+9` opens and the same
place `tab` walks to. It takes no argument: the place *is* a box, and typing in it
searches. With the **memory** row off nothing said is indexed, and the place says so and
searches nothing — find the conversation from home's box instead (see the *places* page).
`/spend` opens the **spend place** — what this machine has cost, by the day, by the model
-and by what it was for. It is the same place `alt+4` opens.
+and by what it was for. It is the same place `alt+5` opens.
**`/spend` used to be an alias of `/cost` and is not any more.** The two answer different
questions: `/cost` is *this conversation's* bill, printed into the conversation, and the
@@ -1112,7 +1113,7 @@ box, opens it on a one-conversation machine and on an empty one alike, and over
it opens the far machine's.
There is no argument form. There are three other ways in: **`alt+1`**, home being the first
-of the four places on the tab bar; **`space` twice** on an empty box; and **`tab`** from any
+of the six words on the tab bar; **`space` twice** on an empty box; and **`tab`** from any
other place.
**It is seven panels**, in one column under 110 cells, two from 110 and three from 170,
@@ -1447,7 +1448,7 @@ is built from this session's own work, and carries only a short dulled note of t
**It is not `/tasks`, and there is no `/tasks` command.** `/task ` and its `solo` form
mean *give codeaf work*; this page starts none, so it does not share their word. Typing
`/history` is the only slash form — but the PLACE this opens is called `tasks` on the tab
-bar, and **`alt+3`** and `tab` reach it without a command at all. The word is a place, not a
+bar, and **`alt+4`** and `tab` reach it without a command at all. The word is a place, not a
command.
Two sections. `running` is the tree of everything still going, drawn whole, with each task's
@@ -1660,13 +1661,31 @@ So a value set here follows you between projects, and a value a project sets for
has to be edited by hand in that file. When a project answers the same row, a write
through `change_setting` says so rather than reporting a change that is not in force.
-Over `--host`, opening the panel first notes:
+Over `--host`, opening the panel on any tab but Teams first notes:
```
-these rows are this machine's — the ones that govern the conversation are read from the profile on the other one
+these rows belong to this machine; the Teams tab is saved on the other one.
```
-and then opens anyway.
+On the Teams tab the note is `these rows are saved on .` An older engine, where
+that tab cannot be saved over the connection, notes:
+
+```
+these rows belong to this machine; this conversation reads its profile on the other one.
+```
+
+The panel opens anyway.
+
+## Can I edit team defaults over --host
+
+The **Teams** tab is the one section that edits the other machine. Over `--host` its five
+rows (`questions go to the manager`, `team messages wake`, `daily cap per team`,
+`team depth`, `sub-team share`) are that machine's defaults, and a change is saved there.
+Each value says `from Settings`, the same words a team's card uses when it inherits the
+row. The foot line says `a team can override any of these on its card · saved on `.
+
+An older engine keeps the tab read only and says
+`changing them is not available over this connection`. The other tabs stay this computer's.
Refusals inside the panel, exactly as written:
diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md
index e3e197f825..b6060ab319 100644
--- a/internal/manual/chat/conversations-and-teams.md
+++ b/internal/manual/chat/conversations-and-teams.md
@@ -12,9 +12,10 @@ Open it any of these ways:
- `alt+v` from anywhere in a conversation (on a Mac keyboard that is not sending alt,
`option+v` types `√`, and that works too)
- `/wall`
-- `chats` or `▦` at the right end of the row under the message box, drawn once a
+- `▦ All` at the right end of the row under the message box, drawn once a
second conversation is open (the word goes first when the row is narrow)
-- `▦ All` on the tab strip, after the new-chat `+` (just `▦` on a narrow window)
+- `▦ All` on the tab strip, after the new-chat `+` (not drawn under 60 columns). The strip
+ is the second line of a chat, not of a place, so from home or teams use `alt+v` or `/wall`
Close it with `alt+v` again, `esc`, the `‹ Back` button at the bottom left, or a press on
the strip's `▦ All`. Nothing you do in the view ends any work: closing a tile closes its
@@ -29,25 +30,34 @@ resumes them in the background, so they arrive as tiles and tabs while the conve
front and your focus stay where they are. When every member is open the button is not there. Under it is the **Teams** row, which ends in `✦ Organize` while every conversation is
shown, and at the bottom a toolbar with `Filter /`,
`New team s`, `Columns − +` and `Help ?`. While the pointer rests on any control, the middle
-of the toolbar says in one dim line what it does and which key does the same.
+of the toolbar says in one dim line what it does and which key does the same; on a narrow
+window `Columns` and the other buttons step aside for that line so it is always whole, and
+come back when the pointer leaves. Pointing at `▦ All` says `The grid of your open tabs, and
+your teams · alt+v`; pointing at a square of the tabs dock under the box names that
+conversation (see below).
-## The chats dock under the message box
+**`▦ All` and the `chats` place are two different doors.** `chats` on the top line is the
+place: every conversation, one at a time, with the tab strip over it. `▦ All` is this view:
+the tabs this window has open, all at once, as a grid.
-Once a second conversation is open, the row under the message box ends in a dim word,
-`chats`, then `▦`, then one square per open conversation, in the same order as the tab
-strip. The square in front is `▣`. The others are `■`, coloured the way the strip colours
+## The tabs dock under the message box
+
+Once a second conversation is open, the row under the message box ends in `▦ All`, the
+same door the tab strip has, then one square per open conversation, in the same order as
+the tab strip. The square in front is `▣`. The others are `■`, coloured the way the strip colours
a tab: the live colour while it is running, amber while it is waiting on you, and dim
while it is idle. Amber is only for waiting on you. One open conversation draws no dock.
Rest the pointer on a square and that square takes the hover ground. The hint line says
`Go to Shipping the parser · running · click`, with `waiting on you` or `idle` in the
-middle. On the square in front it says `Shipping the parser · you are here`. On `chats`
-or on `▦` it says `All conversations · alt+v`.
+middle. On the square in front it says `Shipping the parser · you are here`. On `▦ All`
+it says `The grid of your open tabs, and your teams · alt+v`, as the strip's `▦ All` does,
+and the hover ground covers the glyph and the word together: they are one button.
-A press on a square goes to that conversation and does not open this view. A press on
-`chats` or on `▦` opens the conversations view, the same as `alt+v`. A press on the
-square in front does nothing. On a narrow row the word `chats` is the first thing to
-go. The squares stay, then fewer of them with a `+N`, then the dock is not drawn.
+A press on a square goes to that conversation and does not open this view. A press
+anywhere on `▦ All` opens the conversations view, the same as `alt+v`. A press on the
+square in front does nothing. On a narrow row the word `All` is the first thing to
+go, leaving `▦`. The squares stay, then fewer of them with a `+N`, then the dock is not drawn.
## Reading a tile: title, what it is doing, and the newest lines
@@ -144,9 +154,29 @@ start page, `ctrl+t`, and a folder typed on home all start a new conversation, a
the team that is shown. Going back to a conversation that already exists changes no team.
**Team settings.** `e`, the dot on a team's segment, or the `⋯` the pointer brings up where its
-count was, opens the team's settings: its name, which you edit as you type, and its colour.
-**Delete team** asks first, and deleting a team never closes or changes a conversation; only
-the group's name goes.
+count was, opens the team's **card**: its name, which you edit as you type, its colour,
+**`Inside: harbor ▾`** (which team it sits in; a press opens the Move into… picker), the
+settings it overrides (each one saying where an inherited value comes from) and
+**Close team…**. The **teams page** has the card whole.
+
+**Teams inside teams.** A team can sit inside another. The Teams row stays one flat row and
+names a team inside another with its parent first, `harbor › api`; the team switcher and the
+teams page draw the tree. You move a team on the **teams page** (`m`, Move into…, or a drag in
+its rail) or with `Inside` on its card, and every move can be undone for a few seconds.
+
+## Moving a conversation between teams is written to Traffic
+
+Moving a conversation from one team to another writes one Traffic line on each side:
+`@web moved to harbor` on the team it left, `@web joined from ops` on the team it joined.
+Moving a whole team under another writes the same kind of line on the team that moved and on
+its new parent. A move that does not go through writes nothing. The manager of a team that
+gained or lost a member is told on its next wake, from the Traffic it already reads.
+
+**Closing a team.** `D` closes the team that is shown: at once, with Undo, when nothing in it
+is running, and with a card offering **Wrap up first**, **Close now** and **Cancel** when
+something is. A closed team leaves the Teams row and the switcher and waits under
+`▸ Closed · N` on the teams page, where it can be reopened, and deleted once you are sure.
+Closing or deleting a team never deletes a conversation.
## Organize: teams suggested for your conversations
@@ -191,24 +221,28 @@ After an Apply the Teams row says what it did for a few seconds, like
`Organized · 2 new teams, 2 added Undo`. **Undo**, or `u` while it is there, puts your teams
back exactly as they were.
-Organize only ever adds: it never renames a team and never takes a conversation out of one,
-so pressing it again is how you refresh the suggestions. Nothing runs by itself. The button
+When some teams have had no activity for a week and nothing waiting on them, the card also
+offers **Close 3 quiet teams** under `Quiet for a week`, ticked like the rest; Apply closes
+them and Undo reopens them. Apart from that Organize only ever adds: it never renames a team
+and never takes a conversation out of one, so pressing it again is how you refresh the
+suggestions. Nothing runs by itself. The button
counts the conversations in no team once there are five or more, `✦ Organize 7`, and after a
run that found nothing to suggest it reads `Organized ✓` until your conversations or teams
change; it can still be pressed.
## The team switcher on the tab strip
-While a team is shown, the tab strip carries a chip naming it, `● harbor ▾`, right after
-`Home` and right before the tabs it narrows, with the manager's place after it:
+While a team is shown, the tab strip carries a chip naming it, `● harbor ▾`, first on the
+strip and right before the tabs it narrows, with the manager's place after it:
```
- Home ● harbor ▾ ◆ Manager × Refactor the rail sco… × openrouter price scrape × + ▦ All
+ ● harbor ▾ ◆ Manager × Refactor the rail sco… × openrouter price scrape × + ▦ All
```
-`Home` is a fixed door and stands first; the chip is a filter over the tabs, so it sits with
-them. When the row runs short `Home` is the first to go, then the chip, and never the tab in
-front. With teams but none shown, the chip is a quiet `Teams ▾`; with no teams at all there
+The chip is a filter over the tabs, so it sits with them. There is no `home` on the strip:
+home is the first place on the top line, over the strip while a chat is in front. The
+strip is not drawn on a place. When the row runs
+short the chip goes, and never the tab in front. With teams but none shown, the chip is a quiet `teams ▾`; with no teams at all there
is no chip. A press on the
chip opens the **team switcher** under it, on any page the strip is on, the conversations view
included:
@@ -216,8 +250,10 @@ included:
```
╭─ Teams ────────────────────╮
│ ◉ ● harbor 2 │
-│ ○ ● orbit 1 │
+│ ○ ● orbit 1 │
+│ ○ ● dock 0 │
│ ○ All 3 │
+│ Closed · 2 ▸ │
│ ────────────────────────── │
│ − Remove this conversation │
│ + New team… │
@@ -226,12 +262,15 @@ included:
```
- a team, or **All**, narrows or widens the strip; the conversation in front stays in front
- unless it is not in the team, and then the team's first conversation comes forward
+ unless it is not in the team, and then the team's first conversation comes forward. The
+ teams are the tree: a team inside another stands indented under it
- **+ Add this conversation** puts the conversation in front into the team that is shown, and
the row turns into **− Remove this conversation**
+- **Closed · 2** is there while you have closed teams: a press opens the teams page with its
+ Closed fold open. A closed team is never one of the switcher's teams
- **+ New team…** opens the conversations view with the new-team card, the conversation in
front already picked
-- **Team settings…** opens the shown team's settings
+- **Team settings…** opens the shown team's card, over whatever page you are on
`↑` `↓` move, `enter` chooses, `esc` or a press anywhere off it puts it away.
@@ -241,11 +280,16 @@ A team can have one **manager**, a conversation that runs the team for you: you
hands work to the members and tells you where things stand. While a team is shown, the first
place on the tab strip is the manager's, pinned at the left: a quiet `+ Manager` until there is
one and `◆ Manager` after; `◆ Make this harbor's manager` in the team switcher or in a tile's
-Teams list makes an existing conversation the manager. While the manager is in front its
-**Traffic** rail is on the right (`alt+l` shows or hides it), and `alt+m` goes to the manager. What a manager can do, and how members talk to each other, is on the
-**team manager** page.
+Teams list makes an existing conversation the manager. The right-hand
+column of a chat in a team has two words, `Tasks` and `Traffic` (what passes in the team). A
+Traffic row reads who it is from and who it is for, `◆ → @scrape +2 Please provide…`, and in
+a member's chat that member is `you` (`◆ → you`, `you → ◆`). The
+manager's opens on the Traffic, a member's on its tasks, `←` `→` switch them while the column has
+the keyboard, `alt+l` shows or hides it, and `alt+m` goes to the manager. What a manager can do, and how members talk to each other, is on the
+**team manager** page. The **teams page** (`/teams`, `alt+2`) lists every team as a tree
+and puts the chosen team's manager conversation beside it, with what waits on you.
-## Team names and handles in a conversation are links
+## What clicking a team name in a chat does, and how team names and handles are links
In a conversation that is in a team, every **handle** of a member of that team, like
`@security`, is a link wherever it appears: in a reply, in a team's quoted card, in the
@@ -253,8 +297,12 @@ surface's own notes and in a team tool's call such as `team_send @security`. So
name where it is written as a team: `team harbor`, `the harbor team` or `"harbor"`. The
pointer on one puts a ground under it and the hint line says what a press does, like
`Open @security · santosh dev2 branch code… · click`. A press opens that member, resuming it
-first when this window does not have it open; a team's name opens the conversations view on
-that team. An `@word` that is no member's handle is left as plain text.
+first when this window does not have it open. A press on a team's name opens the **teams
+page** with that team selected: the rail's cursor on it and the pane showing it. The hint
+says `Open harbor on the teams page · click`. A closed team is selected inside `Closed`, and
+that fold is opened. Over `--host`, against an engine that has no teams doors, the teams page
+cannot open, so the press opens the conversations view on that team instead and the hint says
+so. An `@word` that is no member's handle is left as plain text.
## Mention a team or another conversation with @
@@ -271,7 +319,9 @@ filters every section that is still showing.
Choosing a team inserts `●harbor` in the team's colour. Choosing a conversation
inserts `@handle`, or a short slug of its title when it has none, and the row's
hint is the full title. After you send, both stay links. A press on the team opens
-the conversations view on it. A press on the conversation opens that conversation.
+the teams page with it selected. A press on the conversation opens that conversation.
+Over `--host`, against an engine with no teams doors, a press on the team opens the
+conversations view on it instead.
The model receives a short digest of each reference: for a team, its members,
handles, states and recent traffic; for a chat, its title, its state and an excerpt
@@ -305,9 +355,9 @@ what its key does.
| `x` | Close the focused view, or the picked ones; the work keeps running |
| `m` | The teams list for the focused conversation, or the picked ones |
| `s` | New team of the picked conversations, or the focused one |
-| `e` | The shown team's settings |
+| `e` | The shown team's card: name, colour, settings, Close team… |
| `r` | Resume the shown team's conversations that are not open here |
-| `D` | Delete the shown team; its conversations stay open |
+| `D` | Close the shown team; its conversations stay open |
| `o` | Organize: suggest teams for your conversations (while All is shown) |
| `u` | Undo the last Organize, while the Teams row offers it |
| `tab`, `shift+tab` | Next or previous team, then All |
diff --git a/internal/manual/chat/getting-started.md b/internal/manual/chat/getting-started.md
index c0f74c9567..867c9fb5e0 100644
--- a/internal/manual/chat/getting-started.md
+++ b/internal/manual/chat/getting-started.md
@@ -308,9 +308,9 @@ ghostty with `font_family`, `[font.normal] family` and `font-family` in their co
files.
**Option as meta, on macOS.** Every chord codeaf binds is the option key, and on a Mac it is
-drawn the way the keycap names it, `opt+enter` to send what you typed off as a task, `opt+1`…`opt+8`
+drawn the way the keycap names it, `opt+enter` to send what you typed off as a task, `opt+1`…`opt+9`
to jump to a place, `opt+.` for the map. (On Linux and Windows the same chords are drawn
-`alt+enter`, `alt+1`…`alt+8`, `alt+.`; this manual names both spellings together.) Most Mac
+`alt+enter`, `alt+1`…`alt+9`, `alt+.`; this manual names both spellings together.) Most Mac
terminals send Option as an accent-composing key until you tell them otherwise, so those
chords type `¡ ™ £ ≥` instead of doing anything. Turn on **iTerm2** → Profiles → Keys →
*Left Option key: Esc+*, or **Terminal.app** → Profiles → Keyboard → *Use Option as Meta
@@ -323,14 +323,14 @@ line: `your terminal sends opt as a letter — turn on "use option as meta" in
terminal you are actually in.
**The first-run setup says it too.** When the three questions are done, a Mac gets one more
-line: `the places answer opt+1…opt+8 · if opt types a character instead, turn on "use option as
+line: `the places answer opt+1…opt+9 · if opt types a character instead, turn on "use option as
meta" in …`. It is a condition rather than a report — nothing has been pressed yet — and it is
said once.
**On kitty, ghostty and WezTerm there is also a way in with no setting at all:** those
terminals report that they run the kitty keyboard protocol, and where that report arrives
-`ctrl+1` … `ctrl+8` jump to the same seven places and `ctrl+.` draws the same map. The map's
-own line says `alt+1…8 or ctrl+1…8 go to a place` exactly when the alias is live.
+`ctrl+1` … `ctrl+9` jump to the same eight places and `ctrl+.` draws the same map. The map's
+own line says `alt+1…9 or ctrl+1…9 go to a place` exactly when the alias is live.
On Linux and on Windows terminals, Alt is already meta and there is nothing to set. The
whole of this is also in *Screen* — see *The font codeaf is drawn for*, *alt or option or opt —
diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md
index ceab09d4b6..dd44677cf1 100644
--- a/internal/manual/chat/home.md
+++ b/internal/manual/chat/home.md
@@ -8,7 +8,7 @@ answering one question you would ask walking up to a colleague's desk:
```
codeaf $0.14 / $20 · thu 9:49am
- home chats tasks spend settings
+ home teams chats sessions spend settings
───────────────────────────────────────────────────────────────────────────────────────
Understanding Hash Tables 2m projects
@@ -45,7 +45,7 @@ in the right-hand rail instead — heading and one dim line each — so the left
is only ever the things that are actually going on.
`esc` puts you back in exactly the chat you came from, untouched — nothing was closed and
-nothing was sent while you were looking; `alt+2` (`chats` on the bar) does the same. The
+nothing was sent while you were looking; `alt+3` (`chats` on the bar) does the same. The
resting foot reads
`alt+p project · alt+e effort · alt+a approvals · alt+k chats · / commands`.
The project, approvals and chats hints appear only where those controls can act.
@@ -58,7 +58,9 @@ whole map over the cells you are already reading.
There is no argument form. The screen is how you name what you want; a command that took a
project name would be asking you to type out the very thing home exists to show you.
-Home is the **first word on the tab bar**: `home chats tasks spend settings`.
+Home is the **first place on the top line**, right after the `codeaf` wordmark:
+`home teams chats sessions spend settings`.
+The **teams page**, right after it, is where your teams and their managers live.
It still does nothing on its own: no notifications and no alerts. You open it, you see where
things stand, and you either act on something or leave.
@@ -286,6 +288,13 @@ before choosing the fifteen most recent, so each conversation appears once.
The `opt+k` chats menu still lists open tabs; closing or reopening a conversation
updates the tab and the row in Sessions together.
+## Why does home show a session id for an untitled chat, untitled conversation, new conversation
+
+**A conversation nothing has named yet reads `new conversation`** on home's sessions
+list, the same word the sessions place uses. It never reads as its session id, including
+the id with its first letter raised (`D53cceead3f99593`). A conversation that has a title
+keeps that title.
+
## Close or put away a task from home — task row options, new in project, open folder, copy project
**`→` offers task options just as it does for threads:** `x close`,
@@ -488,7 +497,7 @@ names none of them; `alt+.` draws the map when you want the rest.
A digit answers the question row drawing its answers, wherever you are standing. `enter` acts on
the row under the cursor. `alt+.` draws the map.
-## Why is a heading highlighted, why is one project name darker than the others
+## Why is a heading highlighted, which section is my cursor in, why is one project name darker than the others
**Because the cursor is in that panel.** The heading of the panel holding the cursor wears
the cursor's ground — one heading per frame, following the keyboard, and never the mouse.
@@ -509,8 +518,8 @@ own tab stack — so going back is `enter`. A window that has held only one conv
**On a launch** — home greeting you — the cursor is on the conversation this window is
holding, the row `esc` drops back into.
-**`↑` off the top row of home stays on it.** The tab bar — the row of four words — is
-reached by clicking a word, by `tab`, or by a place's own chord (`alt+3` and the rest); on
+**`↑` off the top row of home stays on it.** The tab bar, the row of six words, is
+reached by clicking a word, by `tab`, or by a place's own chord (`alt+4` and the rest); on
every other place `↑` off the top row still walks up onto it. On the bar `←`
and `→` walk along the words without opening anything, `enter` or `↓` goes into the one under the
cursor, and `esc` puts the cursor back on the row it came from. Walking up onto the bar does
@@ -592,19 +601,26 @@ under it.
## The line at the top of home — the pulse, want you, moving, spend and allowance, the clock
-The top line is the program's name and, right-aligned, what is true of the **whole
-machine** right now. It is the first row of **every** frame — home, every place, and the
-chat itself, where it sits over the tab strip (` home `), a rule and a blank:
-the same four rows at the top wherever you are standing. **Inside a chat** and on every
-place but home it reads:
+The top line is the program's name, the places you can go, and, right-aligned, what is true
+of the **whole machine** right now. It is the first row of **every** frame: home, every
+place, and the chat itself, and it does not move. Under it, **inside a chat**, is the tab
+strip of your chats, then a rule and a blank: four rows. On home and every other place
+there is no strip. The rule and a blank come next, three rows, and the page starts one
+row higher. **Inside a chat** and on every place but home the top line reads:
```
- codeaf 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm
+ >● codeaf home teams chats sessions spend settings 2 want you · 4 moving · $0.55 / $500 · tue 1:11pm
```
-**On home it drops the two counts** and keeps the budget and the clock —
-` codeaf $0.55 / $500 · tue 1:11pm` — because the `needs you`
-and `sessions` panels are those counts, row by row.
+**On home it drops the two counts** and keeps the budget and the clock, because the
+`needs you` and `sessions` panels are those counts, row by row.
+
+**On a narrow window the right end gives way first**: the clock, then `4 moving`, then
+the words of `2 want you`, which becomes `2 ?` in the same amber, then the allowance
+(`$0.55 / $500` becomes `$0.55`); only then do the places fold into `more ▾`, and then the
+day's figure goes. **The count of things waiting on you never goes**: `2 ?` is on the line at
+every width, because it is the one number you must not have to go looking for. The **Places** page of
+this manual has the whole order and the `more ▾` menu.
- `2 want you` — how many things have **stopped on you**: a conversation waiting for an
answer, a standing order that will not fire until you say so, an errand holding a
@@ -627,19 +643,19 @@ another window asks can take up to ten seconds to reach a chat's top line.
**The money on this line is the money on the spend place and the `spend` panel**, to the
cent — one reading of one file, wherever you are standing.
-## Where did standing, memory and search go — the four places on the tab bar
+## Where did standing, memory and search go: the six words on the tab bar
-**They are still places; they are just off the bar.** The tab bar under the top line is five
-words, `home chats tasks spend settings`. `tab` walks the four rooms among them and
-`alt+1` … `alt+5` go to each; `chats` (`alt+2`) is the way back to your conversation. Standing,
+**They are still places; they are just off the bar.** The bar on the top line is six
+words, `home teams chats sessions spend settings`. `tab` walks the five rooms among them and
+`alt+1` … `alt+6` go to each; `chats` (`alt+3`) is the way back to your conversation. Standing,
memory and search open exactly as they did:
-- **`/standing`** (or `/orders`), `alt+6`, or `enter` on a `standing` row;
-- **`/memory`** (or `/memories`), `alt+7`, or `enter` on memory's line in `since you left`;
-- **`/search`**, `alt+8`, or the typed door on home's box.
+- **`/standing`** (or `/orders`), `alt+7`, or `enter` on a `standing` row;
+- **`/memory`** (or `/memories`), `alt+8`, or `enter` on memory's line in `since you left`;
+- **`/search`**, `alt+9`, or the typed door on home's box.
-While you stand in one of the three, its word is drawn after the four so you can see where
-you are; `tab` from there goes to home. `alt+.` draws the map of all seven with their
+While you stand in one of the three, its word is drawn after the six so you can see where
+you are; `tab` from there goes to home. `alt+.` draws the map of all eight with their
numbers. Home's own panels already summarise the three on the bar: `sessions` is a glimpse of
tasks, `spend` of spend, `standing` of standing.
@@ -1520,7 +1536,7 @@ machine with one conversation or with none still has a home to go to. The rule t
home from *greeting* a first run is a different rule — not being greeted by home and not
being able to reach it are two different things.
-From home, `alt+2` (`chats` on the bar) goes straight back to the conversation you were
+From home, `alt+3` (`chats` on the bar) goes straight back to the conversation you were
in, and `alt+k` chooses one.
## space space does nothing — why the gesture did not open home
@@ -1630,11 +1646,12 @@ The chips are the ones the question has:
- It is **waiting for permission to run something**: `1 allow once 2 always 3 deny`.
- It is **asking whether to start a task**: `1 yes 2 no`.
-- It is **asking whether to keep an eye on something**: `1 yes 3 just once 0 not set up`
- — or `1 yes 0 not set up`, when what it is asking about is a **one-off reminder**, which
- has no `once` answer at all. **`0` is how you say no from home**: nothing is set up,
- nothing is run, and the card in that window settles as `not set up`. There is no
- `2 change when or where` here, on purpose: that answer is a request for a text box; open
+- It is **asking whether to keep something going**. The chips are that card's own
+ words: a repeating check is `1 Set it up · `, `3 Only now, don't repeat`,
+ `0 Don't set it up`. A one-off reminder has no once, and its no is `Don't remind me`.
+ A watch's no is `Don't watch`. A rule's no is `Don't keep it`. **`0` is how you say
+ no from home**: nothing is set up, nothing is run, and the card settles as `not set up`.
+ There is no change chip here, on purpose: that answer is a request for a text box. Open
the conversation to say a different time or place.
`2 always` means what it means in the window: **that session stops asking about that
@@ -1680,8 +1697,8 @@ one (*Why is the home screen empty*).
Over `--host` home lists **the machine your session is running on**. The projects, the
conversations in them and the work each of those ran are read on the far end and carried
-here, so what you are looking at is the server's afternoon rather than your laptop's — and
-the right end of the tab bar says `on ` so you can see which. Enter on a row opens
+here, so what you are looking at is the server's afternoon rather than your laptop's, and
+the right end of the top line says `on ` so you can see which. Enter on a row opens
that conversation beside the one you are in rather than in place of it.
Two things a remote home does not do, and both are silences rather than sentences. **No row
@@ -1725,7 +1742,7 @@ opens the conversation; on a `standing` row it opens the standing place.
The `◦` mark itself belongs to the standing place and to a conversation's own lines —
`◦ leave for the train · in 4m`. `∙` is a paused item there, and `◆` means the thing went off
-after the last time you spoke in the conversation behind it. The standing place (`alt+6`,
+after the last time you spoke in the conversation behind it. The standing place (`alt+7`,
`/standing`) is every promise this machine has made, with how much rope each has.
**Retired items are nowhere on home.** Something that fired once and finished, or that you
@@ -1829,32 +1846,30 @@ standup note" — a card appears in the conversation and **nothing is set up unt
you answer it**:
```
-╭─ ? ◦ every Monday at 9, post the standup ──────────────────────────────────
+╭─ ? wants to set up a repeating check ──────────────────────────────────────
│ every Monday at 9, post the standup note from the git log
-│ when · Mondays at 9am
+│ Mondays at 9am · about $0.02 a run, at most once a day
│ where · for this project
-│ costs · about $0.02 a run, at most once a day
-│ [ 1 yes, set it up ] [ 2 change when or where ] [ 3 just once ] [ 0 no ]
-│ I'll keep doing this Mondays at 9am, for this project, until you stop it
╰────────────────────────────────────────────────────────────────────────────
```
-Three bands make it different from the card that proposes a task: **`when ·`**, in
-the words you said or the words it worked out; **`where ·`**, how far it reaches;
-and **`costs ·`** — what one run may spend and how often it may run. A watch that
-has to *look* at something adds `checked every 5 minutes`. If it made the timing up
-rather than reading it off what you said, the band asks instead of stating:
-`Mondays at 9am — you didn't say, so that's my guess. Right?`
+The head says what kind of thing it is. The next line is what it does. The line
+after that is when, and what one time costs. **`where ·`** is how far it reaches.
+A watch that has to look at something adds `checked every 5 minutes` on that
+cost line. If it made the timing up rather than reading it off what you said,
+the line asks instead of stating:
+`Mondays at 9am. You didn't say, so that's my guess. Right?`
**The answers**, by key, by `←`/`→` and `enter`, or by clicking one:
-- `1 yes, set it up` — it gets set up and starts happening.
-- `2 change when or where` — the box below becomes a place to say the **time or
- the place** you want instead: "make it 8", "only in this project", "everywhere".
- Nothing is set up until a new card comes with them in it.
-- `3 just once` — do it now and leave nothing behind.
-- `0 no` — nothing is set up, nothing is run, and the row settles as
- `not set up`. `esc` does exactly the same thing.
+- `1` sets it up. On a repeating check the button reads `Set it up · `.
+- `o Change…` turns the box into a place to say the time or the place you want
+ instead: "make it 8", "only in this project", "everywhere". Nothing is set up
+ until a new card comes with them in it. On a rule the button reads `Change where…`.
+- `3` does it now and leaves nothing behind. On a check it reads `Only now, don't repeat`.
+ This used to say `just once`.
+- `0` sets nothing up. The button's words depend on the kind: `Don't set it up`,
+ `Don't remind me`, `Don't watch`, or `Don't keep it`. The row settles as `not set up`.
**The line under the answers says what the one you are on will actually do**, written
out of this card's own facts. A **one-off reminder's card draws no `3`**: "do it now" for
@@ -1945,7 +1960,7 @@ phone*).
**The machine's own card is gone too.** `↑` off the top of the column stays on the top row,
and each thing that card said has a place: `keeping an eye on` is the standing place
-(`alt+6`), `today` is the `spend` panel and the pulse line, `agents` is the pulse's
+(`alt+7`), `today` is the `spend` panel and the pulse line, `agents` is the pulse's
`4 moving`, and `thinking` is the `thinking` row of `/settings`.
## The card beside a search — the preview on the right while you type
@@ -2226,7 +2241,7 @@ words that set one up. On a short terminal `standing` is the first panel to give
**The `spend` panel, pinned in the rail under `projects`**, is the day and the fortnight in three dim lines.
**Nothing under its heading is selectable or clickable**: the arrows step over its lines, the
pointer does not light them, and a press on one does nothing. The heading is the door — `enter`
-cannot reach it, but a click on the word `spend` opens the spend place, as does `/spend` or `alt+4`:
+cannot reach it, but a click on the word `spend` opens the spend place, as does `/spend` or `alt+5`:
```
spend
diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md
index 321c14a968..faf713c293 100644
--- a/internal/manual/chat/keeping-an-eye.md
+++ b/internal/manual/chat/keeping-an-eye.md
@@ -137,25 +137,22 @@ passed".
Because "once" would not be a smaller version of what you asked for; it would be
a different thing at the wrong moment.
-`3 just once` means **do the action now, as an ordinary turn, and leave
-nothing behind**. For a watch, a rule, a routine or overnight work that is a real
-answer: you wanted the tests run, not the arrangement. For "remind me at 6 to
-leave" the whole content of the request is the **6** — doing it now says
-`time to leave` hours early, or says nothing at all. So the card does not offer
-it there:
+`3` means **do the action now, as an ordinary turn, and leave nothing
+behind**. On a repeating check the button reads `Only now, don't repeat`. On a
+watch it reads `Check once now`. It used to say `just once`. For "remind me at 6
+to leave" the whole content of the request is the **6**. Doing it now says
+`time to leave` hours early, or says nothing at all. A rule never runs. So
+neither of those cards offers `3`:
```
-? wants to keep an eye on: remind me at 6 to leave
- 1 yes, set it up it keeps happening until you stop it
- 0 no nothing happens, now or later
+? wants to remind you
+ 1 Remind me at 6 Reminds you then. Nothing repeats.
+ 0 Don't remind me You are not reminded.
```
-Two answers, `1` and `0`, and `3` does nothing — on the question in the
-conversation, in home's `ask here` pane, and on home's own answer row. The hint
-under the box says so too: `1 yes, set it up · 0 no · esc later`. The **way out
-is still drawn**: `0 no` is on every standing card there is.
-
-Everything else keeps all three: a watch, a rule, a routine, overnight work.
+Two answers, `1` and `0`, and `3` does nothing. A rule is the same shape: it
+never runs, so there is no once. A repeating check and a watch keep `3`. The
+**way out is still drawn**, and it is last, so a narrow row does not drop it.
## Why did it set my reminder for a time that already passed — it cannot any more
@@ -265,9 +262,9 @@ yes nor a clear no, codeaf treats it as a no and the log says
## How long do I have to answer the card — the card does not time out
As long as it takes. The card carries **no clock**: no countdown, no bar, and no
-moment when it answers on somebody's behalf. It waits until you press `1 yes,
-set it up`, `0 no`, `c` to change it, or — where the card offers it —
-`3 just once`.
+moment when it answers on somebody's behalf. It waits until you press the yes
+(`1`), the no (`0`), `o` to change it, or, where the card offers it,
+`3` to do it once. `3` used to read `just once`.
`esc` **puts it off and does not answer it**. The question folds away so you can
type, the work stays waiting on it, and the count beside the box goes on
@@ -280,64 +277,60 @@ still takes the decline.
It does not have to be answered in that window either. A window sitting on this
card says so on **home**, and the row there carries the same answers the card is
-offering — `1 yes`, `3 once` and `0 not set up`, or `1 yes` and `0 not set up` on
-a one-off reminder — so the card can be **answered or declined** from the
-dashboard without opening the conversation (home's own page has the whole rule).
-`o other` stays here, where there is a box to say the new time or
-place into.
+offering. A repeating check reads `Set it up · `, `Only now, don't repeat`
+and `Don't set it up`. A reminder reads `Remind me ` and `Don't remind me`.
+The card can be **answered or declined** from the dashboard without opening the
+conversation (home's own page has the whole rule). `o Change…` stays here, where
+there is a box to say the new time or place into.
-## I don't understand these options — what each answer on the card does, how to change the time on a standing card, and how to cancel
+## I don't understand these options. What kind of standing card is this, how do I cancel this card, where did 2 change when or where go
The card in the conversation shows what is being proposed — your own words, when it would
wake, where it reaches, what it costs. The **question sits above the message box**, where
every decision on this screen is put, and every answer carries what it costs beside it:
```
-? wants to keep an eye on: every Monday at 9, post the standup note from the git log
- 1 yes, set it up it keeps happening until you stop it
- 3 just once it happens now, and nothing is kept
- 0 no nothing happens, now or later
- esc later · o other · ? clarify
-```
-
-- **`1 yes, set it up`** — it gets set up and starts happening, and goes on until you stop
- it. The `when ·` and `where ·` bands on the card above say when and how far.
-- **`c` change when or where** — you want it, but not like that. Press `c` and the box below
- becomes a place to say the **time or the place** instead — "make it 8", "only in this
- project", "everywhere" — and `enter` sends your words back. Nothing is set up until a new
- card comes with them in it. (This was `2 change when or where` on the card's own row of
- chips; `c` is the key every question on this screen uses to say "I will take one of these,
- but not as it stands".)
-- **`3 just once`** — do the thing now, this once, and keep nothing. Some cards do not
- offer it; the section above says why.
-- **`0 no`** — **this is cancel**. Nothing is set up, nothing is run, and the card settles
- as `not set up`.
-- **`esc`** — later. Nothing is decided, the rows come off the screen so you can type, and
- the question is still counted beside the box. It used to be the outright no here; it is
- not any more.
+? wants to set up a repeating check
+ every Monday at 9, post the standup note from the git log
+ Mondays at 9am · about $0.02 a run
+ 1 Set it up · Mondays at 9am It repeats on that cadence until you stop it.
+ 3 Only now, don't repeat Runs the check one time now. Nothing repeats.
+ 0 Don't set it up Nothing is set up, and nothing runs.
+ esc later · o Change… · ? clarify
+```
+
+The first line says what kind of thing it is. The second line is what it does.
+The third is when, and what one time costs.
+
+- **A reminder** says `wants to remind you`. `1` is `Remind me ` (reminds you then, nothing repeats), `0` is `Don't remind me`. There is no once.
+- **A repeating check** says `wants to set up a repeating check`. `1` is `Set it up · ` (it repeats until you stop it), `3` is `Only now, don't repeat` (runs once now, nothing repeats), `0` is `Don't set it up`. On a narrow row the cadence drops off the yes before any word is cut. `3` used to say `just once`.
+- **A watch** says `wants to watch for something`. `1` is `Watch for it`, `3` is `Check once now`, `0` is `Don't watch`.
+- **A rule** says `wants to keep a rule`. `1` is `Keep this rule`, `o` is `Change where…`, `0` is `Don't keep it`. There is no once.
+
+`o Change…` turns the box into a place to say the time or the place, and `enter` sends your words back. Nothing is set up until a new card comes with them in it. That is where `2 change when or where` went: it is `o` now, and it does not resolve the card. `0` is how you cancel this card: nothing is set up and nothing runs, and the no is never dropped when the row is narrow. **`esc`** is later. Nothing is decided, the rows come off the screen so you can type, and the question is still counted beside the box.
Each answer is a row of its own and **a click anywhere along it takes that answer**. The
digit takes it too.
-Answering leaves a line where the question was — `✓ wants to keep an eye on: … → yes,
-set it up · you · 14:02` — and the card in the conversation settles with the answer and what
-it came to on its bottom edge: `yes, set it up · set up`.
+Answering leaves a line where the question was, the kind and the button you pressed,
+and the card in the conversation settles with that button and what it came to:
+`Set it up · Mondays at 9am · set up`.
## How do I decline a standing card or say no to a reminder — 0, esc, or the no on the card
-`0 no` is the way out, and it is one keystroke and **one visible answer** everywhere
+`0` is the way out, and it is one keystroke and **one visible answer** everywhere
a card like this is drawn: in the conversation, on home's answer row, and in
home's `ask here` pane. Nothing is created, nothing is run, and the card settles
-as `not set up`. On home's answer row the chip reads `0 not set up`, which is the
-same answer said as its outcome, because that row has no card under it to settle.
+as `not set up`. The chip's words are the kind's own no: `Don't set it up`,
+`Don't remind me`, `Don't watch`, or `Don't keep it`.
**`esc` is not the no.** It used to be, in the conversation, and it is *later*
now: the question folds away, nothing is decided, and the count beside the box
goes on counting it. That is the same thing `esc` does to every question this
program asks, and it is why the no had to become something you can see and click.
-It is a `0` and not a `4` because the answers are numbered by where they sit —
-`1 yes`, `3 just once` — so a fourth digit would move under your hand on a card
+It is a `0` and not a `4` because the answers are numbered by where they sit.
+`1` is yes and `3` is once, so a fourth digit would move under your hand on a card
that drew one answer fewer. `0` is off the end of that numbering, on every card,
always the same answer.
@@ -924,6 +917,17 @@ arrives".
A firing's run folder is a normal session folder outside your projects, so its
transcript reads with the same tools as any other conversation.
+## Why did it say the reminder was never set up after I said just once
+
+The completion check reads a short account of the turn, and a long turn clips
+that account. Your answer on a card this turn is kept on the account anyway,
+after the clip, as one line. For `3 just once` that line is `the person
+answered the card "": only now, don't repeat`. Any
+other card you answered is the same shape, with the words that were on the
+chip. The check is deciding what is still owed. An answer you already gave is
+in front of it, so "nothing was set up" is the choice on the card and not a
+piece of the ask still to do.
+
## What it will not do
- **It will not set anything up when nobody is watching.** A headless `--once`
@@ -936,7 +940,7 @@ transcript reads with the same tools as any other conversation.
task proposal, where silence starts the work: a task is bounded work somebody
is watching, and a standing item spends money at times nobody chose.
- **A "do it once" answer sets nothing up.** It answers
- `do it once, now, as an ordinary turn — nothing stands. Nothing was set up.`
+ `Do it now as an ordinary step and report what happened. The person chose not to repeat it. Do not set it up again unless they ask. Do not investigate codeaf.`
and codeaf does the thing in front of you instead. A **one-off reminder's card
does not offer that answer** — see "Why is there no once on my reminder card".
- **It will not set a reminder for a moment that has already passed.** The stamp
diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md
index 3061164f4f..cee376ec53 100644
--- a/internal/manual/chat/keys.md
+++ b/internal/manual/chat/keys.md
@@ -239,7 +239,9 @@ while it is open, so none of them contend for one keystroke on one screen.
## ? — the key that opens the key sheet
`?` **over an empty box** opens `/help`: every command and every chord, written into the
-transcript where you can scroll it.
+transcript where you can scroll it. A row that is wider than the window wraps under the
+column its sentence already starts in, so the next line does not sit at the left edge as
+if it were another key.
**On a place** — home, tasks, standing, memory, spend, search, settings — `?` draws **the
map** instead, which is what `alt+.` draws: that place's own keys, in the cells the foot
@@ -252,6 +254,14 @@ before this binding is read, so a `?` typed into one of those reaches it and not
`/?` is an alias of `/help` as well, for fingers that arrived from elsewhere.
+## Why a wrapped /help line stays under its key
+
+`/help` is a column. The key sits on the left and the sentence starts in a fixed
+column. When the window is too narrow for the sentence, the next line starts in
+that same column. It does not start at the left edge, which would look like a
+second key. The same is true of a line that was already indented under the
+column above it.
+
## Stop it and tell it something different at the same time — interrupt and say something new in one key
`ctrl+shift+enter` while a turn is running **stops the answer and sends what is in the box**,
@@ -342,7 +352,7 @@ A message waiting above the box stays with its conversation when you open Home w
pasted documents and standing mark stay with it. A connection that holds one conversation
at a time returns the waiting words and pictures to the box and tray when switching ends
the old conversation. `esc` stops the answer and drops waiting messages.
-From Home, `alt+2` (`chats` on the bar) goes straight back to the conversation you
+From Home, `alt+3` (`chats` on the bar) goes straight back to the conversation you
were in, and `alt+k` chooses one.
## Interrupting a running turn — how do I stop it mid answer
@@ -476,7 +486,7 @@ conversation this machine's codeaf service is running **keeps working** after th
closes — its tasks, its questions and its journal are all there when you open the same
workspace again — and a conversation running inside this terminal (`--no-host`, or a host
that could not be reached) stops with it. To see what is running before you go, the task
-column (`ctrl+g`) and `/status` both say.
+column (`alt+l`) and `/status` both say.
**Closing one tab still asks.** `ctrl+w` on a conversation with work running raises a card
that names that work — `a task and a job running` — and waits for an answer. Leaving the
@@ -603,9 +613,9 @@ key arrives as ordinary `enter` and the message steers instead.
| `ctrl+t` | Start a **new chat** — the same start page the `+` at the end of the tab strip opens. Nothing is created until you send the first message, `esc` comes back, and the conversation you were in keeps its draft, its attachments and its work. On a home row it starts the fresh chat in that row's own folder, while clicking a `projects` row selects the folder for the next message |
| `ctrl+w` | **Close this tab** — the same thing the `✕` on it does. Selects the last-used remaining tab, or Home if none remain. Drafts are kept, and the conversation keeps running; a tab with work in it asks `keep running` / `stop work` / `cancel` first |
| `alt+t` (`opt+t`) | Give the keyboard to the task roster. Press again or `esc` to take it back |
-| `alt+l` | With a team's manager in front: show or hide the team's **Traffic**. On a wide window it is the right-hand column (the task column folds to its edge while it is up, and `ctrl+g` gives the column back to the tasks); on a narrow one it is a card over the lower part of the conversation, and `esc` closes it. Remembered for this window |
+| `alt+l` (`opt+l`) | Close the column on the right, or bring it back, in every chat: the same column holds the Tasks and, in a chat in a team, the Traffic. The key is named at the right of the column's header. Under 100 columns it lays the column over the body, and a second press takes it off. Remembered for the next session |
| `alt+m` | In a team that has a manager: go to the manager. Over `--host` against an older codeaf on the far machine it says managers are not available there |
-| `ctrl+g` | A foreground command that can be kept takes the key first. Otherwise close the task roster's column, or bring it back — the column stands even with no tasks in it. Remembered for the next session. On a frame under 100 columns with no roster raised and no command to keep, it does nothing |
+| `ctrl+g` | A foreground command that can be kept takes the key first. Otherwise it does what `alt+l` does: close the column, or bring it back; the column stands even with no tasks in it. Remembered for the next session |
| `ctrl+e` | Empty box: open or close the running conversation’s compact steps first; otherwise the newest `▸ worked` chip onto its outline of captions — the latest completed turn's out here, the newest settled phase's inside a task's page — or the most recent thinking block when there is no chip. A caption is a short status line per step; its tool rows are one expand further. Otherwise: go to end of line |
| `pgup` / `pgdown` | Scroll one page — the height of the view minus one, never less than one row |
| `tab` | Open or commit path completion, over a command's path argument only — and over an **empty** box with no completion showing, go back to the last conversation. Does nothing when this terminal holds only one |
@@ -668,7 +678,7 @@ rather than stopping at the edge.
**With the keyboard**, `shift` with any motion key selects: `shift+←`/`shift+→` by a
character, `shift+↑`/`shift+↓` by a line, `alt+shift+←`/`alt+shift+→` by a word,
`shift+home`/`shift+end` to a line's ends, and `cmd+a` takes the whole message. Those
-`shift` chords are the message box's; on the seven places `shift+←→↑↓` are already the
+`shift` chords are the message box's; on the eight places `shift+←→↑↓` are already the
time window that place is showing, so there they move the window and the pointer is how
you select.
@@ -1711,13 +1721,23 @@ reads `[m] send to main · [esc] cancel`, because reviving live work would dupli
## Keys in the settings panel and the other panels
-**Conversations view** (`alt+v`, `/wall`, `chats` or `▦` under the box, or `▦ All` on the strip): the
+**Conversations view** (`alt+v`, `/wall`, or `▦ All` on the strip or under the box): the
arrows move the focus · `enter` opens · `space` picks · `x` closes a view (the work keeps
running) · `m` its teams · `s` a new team · `e` the shown team's settings · `tab` or `1` to
`9` show a team · `/` filters · `?` lists every key, and each of its rows is a button. The
**team switcher** under the strip's team chip takes `↑` `↓` `enter` `esc`. The whole map is on
the *Conversations and teams* page.
+**Teams page** (`alt+2`, `/teams`): while the manager's conversation has the box, keys type
+into it; `alt+↑` `alt+↓` put the keyboard on the page's buttons and `esc` gives it back. On
+the buttons: `↑` `↓` walk, `←` `→` cross between the rail and the pane, `enter` presses,
+`s` the team's card · `c` close · `w` open on the conversations view · `n` new team (inside
+the chosen team) · `o` Organize · `m` Move into… another team · `space` pick a team for a
+move of several · `p` the members card · `M` a manager · `r` reopen · `d` delete a closed
+team · `u` Undo a close or a move · `esc` cancels a drag or a move's question. In the Move
+into… picker, typing filters, `↑` `↓` walk, `enter` moves, `esc` cancels. The whole map is on
+the *teams page* page.
+
**Settings panel** (`ctrl+,`): `esc` backs out one layer at a time — search, then an
open account, then the panel · `left`/`shift+tab` and `right`/`tab` change tab ·
`up`/`ctrl+p`, `down`/`ctrl+n`, `pgup`, `pgdown`, `home`, `end` walk · `enter` activates,
@@ -1726,8 +1746,7 @@ a phrase like `shell command` can be written · `backspace`, `ctrl+u`, `ctrl+w`
search · anything else types into it.
**Task page** (`ctrl+.`, or `/history`, or the one dim door line at the bottom of the task
-column — `ctrl+. earlier`, or `ctrl+. view more` where the column has only folded a
-family away): `esc` closes it — or clears the filter first, if one is being typed —
+column, `ctrl+. earlier`): `esc` closes it (or clears the filter first, if one is being typed),
and `ctrl+.` closes it either way · `up`/`ctrl+p`, `down`/`ctrl+n` move, stepping
over the `running` and `earlier` section words · `pgup`/`pgdown` move twelve · `home`/`end`
first and last · `enter` opens the row · `backspace`, `ctrl+w` and `ctrl+u` edit the filter
@@ -1781,7 +1800,7 @@ reads `⟲ rewind — pick where the conversation goes back to` and its foot
`⟲ drops 2 turns — everything below the pick is let go` above
`esc close · ↑↓ move · enter picks the point` — which becomes
`esc close · ↑↓ move · enter again rewinds here` once a pick is placed, and
-`esc clears the search · ↑↓ move · enter picks the point` while you are typing one.
+`esc clear the search · ↑↓ move · enter picks the point` while you are typing one.
Clicking a row places the pick; clicking the placed point rewinds; the wheel walks the
cursor. The sessions and rewind page describes what the cut does.
@@ -2169,24 +2188,24 @@ to filter, `↑↓` to walk, `enter` to use it, `esc` to go back to the layer.
**Press the space bar twice with an empty message box.** That is the way back to home from
inside a conversation, and `/home` opens it too.
-**There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the five words on
-the tab bar, `home chats tasks spend settings`, and each answers to its position there,
-`alt+1` through `alt+5`. **`alt+6`, `alt+7` and `alt+8` are kept**, on the three places that
+**There is also a number: `alt+1` (`opt+1` on a Mac).** Home is the first of the six places on
+the top line, `home teams chats sessions spend settings`, and each answers to its position there,
+`alt+1` through `alt+6`. **`alt+7`, `alt+8` and `alt+9` are kept**, on the three places that
are off the bar — standing, memory and search — so those keys still open a room rather than
-doing nothing; `alt+.` draws them all with their numbers. `alt+2` is `chats`, the way back
-to the conversation in front (a new chat when none is open); it is not a room, so `tab` steps
-over it. Hold
+doing nothing; `alt+.` draws them all with their numbers. `alt+2` is the teams page, and
+`alt+3` is `chats`, the way back to the conversation in front (a new chat when none is open);
+it is not a room, so `tab` steps over it. Hold
`alt` and press the digit. On macOS codeaf draws the modifier as `opt+`, after the name on
that keycap; it is the same key and the same chord, and on Linux and on Windows it is drawn
`alt+`. It arrives in every terminal codeaf runs in, which is why the numbers are on `alt`
rather than on `ctrl`.
-**`ctrl+1` … `ctrl+8` are a second spelling, on the terminals that can send them.** `ctrl`
+**`ctrl+1` … `ctrl+9` are a second spelling, on the terminals that can send them.** `ctrl`
and a digit has no encoding in the forty-year-old scheme most terminals speak, so it is not
the first spelling and never will be — but a terminal running the kitty keyboard protocol
sends exactly the keys that scheme cannot spell, and it tells codeaf it does. Where that
-report arrives, `ctrl+1` … `ctrl+8` jump to the same seven places and `ctrl+.` draws the same
-map, and the map's own line says `alt+1…8 or ctrl+1…8 go to a place` so you can see it is
+report arrives, `ctrl+1` … `ctrl+9` jump to the same eight places and `ctrl+.` draws the same
+map, and the map's own line says `alt+1…9 or ctrl+1…9 go to a place` so you can see it is
live. Where it does not, those chords do nothing and are never advertised. kitty, ghostty,
WezTerm, foot and Windows Terminal are the usual ones that report it. **On a Mac this is the
way in that needs no setting at all** — see "Why my option key types ¡ ™ £ instead of
@@ -2197,7 +2216,7 @@ place key that does: `tab` belongs to the composer's path completion while you a
and the rest of the place grammar — `→` for the row's verbs, `alt+` for how a place
is shown, `shift+←→↑↓` for its time window — is about the room you are standing in. Every
number opens its room whatever is in it: a place with nothing of its own to draw spends the
-frame saying what it is for, and none of the seven is ever a key that does nothing. **On
+frame saying what it is for, and none of the eight is ever a key that does nothing. **On
such a page the line under the box names only the way out** — `tab next place · esc` — on
tasks, on standing orders and on memory alike: a foot that offered `enter` or `type to
filter` over a body with no rows would be naming a key with nothing to act on.
@@ -2299,9 +2318,9 @@ not on this chord — it is the `thinking` row of `/settings`, and *alt+e — ho
thing you are looking at thinks* says why.
With the mouse: a click puts the cursor on a row and a second click on that row opens it.
-The wheel walks the list three rows a turn, and the **tab bar above the panels is a
-control** — clicking a place's word goes there, and clicking a gap between two words does
-nothing.
+The wheel walks the list three rows a turn, and the **places on the top line are
+controls**: clicking a place's word, or the cell either side of it, goes there, and clicking
+the air around the words does nothing.
**Under 60 columns those two clicks are one.** At phone width home is an inbox and a
row's card is a full-frame sheet, so a tap selects and opens in one gesture; the sheet's
@@ -2364,9 +2383,11 @@ to it. `ctrl+c` does not close home — it quits codeaf, with home still up.
## The tab bar is a row the cursor can stand on — ↑ off the top row, and ←/→ along the words
-**On every place, `↑` from the first row of the page lands the cursor on the tab bar** —
-the row of four words under the top line (five while you stand in standing, memory or
-search, whose word is drawn after the four). The word you are standing in wears the cursor's
+**On every place, `↑` from the first row of the page lands the cursor on the tab bar**,
+the six place words on the top line after the `codeaf` wordmark (seven while you stand in
+standing, memory or search, whose word is drawn after the six). The chat tab strip is
+not on a place. It is the row under that top line only while a conversation is in front.
+The word you are standing in wears the cursor's
band there instead of its usual mark, and five keys mean something on that row:
| Chord | What it does while the cursor is on the bar |
@@ -2375,10 +2396,10 @@ band there instead of its usual mark, and five keys mean something on that row:
| `enter` | go into the place under the cursor |
| `↓` | the same — go into the place under the cursor |
| `esc` | back into the page, on the row you walked up from. It does **not** close the place |
-| `↑` | nothing. Above the bar is the top line, which is a reading rather than a control |
+| `↑` | nothing. The bar is on the top line, and there is nothing above it |
Everything else means exactly what it means everywhere else: `tab` and `shift+tab` are the
-next and previous place, `alt+1` … `alt+8` jump, `alt+.` draws the map, and **any printable
+next and previous place, `alt+1` … `alt+9` jump, `alt+.` draws the map, and **any printable
key goes into the composer** — taking the cursor back down into the page with it, because
somebody who has started typing has stopped looking at the bar.
@@ -2398,7 +2419,7 @@ between the tabs with the arrow keys*.
## `b` on the spend place — the letter that opens the limits, and the money figure you can press
-On the spend place (`/spend`, or `alt+4`) two things lead to the money limits, and neither
+On the spend place (`/spend`, or `alt+5`) two things lead to the money limits, and neither
one is an editor on that page — the page answers *what did it cost*, and the Spending tab
of `/settings` is the one place *what may it spend* is set.
@@ -2423,30 +2444,34 @@ ink once this conversation has spent four fifths of its own `per conversation` l
## Keys in the task roster and inside a room
**While the task roster holds the keyboard** (`alt+t`, `opt+t`): `esc` gives the keyboard
-back · `up`/`down` move · `right`/`left` open and fold · `enter` opens that row's room ·
-`alt+w` widens the column and narrows it again. Its hint reads exactly
-`↑↓ move · →← tree · enter open · alt+w wide · esc`. On a row whose work is still running or
-still queued the hint gains one more clause before `esc` — `alt+e think harder`, which
-moves that task's thinking rung. On an ordinary finished task, it saves the rung
-for when you continue; it does not restart work or rewrite the last attempt.
+back · `up`/`down` move over the needs-you band, the group headings and the tasks ·
+`enter` opens a task's room, opens or folds a group on its heading, and opens a band row's
+thing · `left`/`right` switch the column between Tasks and Traffic in a chat in a team ·
+`x` stops the task under the cursor · `alt+w` widens the column and narrows it again. Its
+hint reads exactly `↑↓ move · enter open · x stop · alt+w wide · esc`, led by
+`←→ tasks/traffic · ` in a chat in a team. On a row whose work is still running or still
+queued the hint gains one more clause before `esc`, `alt+e think harder`, which moves that
+task's thinking rung. On an ordinary finished task, it saves the rung for when you
+continue; it does not restart work or rewrite the last attempt.
**Widen is a chord and not the bare letter `w`.** It used to be `w`, and `w` was read
before the message box: a sentence typed while the roster still held the keyboard came out
as `riting the port` and `orktree`. Every bare letter on this surface is either a key on a
modal page with no message box, or an answer to a question drawn on screen, pressed over an
-empty box — and widening a column is neither, so it took a chord. The bare `w` still works
-on the **full-frame roster** (`alt+t` under about 100 columns, where the roster is drawn
-over the whole frame and there is no message box on screen). The column's own footer says
-`alt+w widen · click seam` or `alt+w narrow · click seam`, and clicking the seam — the
-column's two leftmost cells — does the same thing with the pointer. Both the offer and
-the handle exist only from 120 columns up, which is the only frame that lends the wider
-tier; narrower than that those two cells belong to the row under them and open its task.
-
-**`→` and `←` fold two things, and it is one gesture.** On a family's root row they open
-and close the family. On a row whose **work has finished** they open and close that row's
-own detail line — the merge word and price — which a finished row keeps folded so that the
-column's height goes to work that is still moving. A job's log path is not on that fold:
-it is on the job's page. `→` on anything else does nothing.
+empty box, and widening a column is neither, so it took a chord. The bare `w` still works
+on the **full-frame roster** (`alt+t` under 100 columns, where the column is drawn over the
+whole frame and there is no message box on screen). The column's own footer says
+`alt+w widen · click seam` or `alt+w narrow · click seam`, and clicking the seam (the
+column's two leftmost cells) does the same thing with the pointer. Both the offer and the
+handle exist only from 120 columns up, which is the only frame that lends the wider
+column; narrower than that those two cells belong to the row under them and open its task.
+
+**Folding is the headings' and nothing else's.** `Queued`, `Waiting` and `Done` start folded
+to one heading line; `enter` on the heading, or a press on it, opens the group, and it stays
+open for the session. `Running` never folds. A task row has no fold of its own: what it
+used to fold away (the merge word, the price, the branch) is on the hint line when the
+pointer is on it. `←` and `→` no longer fold anything; in a chat in a team they switch the
+column's two words, and elsewhere they do nothing.
**The walk stops at this conversation's last job, after its last task.** The roster holds
this conversation's work, then the jobs section under it, so `↓` walks both and clamps at
@@ -2454,46 +2479,43 @@ the bottom rather than carrying on into the project's record. Old tasks from ear
sessions are on the sessions place, reached from the column's own `ctrl+. earlier` line, from
`ctrl+.` or from `/history`; `enter` on an `earlier` row there goes inside that task's
card. In a directory whose earlier sessions ran tasks but where **this** conversation has
-run none and started no jobs, `alt+t` falls through — there is nothing on the column to
+run none and started no jobs, `alt+t` falls through: there is nothing on the column to
put a cursor on. A session that has only started a server still has the jobs section, so
`alt+t` takes it.
**The column's other lines take no cursor.** Its `standing` section, and the two `+` rows
-that close each of those sections (`+ /task`, `+ /standing`), are the pointer's — the walk
-skips them. The `jobs` section does take the cursor: the label, then every job row the
-column actually drew. Their keyboard equivalents for standing are the commands themselves:
-`/standing` opens the standing orders page, and typing `/task ` is exactly what pressing
-`+ /task` puts in the box. **No new key is added to the column by either of them.** `enter`
-on the `jobs` label toggles the section; `enter` on a job row opens that job's page.
-
-**Under 60 columns the TASKS PAGE this column reaches is a thumb's, not a keyboard's** — the
+(`+ /task`, `+ /standing`), are the pointer's; the walk skips them. The `jobs` section does
+take the cursor: the label, then every job row the column actually drew. Their keyboard
+equivalents for standing are the commands themselves: `/standing` opens the standing orders
+page, and typing `/task ` is exactly what pressing `+ /task` puts in the box. `enter` on the
+`jobs` label toggles the section; `enter` on a job row opens that job's page.
+
+**Under 60 columns the TASKS PAGE this column reaches is a thumb's, not a keyboard's**: the
column itself is unchanged, and its own keys are the ones above. The page's rows become
two-line cards a tap opens, its foot is a `‹ back` bar in place of the key legend
`enter open its room · alt+s sort · type to filter`, and the strip that opens it is one full-width door
-(`▸ 3 tasks · 1 running`) rather than a row of chips. Mouse motion is ignored — a tap opens
+(`▸ 3 tasks · 1 running`) rather than a row of chips. Mouse motion is ignored: a tap opens
in one gesture. The tasks page describes the phone flow in full.
-**`ctrl+g` closes the roster's column, and opens it again when it has no foreground
-command to keep.** It works from the message
-box, from inside a room, and while the roster holds the keyboard — it is the one key
-here you do not have to ask for the roster first to use. Closing it hands the keyboard
-back to the box. The choice is written to your profile as `ui.task_column`, so the next
-session opens the way you left it, and `alt+t` counts as asking for the column back.
+**`alt+l` closes the column, and opens it again.** It works from the message box, from
+inside a room, and while the roster holds the keyboard: it is the one key here you do not
+have to ask for the roster first to use. It is the same key in every chat, whichever word
+is in front, and the column's header names it at its right (`opt+l` on a Mac). Closing it
+hands the keyboard back to the box. The choice is written to your profile as
+`ui.task_column`, so the next session opens the way you left it, and `alt+t` counts as
+asking for the column back. `ctrl+g` does the same while no foreground command can be kept;
+while one can, `ctrl+g` backgrounds the command instead.
**A closed column leaves a two-column edge down the right of the frame with a `❮` in it,
drawn in ink, and clicking anywhere on that edge opens the column again. Clicking the
-column's `❯` door while it stands closes it** — one chevron control, two states, so the
-pointer can go both ways. With no foreground command to keep the line says
-`❯ ctrl+g hide`; while a command owns `ctrl+g`, it says only `❯ hide`. The chevron
-works in both states; the keyboard chord belongs to the command in the second one.
-When no command can be kept, the key falls through and does nothing only when there is
-no roster on the frame to close: a frame under 100 columns where nothing has raised the overlay. It works with
-no tasks at all — the column stands with only its `+ /task` and `+ /standing` doors, with the
-`ctrl+. earlier` door under them if earlier sessions ran anything, and either way an
-empty column is still a column to close. On the untouched empty screen there is no
-column yet; there `ctrl+g` is a first keystroke like any other — the greeting goes and
-the key then closes the column it would just have raised, so a second press brings it
-back (*The empty screen* page).
+`alt+l` at the right of the column's header while it stands closes it**, so the pointer can
+go both ways. `alt+l` works with no tasks at all: the column stands with only its header,
+its `+ /task` and `+ /standing` doors, and the `ctrl+. earlier` door under them if earlier
+sessions ran anything, and either way an empty column is still a column to close. Under 100
+columns there is no column beside the conversation, and `alt+l` lays it over the body
+instead. On the untouched empty screen there is no column yet; there the key is a first
+keystroke like any other: the greeting goes and the key then closes the column it would
+just have raised, so a second press brings it back (*The empty screen* page).
**With a room open:** `esc` leaves the room, though a history recall walk is
cancelled first · `enter` steers the node (see *What steering a task looks like on its
@@ -2712,11 +2734,14 @@ Only the left button acts. A press is resolved in this order:
terminal the strip chip the roster's cursor is on carries a `✕` of its own, and
pressing it asks to stop that work instead of opening its room. **When the column is
closed, the two-column edge it leaves at the right of the frame answers here too** —
- a press anywhere on it opens the column again. With no foreground command to keep,
- `ctrl+g` does that too; while a command can be kept, the key backgrounds that command.
- Within the column, its own lines are asked before its task rows: a `+ /task` or
- `+ /standing` row types that command into your message box, and a row in the
- `standing` section opens `/standing` with the cursor already on that order.
+ a press anywhere on it opens the column again; `alt+l` does that too.
+ Within the column, its own lines are asked before its task rows: the header's words
+ (the other word brings its view to the front, and the `alt+l` at the right closes the
+ column), a needs-you band row (it opens its thing), a group heading (it opens or folds
+ the group), a Traffic row, a `+ /task` or `+ /standing` row (it types that command into
+ your message box), and a row in the `standing` section (it opens `/standing` with the
+ cursor already on that order). Whatever lights under the pointer is exactly what a press
+ there does.
9. The figures on the status row: the **money figure** (`$0.14`) and the cache beside it,
which open the **Spending** tab of `/settings`; and the **context meter**
(`66.8k/1.3M · 5%`) and the compaction forecast, which print `/status`. Each brightens
@@ -2781,8 +2806,8 @@ the wheel walks the **cursor** rather than a scroll offset of its own, because o
window follows the cursor.
**The task column on the right scrolls under the pointer** and leaves the conversation
-beside it where it is. It moves the column's own window — running work stays pinned at the
-top, and the `tasks` label with it — unless the column is holding the keyboard (`alt+t`),
+beside it where it is. It moves the column's own window (the header and the needs-you band
+stay pinned at the top) unless the column is holding the keyboard (`alt+t`),
in which case the window is already following the cursor and the wheel walks that instead.
Reaching the bottom **re-arms sticking**, so new replies follow along again. Scrolling
@@ -2913,14 +2938,13 @@ status line says what was copied instead of opening anything. See "selecting tex
your mouse" above.
**There is nothing under the pointer.** A click on empty space does nothing anywhere on
-this surface, including the gap between two words of the tab bar and the blank rows of a
-task's page. A click in copy mode acts on nothing at all, because those rows are a frozen
+this surface, including the air around the place words on the top line and the blank rows of
+a task's page. A click in copy mode acts on nothing at all, because those rows are a frozen
snapshot.
-**The terminal is too narrow for the word you are aiming at.** The tab bar gives up words
-as the frame narrows, and at its narrowest it carries only the place you are standing in —
-so on a narrow window there is no other place-word on screen to click. `tab`, `shift+tab`
-and `alt+1`…`alt+8` still go everywhere.
+**The terminal is too narrow for the word you are aiming at.** The top line folds its
+trailing places into `more ▾` as the frame narrows; click `more ▾` and pick the place from
+its menu. `tab`, `shift+tab` and `alt+1`…`alt+9` still go everywhere.
**A file path is your terminal's click, not codeaf's** — usually **cmd+click**
(ctrl+click on Linux). If a plain click on a path does nothing, that is why.
@@ -3138,7 +3162,7 @@ answer:
| `alt+e` | **Bound**, on three surfaces: it moves how hard the thing you are standing on thinks — this conversation from the message box, a task, or a standing item on home. The machine's own default is the `thinking` row of `/settings` and is not on this chord. See "The thinking chip above the message box" and "alt+e — how hard the thing you are looking at thinks". Anywhere else it does nothing. On macOS it is shown as `opt+e`; the terminal must send Option as Alt/Meta, as for the other Option shortcuts |
| `ctrl+x` | Bound in three places: it drops a harness design from inside its room; on home it stops a standing item for good; and on a `tasks` row of home that this window holds it asks to stop that task (`ctrl+x stop it` on the `alt+.` map; the foot under a field row is the resting sentence and does not name it). Not bound anywhere else |
| `ctrl+y`, `ctrl+z` | Not bound |
-| `ctrl+` | **Bound as a second spelling of the place keys, on the terminals that report they can send it.** `ctrl` and a digit has no encoding in the scheme most terminals speak, which is why `alt+1` … `alt+8` (`opt+1` … `opt+8` on a Mac) are the first spelling and always will be, but a terminal running the kitty keyboard protocol sends it and says so, and where that report arrives `ctrl+1` … `ctrl+8` reach the same seven places. The map's line says `alt+1…8 or ctrl+1…8 go to a place` exactly when the alias is live. Where the terminal has said nothing, the chord does nothing and is never drawn |
+| `ctrl+` | **Bound as a second spelling of the place keys, on the terminals that report they can send it.** `ctrl` and a digit has no encoding in the scheme most terminals speak, which is why `alt+1` … `alt+9` (`opt+1` … `opt+9` on a Mac) are the first spelling and always will be, but a terminal running the kitty keyboard protocol sends it and says so, and where that report arrives `ctrl+1` … `ctrl+9` reach the same eight places. The map's line says `alt+1…9 or ctrl+1…9 go to a place` exactly when the alias is live. Where the terminal has said nothing, the chord does nothing and is never drawn |
| `ctrl+.` | Two meanings, on two screens that cannot both be up. In a conversation it is every task this project has run (`/history`); while a place is standing it draws the key map, on the terminals that can send `ctrl+` |
| `alt+` | Bound **only where a place says so, and only on that place**. `alt+s` changes the shelf on the memory place; `alt+b` and `alt+f` are the word jumps inside every box and are never taken by a place. Every other `alt+` does nothing |
| `shift+←` `shift+→` `shift+↑` `shift+↓` | The **time window** of a place that has one: `shift+←→` moves it by its own length, `shift+↑↓` changes how coarse it is. Three places have one — tasks (when it ran), standing (when it fired) and spend (which days) — and each draws the same control on its head row, `shift+← aug 12 – aug 25 →` with `shift+↑ coarser` beside it. Anywhere else, on a terminal too narrow to draw the control, and (for the zoom alone) on a line with no room for its clause, they do nothing |
@@ -3179,8 +3203,8 @@ When more than one command is running at once, `ctrl+g` takes **the one that has
been running longest**, which is the one you are waiting on.
While a command can be kept, this meaning takes precedence over hiding or restoring the
-task column, so the column stays where it was. With no such command the key belongs to
-the column as described above. `ctrl+b` is copy mode and `esc` interrupts; neither changes.
+task column, so the column stays where it was. With no such command the key does what
+`alt+l` does: it closes the column or brings it back. `ctrl+b` is copy mode and `esc` interrupts; neither changes.
## When a settings change lands
diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md
index a98f535129..2003f688d8 100644
--- a/internal/manual/chat/models-and-cost.md
+++ b/internal/manual/chat/models-and-cost.md
@@ -2228,7 +2228,7 @@ covers the requests made before the restart.
## Is there a record of what I spent across all my conversations, by day or by model
-Yes, a file on disk, and **the spend place reads it**. Press `alt+4`, or `tab` to it from any
+Yes, a file on disk, and **the spend place reads it**. Press `alt+5`, or `tab` to it from any
other place, and it draws that file: which days, which models, and what the money was for.
Every cost line written into a conversation's transcript is also appended to one file for the
@@ -2284,7 +2284,7 @@ breakdown; `/spend` opens the ledger for the whole machine.
## The spend place — what days and models cost, and what the money was for
-`alt+4` opens it. It reads the machine-wide ledger above when you walk in and again on the
+`alt+5` opens it. It reads the machine-wide ledger above when you walk in and again on the
same three-second beat every place runs on, and it draws three things:
- **the window and its total** — `14 days came to $34.10 · 41.2M tokens` on the left of the
@@ -2846,7 +2846,7 @@ the same registry row, so what you set through one is what the others show:
| --- | --- |
| `/budget`, also `/limits` | opens the tab with the cursor on `per day` |
| the money segment on the status line | press `$0.14` — it opens the tab. It brightens under the pointer to say it is a door |
-| the spend place (`alt+4`) | `enter` on its first line, the dim `today $3.42 of $500 · /budget sets the limits`, the same figure the top line of every place draws |
+| the spend place (`alt+5`) | `enter` on its first line, the dim `today $3.42 of $500 · /budget sets the limits`, the same figure the top line of every place draws |
| the spend place, from a row | `→` opens the verb strip, where `b` is `the limits` |
| a refused turn | the message names `/budget` |
| the first-run setup | its `Models and spending` screen, whose **Daily limit** row writes this same row. It asks about the day's limit only — `per plan` and `per conversation` keep their defaults there and are changed here |
@@ -2910,7 +2910,7 @@ Spending tab and `/cost` are the two readings — `/cost` is this conversation,
the whole machine since midnight.
The machine's day is drawn in **three** places and they are **one reading of one file**:
-`today` on the Spending tab, `today $3.42 of $500` on the spend place (`alt+4`), and the
+`today` on the Spending tab, `today $3.42 of $500` on the spend place (`alt+5`), and the
green figure on the **top line of every place** — `$3.42 / $500.00`, beside the clock. All
three sum the same rows of the machine ledger, so they cannot come apart, and the top line
says the same thing whichever place you are standing on.
@@ -3472,7 +3472,7 @@ it adds up to, and where to see it.
There is exactly one request codeaf makes that its own money figures do not count: the
**one-token measurement** it sends while you are typing, to warm the connection and time
the provider your next message is heading for. Your provider bills you for it. `/cost`,
-the status line, the spend place (`alt+4`) and the total at the end of `codeaf do` all
+the status line, the spend place (`alt+5`) and the total at the end of `codeaf do` all
leave it out, and so do the call-log rows and `codeaf-census`.
**Why it is missing.** Those figures are all counts of the **call log**, and the
diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md
index 1dba209b64..eb91d29705 100644
--- a/internal/manual/chat/places.md
+++ b/internal/manual/chat/places.md
@@ -1,67 +1,78 @@
# Places
-## What a place is, and the seven of them
+## What a place is, and the eight of them
-A **place** is a full-screen room in codeaf that is not this conversation. There are seven,
-and they are always in the same order — the four on the tab bar, then the three reached by
-their command:
+A **place** is a full-screen room in codeaf that is not this conversation. There are eight,
+and they are always in the same order: the words on the top line, then the three reached by
+their command, with `chats`, the way back to your conversations, third on the line:
-`home` · `sessions` · `spend` · `settings` · `standing` · `memory` · `search`
+`home` · `teams` · `chats` · `sessions` · `spend` · `settings` · `standing` · `memory` · `search`
-**The tab bar draws five words:** `home chats tasks spend settings`. `chats` is not a
-room: it is the way back to your conversations (*The `chats` word on the bar* below). Standing, memory and search are
+**The top line draws six places:** `home teams chats sessions spend settings`. **Teams** is
+right after home: your teams, what waits on you from them, and the selected team's manager
+conversation (the **Teams page** of this manual has all of it). `chats` is not a room: it is
+the way back to your conversations (*The `chats` word on the bar* below). Standing, memory and search are
places all the same — `/standing`, `/memory`, `/search`, their digit, the map and the typed
box all reach them — and while you are standing in one its word is on the bar after the
-four, so the bar always says where you are.
+six, so the bar always says where you are.
-They are drawn as a **tab bar** on the second row of every place, under the top line — the
-row of words at the top of the screen, `home chats tasks spend settings`, is this bar. It
-is the same row as the conversation's tab strip: the same line, the first word in the same
-column, the same air between two words, and the word you are in on the same ground as the
-conversation in front, so moving between a place and a chat never moves the top of the screen. The
-one you are standing in wears a filled band; the rest are dim. Nothing else on the surface
-looks like that bar, so "which place am I in" is one glance.
+They are drawn on the **top line of every page**, right after the `codeaf` wordmark, on a
+place and in a conversation alike. This manual still calls that row of words **the bar**:
-Every place is drawn in the same frame:
+```
+ >● codeaf home teams chats sessions spend settings 3 moving · $1.20 thu 10:31pm
+──────────────────────────────────────────────────────────────────────────────────────────
+```
+
+The place you are standing in is **lit in the accent colour**; the other words are muted.
+Inside a conversation the lit word is `chats`. Nothing else on the surface looks like that
+row, so "which place am I in" is one glance, and the words sit in the same cells on every
+page, so moving between a place and a chat never moves the top of the screen.
-1. the top line — this machine's own signs: what is on watch, what today has cost, the time
-2. the tab bar — the four words, and the one you are in when it is not one of them
-3. a dim rule
-4. the place's own body
-5. a rule, then the **composer** — one line you can type into, wherever you are
-6. the hint line — what the keys do here
+Every place is drawn in the same frame. A place spends three rows before its body. A
+conversation spends four, because the tab strip is that conversation's own row:
+
+1. the top line: the wordmark, the places, and on the far end this machine's own signs,
+ what wants you, what is moving, what today has cost, the time. This row does not move.
+2. a dim rule, then a blank. In a conversation the **tab strip** of your open chats sits
+ between the top line and that rule. A place does not draw it.
+3. the place's own body, one row higher than a chat's body
+4. a rule, then the **composer**, one line you can type into, wherever you are
+5. the hint line: what the keys do here
`esc` leaves a place and puts you back in the conversation you were in. Places are not
stacked: opening one closes whichever was up, so `esc` is always one press from the chat.
## The `chats` word on the bar: how do I get back to my conversation from a place
-**`chats` is the second word on the bar, and it is the way back.** Click it, press `alt+2`
-(`opt+2` on a Mac), or walk the bar's cursor onto it and press `enter`: the place closes and
+**`chats` is the third word on the bar, and it is the way back.** Click it, press `alt+3`
+(`opt+3` on a Mac), or walk the bar's cursor onto it and press `enter`: the place closes and
the conversation that was in front before you opened it is in front again, with its draft
and its scroll where you left them. With no conversation open at all, it opens the new-chat
page instead, so the word never leads nowhere.
-It is not a room. It never wears the filled band, `tab` and `shift+tab` step over it
-(they walk the rooms), and it has no count. `esc` still goes to home, as on every place.
+It is not a room. It is the lit word while you are in a conversation, because that is where
+the tab strip of your chats is drawn; `tab` and `shift+tab` step over it (they walk the rooms), and it has
+no count. Its hint says `every conversation, one at a time`, which is how it differs from
+`▦ All` on the strip: that one is the grid of the tabs you have open, all at once. `esc` still goes to home, as on every place.
`alt+k` is a different key: it opens the chats switcher to choose *which* conversation, while
`chats` on the bar goes straight back to the one you were in.
## How to get to a place — the keyboard shortcut to jump between pages
-Four ways, and they all reach the same seven rooms:
+Four ways, and they all reach the same eight rooms:
- **`tab`** — the next place **on the bar**, round again from the last. **`shift+tab`** —
the one before. From standing, memory or search, `tab` goes on round the bar to home.
-- **`alt+1`** … **`alt+8`** (**`opt+1`** … **`opt+8`** on a Mac), jump straight to one, **from a
- place or from a conversation**. `alt+1` … `alt+5` are the tab bar's own order (home,
- chats, tasks, spend, settings), and `alt+6`, `alt+7`, `alt+8` are the three places off the bar:
+- **`alt+1`** … **`alt+9`** (**`opt+1`** … **`opt+9`** on a Mac) jump straight to one, **from a
+ place or from a conversation**. `alt+1` … `alt+6` are the tab bar's own order (home,
+ teams, chats, sessions, spend, settings), and `alt+7`, `alt+8`, `alt+9` are the three places off the bar:
standing, memory, search. Hold `alt` and press the digit. macOS draws the modifier as `opt`
because that is the key's name on a Mac keycap; Linux and Windows draw it `alt+`, and it is the same
chord either way. `tab` and the shift-arrows are not like them: in a conversation those
already belong to path completion and to the caret, so the digits are the one class of
place key that means the same thing wherever you are standing.
-- **`ctrl+1`** … **`ctrl+8`**, the same jump, on the terminals that can send it. `ctrl` and
+- **`ctrl+1`** … **`ctrl+9`**: the same jump, on the terminals that can send it. `ctrl` and
a digit has no encoding in the scheme most terminals speak, so this is a second spelling and
never the first: it works only where the terminal runs the kitty keyboard protocol and says
so (kitty, ghostty, WezTerm, foot, Windows Terminal are the usual ones). `ctrl+.` draws the
@@ -78,10 +89,11 @@ Four ways, and they all reach the same seven rooms:
to count, or nothing in it, says `a place` alone.
- **a command** — `/home`, `/history`, `/standing`, `/memory`, `/settings`. Each opens the
place it names.
-- **click the word** — the tab bar itself is the control. A press on a place's word goes
- there; a press in the gap between two words does nothing, and a press on the word you are
- already standing on does nothing (going there would throw away what you have typed and
- the row you are on).
+- **click the word**: the top line itself is the control, from a place or from a
+ conversation. Each word is a button a cell wider than the word on both sides, and a press
+ anywhere on that button goes there; a press on the air before the first word or after the
+ last does nothing, and a press on the word you are already standing on does nothing (going
+ there would throw away what you have typed and the row you are on).
`alt+` arrives in every terminal codeaf runs in. `ctrl+` does not exist as a
thing a terminal can send, which is why the numbers are on `alt`.
@@ -100,7 +112,7 @@ over one line: `work you send off with /task lands here, and its record stays`.
## How do I move between the tabs with the arrow keys — the tab bar is a row the cursor can stand on
**Press `↑` from the first row of the page you are on.** The cursor leaves the list and
-lands on the **tab bar** — the row of place words under the top line — and from there:
+lands on the **bar**, the row of place words on the top line, and from there:
| Key | What it does on the bar |
| --- | --- |
@@ -108,9 +120,9 @@ lands on the **tab bar** — the row of place words under the top line — and f
| `enter` | go into the place under the cursor |
| `↓` | the same as `enter` — go in |
| `esc` | back into the page, on the row you walked up from |
-| `↑` | nothing. There is nothing above the bar but the top line, which is a reading rather than a control |
+| `↑` | nothing. The bar is the top line; there is nothing above it |
| `tab` `shift+tab` | the next and the previous place, exactly as everywhere else |
-| `alt+1` … `alt+8` | jump straight to one, exactly as everywhere else |
+| `alt+1` … `alt+9` | jump straight to one, exactly as everywhere else |
| any printable key | goes to the composer, and the cursor comes back down into the page with it |
**The word your cursor is on wears the cursor's band**, in place of the mark the word you
@@ -138,27 +150,71 @@ page; a second `esc` is the page's own, and that one leaves.
## Does the tab bar do anything when I hover over it — the pointer on the words
-**Yes: the word under your pointer lifts.** Resting the mouse on a place's word paints that
-word in the brighter ink the word you are standing in wears, without the band under it — so
-the bar says "this one is a door" without pretending you are already in it. Moving the
-pointer off the word puts the ink back, and moving it into the gap between two words lifts
-nothing, because the gap belongs to no room.
+**Yes: the word under your pointer takes a ground**, the same hover ground every button on
+this surface wears, and the ground covers the button's pad cells too, so what lights is
+exactly what a press acts on. On a plain or 16-colour terminal, where a ground cannot show,
+the word wears a `·` in the cell before it instead. Moving the pointer off the word puts it
+back.
+
+**The hint line says what the word opens and its key** while the pointer rests on it:
+`alt+2 teams · the teams you hand work to`, `alt+3 chats · every conversation, one at a
+time`. On `more ▾` it says `more · the places this row has no room for`; on the strip's
+`▦ All` it says `The grid of your open tabs, and your teams · alt+v`.
+
+**A resize drops that hint.** Widening the window so `more ▾` is no longer drawn clears
+the hover, and the hint line stops saying `more · the places this row has no room for`
+without waiting for the pointer to move. The same is true of a place word the new row
+may not draw.
+
+## The hint line stayed after I resized, stale hover, more hint after widening
+
+**Resizing the window forgets a hover that pointed at a door the new layout may not
+draw.** That is `more ▾` once the places fit on the row, and a place word that folded
+away. The hint line reads the hover, so leaving it would keep naming a door that is
+gone until the pointer moved. After the resize the hint no longer says `more`.
+
+**Hovering moves nothing else at all.** No cursor, no page, no window, no focus. If the
+keyboard cursor is standing on the bar as well you will see both marks at once: the
+cursor's band on the word the cursor is on, the hover ground on the word the pointer is on.
+
+**The wheel over the bar walks the places** while you are on a place, one room a turn:
+`tab` and `shift+tab` under the hand that is already there. It is one room a notch and not
+three, because each step opens a room, and two rooms nobody asked to see is two filters
+thrown away.
-**Hovering moves nothing else at all.** No cursor, no page, no window — the pointer
-previews the tab words; list rows instead share one selection with the keyboard. If the
-keyboard cursor is standing on the bar as well you will see both marks at once: the band on
-the word the cursor is on, the lifted ink on the word the pointer is on.
+**Clicking still opens.** A press on a word goes there; a press on the air around the words
+does nothing; a press on the word you are already standing on does nothing.
-**The wheel over the bar walks the places**, one room a turn — `tab` and `shift+tab` under
-the hand that is already there. It is one room a notch and not three, because each step
-opens a room, and two rooms nobody asked to see is two filters thrown away.
+## Where did the places go: the top line holds the places, and why chat tabs are not showing on the home page
-**Clicking still opens.** A press on a word goes there; a press in the gap does nothing;
-a press on the word you are already standing on does nothing.
+**The places sit on the top line, after the `codeaf` wordmark. The tab strip of your chats
+is drawn only while a conversation is in front.** It is not on home, teams, sessions, spend,
+settings, or any other place. Chat tabs on the home page are not a thing this screen does.
+`chats` on the top line, `alt+k`, and home's sessions list are how you get to one.
+
+The places used to be a row of their own under the top line on a place, and the chat strip
+took that same row inside a conversation. For a while after that the strip stayed on every
+page, which repeated the teams rail and home's sessions. Now:
+
+- **The top line** is the wordmark, the six places, and the machine's signs on the far end.
+ It is identical on every page; only which word is lit changes. It never moves.
+- **The second line of a chat** is the tab strip: the team chip (` ● harbor ▾ `, or a quiet
+ ` teams ▾ `), `◆ Manager`, your tabs, `+` and `▦ All`. It has no `home` piece, since home
+ is the first word of the line above.
+- **On a place the second line is the rule**, then a blank, and the page starts on the next
+ row. A click there is the page's. There is no invisible tab under it.
+
+## Why is there no tab strip on the teams page
+
+**The tab strip is a chat's row.** Teams is a place, like home, sessions, spend and
+settings, so the row under the top line is the rule, then a blank, and the page starts
+there. The manager's conversation in the pane does not bring the strip back. `chats` on
+the top line, `alt+k`, and home's sessions list open a conversation, and the strip is on
+that chat.
## The mouse on a place — clicking a row, hovering, and the wheel
-Three gestures, the same on all seven places:
+Three gestures, the same on all eight places:
- **List rows have one selection.** On home, tasks, standing, memory, spend and search,
moving the mouse onto a row selects it. Keyboard navigation immediately takes over
@@ -340,8 +396,8 @@ may not name a key it has not bound. Six classes, and a key belongs to exactly o
| `↑` `↓` `enter` `esc` `tab` | move, open, back out, next place — and `↑` off the first row of the page moves onto the **tab bar**, which is a row the cursor can stand on (*How do I move between the tabs with the arrow keys*) |
| any printable key | goes to the composer, always |
| `alt+enter` | send what you typed off as a task |
-| `alt+1` … `alt+8` (`opt+1` … `opt+8` on a Mac) | jump straight to a place |
-| `ctrl+1` … `ctrl+8` | the same jump, only on terminals that report they can send it |
+| `alt+1` … `alt+9` (`opt+1` … `opt+9` on a Mac) | jump straight to a place |
+| `ctrl+1` … `ctrl+9` | the same jump, only on terminals that report they can send it |
| `alt+` | change how THIS place is shown |
| `alt+p` `alt+o` | inside the composer layer only: move the task, change its model |
| `shift+←` `→` `↑` `↓` | move this place's time window |
@@ -460,7 +516,7 @@ Press `alt+.` and the whole key map appears **in the cells you were already read
the keys for where I am standing. In a conversation the same key opens the `/help` sheet.
**The chord list is built from the place you are on.** It reads
-`alt+1…8 go to a place · alt+enter send it off as a task · → show what this row can do · esc close`
+`alt+1…9 go to a place · alt+enter send it off as a task · → show what this row can do · esc close`
— and the `→` clause is left out on a place whose rows have no verbs, such as search, rather
than naming a key that would open nothing there.
@@ -490,6 +546,19 @@ approvals and `alt+k` for chats; `alt+g` and `alt+q` are unbound there.
Clicking Home’s `since you left` heading opens memory. Questions appear as amber `?`
bullets on the conversation or task, with no separate `needs you` heading.
+## teams: your teams, what waits on you, and each team's manager
+
+The second place on the bar, right after home. On the left is a **rail**: your teams as a
+tree (a sub-team indented under its parent), each with its colour, and a mark only when
+something is happening in it: a dim `⠿` while one of its members is working, and an amber
+`? 2` while two things wait on you. Under the tree are `+ New team` and `✦ Organize`, and
+at the foot a folded `▸ Closed · N` holds the teams you closed. On the right is the team
+you chose: a header with what it has spent today against its cap and three buttons,
+`Settings`, `Close…` and `Open ▦`; a line of its members, every one of them, open in this
+window or not; the decisions waiting on you, as cards you answer with one press; and the
+team manager's own conversation, which you talk to right there. `/teams`, `alt+2` and a
+click on the word open it. The **Teams page** of this manual has the whole of it.
+
## tasks — the tasks page, and how to get to it without a command
The full-screen conversation tree groups every chat and its tasks into **running** and
@@ -500,7 +569,7 @@ completed; new work moves it back. Each task retains its own state word.
Both sections default to newest activity first; click the age heading to reverse. Conversation titles match Home, and the same
bullets mark running work, unread replies and unanswered questions. Every conversation and nested task
starts expanded, with connecting tree lines and fold arrows immediately after titles in the left column. Projects
-have their own column. You can fold a branch yourself. `/history`, `ctrl+.` and `alt+3` open it.
+have their own column. You can fold a branch yourself. `/history`, `ctrl+.` and `alt+4` open it.
**Typing here narrows the list.** While this place is up every printable key goes to its
filter — the one exception being `1` and `2` over a row the record pane beside the list is
@@ -539,7 +608,7 @@ them. Its existing stop action remains available when that engine supports it.
The orders that fire on their own, on four shelves each under its own heading: this
conversation's, this project's, the machine's, and then `in other projects` — everything
else standing on this computer that does not reach the conversation you are in.
-`/standing` and `/orders` open it, and so does `alt+6`. It is not on the tab bar.
+`/standing` and `/orders` open it, and so does `alt+7`. It is not on the tab bar.
`enter` opens where an order was asked for. `→` opens the row's verbs — `p pause`, `s stop`,
and `n not here` on the three shelves that reach this conversation. Those three used to be
@@ -560,7 +629,7 @@ The standing orders page has the whole of it.
## memory — what is held true
What codeaf holds true about you and this machine, with what kind of thing each line is, how
-it has done, `helped 19 · bore on 3`, and how old it is out at the right. `/memory` and `/memories` open it, and so does `alt+7`. It is not on the tab bar.
+it has done, `helped 19 · bore on 3`, and how old it is out at the right. `/memory` and `/memories` open it, and so does `alt+8`. It is not on the tab bar.
The page is **shelves** — you, this project, this machine — biggest first, with the biggest
one open and the rest rolled up. Type to filter what is already on the page, **every letter
@@ -584,7 +653,7 @@ machine · /remember adds a line`.
## spend — what it cost, and why it counts another session or another machine
-What this machine has cost, by the day, by the model, and by what it was for. `alt+4` opens
+What this machine has cost, by the day, by the model, and by what it was for. `alt+5` opens
it. It reads one machine-wide ledger — a line per model call — so the figures are the bill
and not an estimate.
@@ -632,7 +701,7 @@ different thing — its head row stays, with the arrows that page it back.
## search — finding anything said or run, and what it matches when memory is off
-Everything that has been said on this machine. `alt+8` opens it, it is not on the tab
+Everything that has been said on this machine. `alt+9` opens it; it is not on the tab
bar — and typing searches: the
matches come back with the conversation they were said in, how long ago, and the project it
belongs to, with your own words picked out in the line.
@@ -642,16 +711,16 @@ read happens after the box has been quiet for a moment, never on the keystroke,
never waits on a search.
`enter` opens the conversation the matching turn was said in, and **the foot says so**:
-`enter opens it at that turn · ↑↓ pick · type to search · esc clears the words · tab next
+`enter opens it at that turn · ↑↓ pick · type to search · esc clear the words · tab next
place`. With nothing typed there is no row to stand on, so the foot drops to `type to
-search · esc clears the words · tab next place` — `enter` is not named where it does
+search · esc clear the words · tab next place` — `enter` is not named where it does
nothing. (This foot used to be the router's own default, which said `enter talk about it`
and was wrong in both states.) Above the results, a legend counts the projects the matches
came from. Twelve conversations are shown and the rest fold into one line, `▸ 38 more`;
`enter` or a click on it draws them all, and `▾ 38 fewer` folds them back. A new search
starts folded.
-**`/search` is the typed door onto this place**, beside `alt+8` and the map. It
+**`/search` is the typed door onto this place**, beside `alt+9` and the map. It
takes no argument: the place is a box, and typing in it searches.
With nothing typed the place is its heading `search` over one line saying what to do:
@@ -681,50 +750,47 @@ would be two rankings that could disagree.
## settings — how this machine is set
Every setting, in sections, with a search that crosses all of them. `/settings`, `/set` and
-`/config` open it, and so does `alt+5`.
+`/config` open it, and so does `alt+6`.
It has a **second bar** under the place bar: its own sections. Those two bars are not a
repetition — the upper one is the places, the lower one is settings' own pages. `←` and
`→` move between sections. `tab` does **not**: it is the way to the next place, here as
everywhere.
-## Why the tab bar looks squashed on a narrow terminal — the places at 60 columns
+## Why the tab bar looks squashed on a narrow terminal: the places at 60 columns, and what does more on the top line do
-**All five words are on the bar at 60 columns, and down to 42.** The five words with the
-padding each chip carries and the air between them are 50 cells; under that the bar gives up
-the *air between the chips*, a cell at a time, before it gives up a word, so a 42-column pane
-draws
+**The top line gives things up in a fixed order as the window narrows**, and the air between
+the words never changes (two blank cells between two words, one blank cell at each end of
+the line):
-```
- home chats sessions spend settings
-```
+1. the clock goes first, and the `on ` name with it over `--host`;
+2. then `3 moving`;
+3. then the words of `2 want you`, which becomes `2 ?`, the same count in the same amber;
+4. then the allowance behind the day's figure (`$1.20 / $20` becomes `$1.20`);
+5. then the trailing places fold, one at a time from the right, into a **`more ▾`** word;
+6. and then the day's figure itself.
-with two cells between the words instead of four. Nothing else changes: the band under the
-word you are standing in, the counts, `tab`, `←` `→` and `alt+1`…`alt+8` all mean exactly
-what they mean on a wide screen.
-
-**Under 42 columns the bar carries what it can and counts the rest.** It keeps the place
-you are standing in, the word the cursor is on, and any place wearing a count, then fills in
-the bar's own order until the row is full and ends with a dim `▸ 2` — the number of the
-bar's words that are not on the row (standing, memory and search are never counted: they
-are not the bar's to give up):
+**The count of things waiting on you never goes.** `2 ?` outlasts the money and the places,
+at every width. At 80 columns all six places fit beside `2 ? · $1.20 / $20`. At 60, with
+nothing waiting on you, they read
```
- home chats sessions ▸ 2
+ >● codeaf home teams chats sessions more ▾ $1.20
```
-`▸ 3 more` where there are cells for the longer spelling, `▸ 3` where there are not — the
-same fold mark every other list on this surface puts over the rows it is not drawing, so a
-count on the bar reads as the same idea as `▸ 11 more` at the foot of the spend place. The word gives
-way before the mark does: `▸` is what says there is more behind the row. **The
-count is a sign and not a button** — pressing it does nothing, because it stands for several
-places at once and no single one of them is the answer.
+**`more ▾` is a door.** Click it and a small menu hangs under it listing exactly the places it
+folded, each with its key (`spend alt+5`, `settings alt+6`). The pointer lights a row and a
+click goes there; `↑` `↓` and `enter` do the same from the keyboard; `esc`, or a click
+anywhere off the menu, puts it away and leaves everything else as it was.
+
+**The wordmark and the place you are standing in never fold**, and neither does the word the
+bar's cursor is on or a place wearing a count. `alt+1`…`alt+9` still go straight to a place
+whether or not its word is on the row, and the numbers never move: the six on the bar are
+`alt+1`…`alt+6`, then standing, memory and search are `alt+7`…`alt+9`.
-**The key that reaches them is `tab`**, and it is named on the foot of every place —
-`… · tab next place`, which is the last clause a narrow foot gives up. `alt+1`…`alt+8`
-still go straight to a place whether or not its word is on the row, and the numbers never
-move: the five on the bar are `alt+1`…`alt+5`, then standing, memory and search are
-`alt+6`…`alt+8`, whatever happens to be drawn.
+The tab strip, drawn only inside a chat, keeps its own narrowing: long names are cut at a word,
+the names shrink until every tab fits, then the strip scrolls with `‹` `›`, and `+3` counts
+the tabs it could not spell.
## Why a nearly-empty place says what it is for — why is the tasks page empty
@@ -735,8 +801,8 @@ names what arrives there and the one thing that puts it there, the way home's em
do. It never says the place is empty.
**Every place opens, always.** There is no state of the machine in which a word on the bar,
-or any of the seven digits, is a key that does nothing. On a machine codeaf was installed on
-an hour ago, `alt+3`, `alt+6` and `alt+7` all open:
+or any of the nine digits, is a key that does nothing. On a machine codeaf was installed on
+an hour ago, `alt+4`, `alt+7` and `alt+8` all open:
- **sessions**, headed `sessions`:
`work you send off with /task lands here, and its record stays`
@@ -756,7 +822,7 @@ never cut, and never ends in `…`. The moment the first thing arrives the line
the same heading; nothing above it moves.
- **spend**, **search** and **memory** over `--host` each say one dim line where their rows
would be — see *The places over --host* below for the exact words and why three of the
- seven still say them.
+ eight still say them.
There is no "coming soon", no greyed-out list and no empty table with headings over it. A
page that draws the furniture of a feature it does not have looks like a bug rather than like
@@ -795,10 +861,10 @@ walk in.
## The rewind timeline is not a place
`/rewind` (also `/undo`, `/back`) opens a full-screen page too, and it is deliberately **not**
-one of the seven. It is something you do to *this conversation* — pick a point and cut back
+one of the eight. It is something you do to *this conversation*: pick a point and cut back
to it — rather than a room in the machine, so it has no tab and `tab` does not walk to it.
-## The places over --host — whose machine am I looking at
+## The places over --host: whose machine am I looking at, and what `on spark` at the end of the tab bar means
**A place is a listing of one machine's disk, and over `--host` that machine is the one your
session runs on.** Home lists the conversations under `~/.codeaf/v3`; tasks lists the work
@@ -814,6 +880,7 @@ their readings have not crossed:
| Place | Over `--host` |
|---|---|
| **home** | the far machine's projects and conversations |
+| **teams** | the far machine's teams, their packets and their spend; the team card's settings rows are that machine's, and `Wrap up first` is offered only when its engine answers the wrap-up doors |
| **sessions** | the far machine's work, out of the same reading |
| **standing** | the far machine's orders — both what stands on this conversation and what stands anywhere else on that machine |
| **settings** | this computer's rows; the sheet says the far conversation reads its profile on the other machine |
@@ -827,10 +894,12 @@ say why. A screen full of the wrong machine's work is a confident lie, and one h
sentence is better than eight rows and a total in dollars that belong to somebody else's
afternoon.
-**The tab bar says whose machine it is.** Over a connection the right end of the bar reads
-`on ` — the same name you typed after `--host`, and the same one the status line's
-place segment and the legend under the box already carry. On a local session it is not there
-at all: a machine name is worth a word only when there is more than one machine in play.
+**The top line says whose machine it is: `on spark` at the end of the tab bar.** Over a
+connection the far end of the top line, where the places are, reads `on `, just
+before the clock: the same name you typed after `--host`, and the same
+one the status line's place segment and the legend under the box already carry. On a local
+session it is not there at all: a machine name is worth a word only when there is more than
+one machine in play. On a narrow window it goes with the clock, before anything else.
## Why is home empty over ssh when I connect to another machine — space space over --host
diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md
index abeb890f7e..5a21e10a42 100644
--- a/internal/manual/chat/questions.md
+++ b/internal/manual/chat/questions.md
@@ -1270,13 +1270,13 @@ has all of the above: the digits, `esc` for later, the chip, the receipt, the
settle guard, the narrow card and the phone sheet. `permissions` is its own page
and states what each answer banks.
-**So does the standing card** — `wants to keep an eye on:` with `1 yes, set it up`,
-`3 just once` where the item can be done once at all, and `0 no`. What is left in
-the conversation is the card itself: your own words, the `when ·`, `where ·` and
-`costs ·` bands, and the meter where the engine put a deadline on it. The answers
-are up above the box with everything else you are being waited on for, and each
-one says what it costs beside it. `o` starts an updated request — it turns
-the box into the correction lane, and `enter` sends your words back to be
+**So does the standing card.** The first line says the kind: `wants to remind you`,
+`wants to set up a repeating check`, `wants to watch for something`, or
+`wants to keep a rule`. Under that is what it does, then when and what one time
+costs, and the `where ·` band. The answers sit above the box. A repeating check's
+yes is `Set it up · `, its once is `Only now, don't repeat` (this used
+to say `just once`), and its no is `Don't set it up`. A reminder and a rule have
+no once. `o` starts an updated request. `enter` sends your words back to be
re-proposed.
**`esc` on a standing card means *later* now, and it used to mean no.** It is
diff --git a/internal/manual/chat/running-on-another-machine.md b/internal/manual/chat/running-on-another-machine.md
index 86b853593c..599d932528 100644
--- a/internal/manual/chat/running-on-another-machine.md
+++ b/internal/manual/chat/running-on-another-machine.md
@@ -272,7 +272,7 @@ that conversation beside the one you are in — the engine gives it a connection
and the chat you came from keeps running, the same door `codeaf resume` uses locally. The right end of the tab bar reads `on ` so you can
see whose afternoon you are looking at, and it is not there at all on a local session.
-Three of the seven places still read the machine this window is running on, and each says so
+Three of the eight places still read the machine this window is running on, and each says so
in one line where its rows would be: **spend**, **search** and **memory**. The whole table,
and why the look-stamp behind each tab's number is kept per machine, is on the Places page
under *The places over --host*.
@@ -454,11 +454,14 @@ exact sentence each one says.
travels on the wire like every other answer; nothing about it needs a browser or a
port. This is the one entry on the list that is a capability, not a limit.
-5. **`/settings` opens anyway, and says one sentence as it opens.** Half these rows are
- this surface's own — the mouse, the timestamps, the draft — and genuinely apply; the
- other half govern the conversation, which reads them from the far machine's profile. It
- says exactly:
- `these rows are this machine's — the ones that govern the conversation are read from the profile on the other one`
+5. **`/settings` opens anyway, and says whose rows these are.** Every tab but Teams writes
+ this machine. The Teams tab is saved on the other machine when that machine can take the
+ change. Opening on any other tab says exactly:
+ `these rows belong to this machine; the Teams tab is saved on the other one.`
+ On the Teams tab it says only what is true there:
+ `these rows are saved on .`
+ An older engine, where the Teams tab cannot be saved over the connection, says exactly:
+ `these rows belong to this machine; this conversation reads its profile on the other one.`
## More of what does not work over --host
@@ -574,7 +577,7 @@ The task roster lists this far conversation's work. Its rows come from the far
keep it, or drop it — goes back the same way. Running a harness that already exists was
never affected.
-13. **Three of the seven places still read this machine.** Spend adds up the ledger every
+13. **Three of the eight places still read this machine.** Spend adds up the ledger every
model call on the machine this window runs on writes into, search reads the index of what
was said here, and memory reads what sessions here learned — and there is no door on the
wire for any of the three yet. Each place opens, keeps its head, its bar and its box, and
@@ -671,21 +674,51 @@ browser and no port, so that road stays open over `--host`.
## Settings over --host
-`/settings` opens over a connection and says one sentence as it opens:
+`/settings` opens over a connection. On any tab but Teams it says exactly:
```
-these rows are this machine's — the ones that govern the conversation are read from the profile on the other one
+these rows belong to this machine; the Teams tab is saved on the other one.
```
-Half the rows are this surface's own — the mouse, the timestamps, the draft — and those
-genuinely apply to what you are looking at. The other half govern the conversation, and
-the conversation reads them from the profile on the far machine. Change those over there.
+On the Teams tab it says only what is true there:
+
+```
+these rows are saved on .
+```
+
+An older engine, where that tab cannot be saved over the connection, says exactly:
+
+```
+these rows belong to this machine; this conversation reads its profile on the other one.
+```
+
+The rows on every tab but Teams are this machine's, and those changes apply to what you
+are looking at. The Teams tab is the other machine's when the connection can save it.
**Asking codeaf to change a setting goes the other way.** `settings` and `change_setting`
-run inside the session, which is on the far machine, so they read and write **that**
-machine's profile — which is the profile the conversation actually obeys. So over a
+run inside the session, which is on the far machine, so they read and write that
+machine's profile, which is the profile the conversation actually obeys. So over a
connection the two doors land in two different files: the panel edits this laptop, and
-asking edits the machine the work is on.
+asking edits the machine the work is on. The Teams tab is the exception on the panel.
+It saves on the other machine, the same place asking would write a team default.
+
+## Can I change the Teams settings on another machine, and where do team defaults go over a connection
+
+The **Teams** tab is the exception. Team defaults over a connection are what every team on
+the far machine inherits, so the tab shows that machine's values and a change is saved
+there, not on this computer. A value reads `from Settings`, the same words a team's own
+card uses when the team has not overridden it.
+
+You can change `questions go to the manager`, `team messages wake`, `daily cap per team`,
+`team depth` and `sub-team share`. The foot line says
+`a team can override any of these on its card · saved on `. The note on this tab
+says exactly `these rows are saved on .`
+
+An older engine, one that can show the teams but cannot take this change, keeps the tab
+read only. The note says exactly
+`these rows are on . changing them is not available over this connection.`
+The foot line says `changing them is not available over this connection`, and
+pressing enter on a row says the same sentence. Nothing is written on either machine.
## Approvals over --host
diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md
index 18a192fc6b..fb4f1ef5ca 100644
--- a/internal/manual/chat/screen.md
+++ b/internal/manual/chat/screen.md
@@ -28,42 +28,42 @@ line above the message box* below, and *The thinking chip above the message box*
keys page). The tray is drawn only when it has something on it: with nothing attached, the
row is not there and the box sits straight under the gap.
-Beside the conversation, on the right, the column — the right-hand bar, sidebar, task
-panel, whatever you call it — takes 30 columns (24 on a narrower frame)
-from the session's first keystroke, before any tasks exist. An untouched empty
+Beside the conversation, on the right, the column (the right-hand bar, sidebar, task
+panel, whatever you call it) takes a quarter of the frame, 28 to 40 columns (30 at 120),
+from the session's first keystroke, before any tasks exist. It is the same column in every
+chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team. An untouched empty
conversation opens without it: no column, no doors, no rule, no telemetry, just the
centred greeting with the message box inside it (see *The empty screen* page); the
column stands the moment you type, or at once if a standing order or a task is already
-here. It carries `tasks`, the
-roster of work, and `standing`, the orders standing over this conversation. A section's
-dim lowercase label appears only when that section has rows. Work fills the column
-rather than raising it, and the work it fills with is **this conversation's alone**.
-An empty column keeps only its typeable `+ /task` and `+ /standing` doors; where
-the project has a record from earlier sessions, one dim line at the foot of the column
-reads `ctrl+. earlier` and opens the task page. With no foreground command that can be
-kept, `ctrl+g` closes the column and opens it again, remembered between sessions, and the
-column's hide control above `+ /task` says so: `❯ ctrl+g hide`. While a command can be kept, that
-command takes the key and the column stays where it was. With the column closed the
-conversation is laid out at the full width of the terminal, running work still draws
-the strip along the top, and the keys row under the box reads `ctrl+g tasks` once the
-session has tasks to come back to and no running-turn line owns that row.
-
-**Seven places take the whole frame instead of sharing it**, at every width: home, tasks,
-standing, memory, spend, search and settings. Four are on the tab bar with the way back to the chats, `home chats
-tasks spend settings`, and `tab` walks the four rooms; `alt+1` … `alt+8` (`opt+1` … `opt+8` on a Mac) jump
-straight to any of the seven from wherever you are standing — a place or a conversation —
+here. Under the header, a needs-you band when anything needs you; then the roster of
+work grouped by state, and `standing`, the orders standing over this conversation. Work
+fills the column rather than raising it, and the work it fills with is **this
+conversation's alone**. An empty column keeps only its header and its typeable `+ /task`
+and `+ /standing` doors; where the project has a record from earlier sessions, one dim line
+at the foot of the column reads `ctrl+. earlier` and opens the task page. `alt+l` closes
+the column and opens it again, remembered between sessions, and the header names it at its
+right: `alt+l`. `ctrl+g` does the same while no foreground command can be kept. With the
+column closed the conversation is laid out at the full width of the terminal, running work
+still draws the strip along the top, and the keys row under the box reads `alt+l tasks`
+once the session has tasks to come back to and no running-turn line owns that row.
+
+**Eight places take the whole frame instead of sharing it**, at every width: home, teams,
+sessions, standing, memory, spend, search and settings. Five are on the tab bar with the way back to the chats, `home teams chats
+sessions spend settings`, and `tab` walks the five rooms; `alt+1` … `alt+9` (`opt+1` … `opt+9` on a Mac) jump
+straight to any of the eight from wherever you are standing, a place or a conversation,
and each
-has commands of its own (`/home`, `/history`, `/standing`,
+has commands of its own (`/home`, `/teams`, `/history`, `/standing`,
`/memory`, `/settings`). The rewind timeline (`/rewind`) takes the frame the same way and is
-deliberately not one of the seven — it is something you do to this conversation rather than
+deliberately not one of the eight: it is something you do to this conversation rather than
a room in the machine.
While any of them is up nothing else is drawn — no conversation, no box, no status line —
and `esc` gives the frame back. **Only one is ever up:** opening any one closes the rest.
-Every place is drawn in one frame, top to bottom: the machine's own top line, the tab bar
-naming the four (and the one you stand in, when it is off the bar), a dim rule, the place's body, a rule carrying the place's own count or note, and
-the hint line last. See the **Places** page.
+Every place is drawn in one frame, top to bottom: the top line (the `codeaf` wordmark, the
+six places with the one you stand in lit, and the machine's own signs on the far end), the
+tab strip of your chats, a dim rule, the place's body, a rule carrying the place's own count
+or note, and the hint line last. See the **Places** page.
**Only home has a box under that rule.** Its seam starts with the model, a colon and
its effort word and approvals: `z-ai/glm-5.3-flash:auto · ◇ asks`. Conversation seams use
@@ -99,26 +99,42 @@ while preserving the content's indentation.
## Conversation tabs — switching conversations by clicking, the tab strip over a chat, clicking a chat name
-**The header begins with `home` and the conversations this window has been in**, drawn as
-tabs in a row of their own, with a thin rule separating navigation from reading:
+**The header's second line, inside a chat, is the conversations this window has been in**,
+drawn as tabs in a row of their own under the top line, with a thin rule separating
+navigation from reading. The strip is that chat's row. Home, teams, sessions, spend,
+settings and every other place do not draw it:
```
- home openrouter price scrape Refactor the rail sco… [Shipping the parser] × + +2
- ────────────────────────────────────────────────────────────────────────────────────────
+ >● codeaf home teams chats sessions spend settings $1.20 / $20 · thu 10:31pm
+ openrouter price scrape Refactor the rail scope... Shipping the parser × + ▦ All
+ ──────────────────────────────────────────────────────────────────────────────────────────────────
```
-While a team is shown its chip, `● harbor ▾`, comes right after `Home` and before the tabs it
+While a team is shown its chip, `● harbor ▾`, comes first on the strip, before the tabs it
narrows, with the team's `◆ Manager` after it (see *The team switcher on the tab strip*).
-
-Each tab is a **padded target** separated by quiet space. The filled surface includes
-one blank cell before its status icon and after its close mark; the leading inset
-selects the tab and the trailing inset belongs to the close target. The gaps do nothing.
-Every tab has a filled background. The active tab reverses the surface contrast
-and has stronger text; brackets identify it on
-terminals without background color. **Every tab reacts to the pointer**,
-including the one you are already in, and the highlight it wears as the *chosen* tab stays
-put when the pointer leaves. Without color, hovering adds a dot beside the tab’s
-close mark; Home, `+`, and the scroll arrows gain a pointer dot.
+The strip is not on a place, so there is no unlit tab there and a press on that row is
+the page's.
+
+Each tab is a **padded target**, and **one blank cell separates any two pieces of the
+row**: the team chip and the first tab, two tabs, the last tab and `+`, `+` and `▦ All`.
+The row's first piece starts in the same column with a team chip or without one. A tab is
+the same width on both sides of its name: one blank cell, the status mark's cell and a
+space lead it, and a space, the `×` cell and one blank cell close it (` ◐ name × `). The
+status and `×` cells are kept even while they are empty, so a tab never changes width when
+work starts or the pointer arrives. The leading cells select the tab and the `×` and the
+cell after it close it. The gaps do nothing. Every tab has a filled background. The active
+tab reverses the surface contrast and has stronger text; brackets identify it on terminals
+without background color. **Every tab reacts to the pointer**, including the one you are
+already in, and the highlight it wears as the *chosen* tab stays put when the pointer
+leaves. Where colour cannot show a ground (16 colours or none), the tab under the pointer
+wears `·` in its first cell, as a top-line word does, and shows its `×`; `+` and the scroll
+arrows gain the same dot.
+
+**Every piece of the strip says what it does on the hint line** while the pointer rests
+on it: a tab says `Go to openrouter price scrape · running · click` (the same sentence its
+square in the dock says), its `×` says `Close this tab · the work keeps running · click`
+(`ctrl+w` on the tab you are in), `+` says `New chat · ctrl+t`, and `‹` `›` say
+`More tabs to the left · click` and `More tabs to the right · click`.
**Clicking a tab goes to that conversation** — the same switch `alt+k` makes. Clicking the
tab you are already in does nothing while you are in the conversation itself, and takes you
@@ -131,9 +147,11 @@ them, so the one you reached for a minute ago is still in the same place. At mos
remembered; past that the one you have not been in for longest falls off, and the tab you
are in never does. This is a presentation limit, not a limit on running work or history.
-**Many tabs scroll horizontally instead of shrinking their names.** On a roomy strip,
-`‹` and `›` appear at the edges when more tabs exist in that direction (`<` and `>` in
-ASCII). Click an arrow, or wheel vertically or horizontally over the header, to browse
+**Names shrink first, then the tabs scroll.** A long name is cut at a word where one is
+near (`Refactor the rail...`, not `Refactor the rail scop...`). While every tab still fits
+with at least 16 cells each, the names shrink so every tab stays on the row, as a browser's
+tabs do. Past that the strip scrolls: `‹` and `›` both appear at the edges, the one with
+nothing further in its direction dim and inert (`<` and `>` in ASCII). Click an arrow, or wheel vertically or horizontally over the header, to browse
the names. This changes neither the conversation, its draft nor the transcript position.
The selected tab may leave view while you browse; choosing a conversation or closing a
tab brings the selection back. Narrow frames keep the selected tab without spending its
@@ -182,17 +200,14 @@ door that had to close the conversation to leave it.
## Why did my tabs disappear on a small terminal — how wide and tall the tab bar needs
**The tab strip stands down below 12 columns or below 16 rows.** At 16 rows and taller,
-the conversation keeps the same four-row head as every place: the machine pulse, the tabs,
-a rule, and a blank. From 6 through 15 rows the whole head stands down, while the blank and
+the conversation keeps the same four-row head as every place: the top line with the places
+and the machine pulse, the tabs, a rule, and a blank. From 6 through 15 rows the whole head stands down, while the blank and
rule above the message box remain. Below 6 rows those give way too, leaving the conversation,
the box, and the status line. Blank rows cannot activate the content beneath them.
-**`home` at the left opens the home page**, keeping your conversation and unsent words.
-Its lowercase label and padded click target stay in the same columns on the dashboard
-and in conversations, including while hovered.
-It is separate from the tabs and breadcrumbs. Space twice on an empty composer still
-opens Home. Home disappears when the connection cannot open conversations, and on
-very narrow frames the current tab takes priority.
+**`home`, the first place on the top line, opens the home page**, keeping your
+conversation and unsent words. It is separate from the tabs and breadcrumbs, and it is on
+the top line of every page. Space twice on an empty composer also opens it.
The switcher floats on a separate background inside a rounded outline, with space
above and below its contents when the window is tall enough. `>` marks the keyboard
@@ -854,7 +869,8 @@ slot and the standing line wins whenever both would show. While the spelling-out
out, the slot turns a small spinner in front of the same words. Neither ever moves the
message box: this line is on the frame in every state.
-One line in that slot is not about the next keystroke: `ctrl+g tasks`, which appears
+One line in that slot is not about the next keystroke: `alt+l tasks` (`alt+l traffic` in a
+manager's chat), which appears
when you have closed the task column, this session has run something, and no active
running-turn hint has the slot. It is the whole of what the frame says about a roster
that is not on screen, and it says nothing at all when nothing has been run.
@@ -1392,39 +1408,36 @@ On top of those eight: preview blocks under a pending call are capped at 4 rows
## What the top line of home drops when it is narrow — the clock goes first
-Home's top line is the program's name on the left and the machine's vital signs on the
-right:
+The top line is the program's name and the places on the left, and the machine's vital
+signs on the right:
```
- codeaf 2 want you · 4 moving · $0.55 / $20.00 · thu 1:11pm
+ >● codeaf home teams chats sessions spend settings 2 want you · 4 moving · $0.55 / $20 · thu 1:11pm
```
-When there is not room for all of it, the segments give way **one at a time, in a fixed
-order**, exactly the way the status line's do:
+When there is not room for all of it, things give way **one at a time, in a fixed order**
+(the owner's order, 2026-09-24):
```
-clock → the allowance ($20.00) → the day's spend → moving → want you
+clock (and `on `) → moving → `2 want you` becomes `2 ?` → the allowance → the places fold into `more ▾` → the day's spend
```
-So the clock is the first thing off the line and `2 want you` is the last. The reason is
-one sentence: the terminal's own bar, the window and the wall clock all say what time it
-is, and nothing anywhere else says that two things have stopped and will not move until
-you look — a cell that could carry either carries the one you can only get here. Within
-that, `want you` outranks `moving` because a stopped thing needs you and a moving one does
-not, and the day's spend outranks the allowance because a figure is a fact and a fraction
-is that fact plus a bound.
+So the clock is the first thing off the line, `moving` goes next, and the places fold only
+after that, into a `more ▾` word that opens a menu of exactly the places it folded. The
+day's figure goes after the places. **The count of things waiting on you never goes**: its
+words shorten to `2 ?`, the same count in the same amber and the mark a waiting tab wears,
+and that is on the line at every width. `moving` goes first because a stopped thing needs
+you and a moving one does not.
-**The allowance goes by respelling, not by slicing.** `$0.55 / $20.00` becomes `$0.55` —
+**The allowance goes by respelling, not by slicing.** `$0.55 / $20` becomes `$0.55`,
never `$0.55 /` and never a bound with nothing in front of it. And when the money segment
goes entirely there is **no `$` left on the line at all**: a narrow top line never says
`$0.00`, because that would be the line reporting a figure it had actually given up on.
(The one `$0.00` on the whole surface is the live status line of a conversation, so its
segments do not jump sideways as the first money arrives. It is a different line.)
-**The name never gives way.** A window too narrow even for `2 want you` beside it draws
-` codeaf` alone. This used to be all-or-nothing — everything, or the name by itself — so a
-sixty-column window spent twelve cells on `thu 12:01am` and then, one segment later, said
-nothing about the machine whatsoever.
+**The name and the place you are standing in never give way.** Nor does the air: two blank
+cells between two place words and one at each end of the line, at every width.
## Other width thresholds worth knowing
@@ -1434,10 +1447,10 @@ Beyond the four tiers, these are the exact points where parts of the screen give
| --- | --- |
| the status row's right edge may wrap to its own row | below width 100 |
| the ledger drops its compaction forecast | below width 70 |
-| full task rail, 30 columns off the conversation | width 120 |
-| slim task rail, 24 columns | width 100 |
-| no rail column at all — `alt+t` overlays the roster instead | below width 100 |
-| no rail column at any width — you closed it with `ctrl+g` | your choice, remembered |
+| the side column, a quarter of the frame, 28 to 40 columns | width 100 |
+| `alt+w` widens the side column by 16 columns | width 120 |
+| no side column at all; `alt+t` or `alt+l` lays it over the body | below width 100 |
+| no side column at any width: you closed it with `alt+l` | your choice, remembered |
| task strip | width 24 **and** height 6 |
| a room's pinned header | width 12 and a non-zero breathing gap |
| the empty screen's greeting (wordmark, model line, centred message box, try line, recent sessions) | not drawn below height 12 or width 40 |
@@ -2254,8 +2267,8 @@ long time `↓` starts from what that window holds and counts up from there.
A room opened on a task that was already running shows `↑` from the newest
request in its record and `↓` from the moment the task writes anything. The
column leaves when the task finishes.
-The price and the token count on the task's row in the task column move at each
-step that spends, not only when the task changes state.
+The price and the token count on the hint line over the task's row in the task column
+move at each step that spends, not only when the task changes state.
A run's read-only transcript inside an adaptive run's page draws no column: it
is a record being read back, not work being watched.
@@ -2934,10 +2947,10 @@ back to plain ASCII (`!` `*` `o` `-` `+`) and the screen still reads.
## alt or option or opt — how the chords are spelled on a Mac, on Linux and on Windows, and why not the option symbol
**It is one key and two spellings, and codeaf picks the spelling from the platform it is
-running on.** On macOS every chord is drawn with `opt+`, `opt+1`…`opt+8`, `opt+.`,
+running on.** On macOS every chord is drawn with `opt+`, `opt+1`…`opt+9`, `opt+.`,
`opt+enter`, `opt+g`, `opt+q`, `opt+s`, `opt+w`, `opt+o` — because the key that Mac keycap
calls **option** is the key you press. On Linux, on Windows, and everywhere else the same
-chords are drawn `alt+1`…`alt+8`, `alt+.`, `alt+enter` and so on. Every hint line, the key
+chords are drawn `alt+1`…`alt+9`, `alt+.`, `alt+enter` and so on. Every hint line, the key
map, the composer layer's rows and the key sheet `/help` draws read that one spelling, so
what is on your screen is what is on your keyboard.
@@ -2970,7 +2983,7 @@ appears under the list:
It names the terminal you are actually in, it is said once, and the first real chord that
arrives retires it for the rest of the session. The first-run setup says the same thing ahead
-of time, as a condition rather than a diagnosis: `the places answer opt+1…opt+8 · if opt types
+of time, as a condition rather than a diagnosis: `the places answer opt+1…opt+9 · if opt types
a character instead, turn on "use option as meta" in …`.
**`alt+b` and `alt+f` do not retire it, and that is deliberate.** iTerm2's Natural Text
@@ -3002,8 +3015,8 @@ are worth the one setting.
**And on kitty, ghostty and WezTerm there is a way in that needs no setting at all.** Those
terminals run the kitty keyboard protocol and report it, and where that report arrives codeaf
-binds `ctrl+1` … `ctrl+8` as a second spelling of the jump and `ctrl+.` as a second spelling of
-the map. The map's own line says `alt+1…8 or ctrl+1…8 go to a place` exactly when the alias is
+binds `ctrl+1` … `ctrl+9` as a second spelling of the jump and `ctrl+.` as a second spelling of
+the map. The map's own line says `alt+1…9 or ctrl+1…9 go to a place` exactly when the alias is
live, so you never have to guess. `ctrl+` has no encoding in the older scheme, which is
why it can only ever be the second spelling and never the first — a terminal that has said
nothing is never promised it.
@@ -3087,38 +3100,51 @@ are one.
## What the task column looks like: quiet rows, its footer lines and its one door
-The right-hand task column is read at a glance, so it is drawn as one bright thing and a
-lot of quiet ones. (This is its `tasks` section. The same column's other section,
-`standing`, is described under *What is that column on the right*.)
+The right-hand column is read at a glance, so it is drawn as one bright thing and a lot of
+quiet ones. This is its Tasks view; *What is that column on the right* has the whole column,
+and the team manager page has its Traffic view.
+
+```
+Tasks 14 · Traffic 3 new alt+l
+? Port the parser · your call · nobod…
+✕ parser bench incomplete
+──────────────────────────────────────
+Running 4
+ ⠙ rebase the parser onto dev 2m
+ ⠙ bench the lexer at p99
+Queued 3 ▸
+Done 6 ▸
++ /task
+```
+- **The header** is the column's two words in a chat in a team, one word elsewhere. The
+ word in front is bold ink and the other is dim, and each keeps its count; a count of `0`
+ is dim, and a count of new traffic, `3 new`, is in ink. The `alt+l` at the right is dim.
+- **The needs-you band** is the one place with colour of its own: what is waiting on you
+ leads with its `?` in the needs-you amber, and a failure nobody has opened leads with its
+ `✕` in ordinary ink. A thin dim rule closes it. With nothing waiting it is not drawn.
+- **Group headings** (`Running 4`, `Done 6 ▸`) are muted with a dim count; the `▸` of a
+ folded group and the `▾` of an open one are muted too. `Running` wears no mark, because
+ it does not fold.
- A **running** task's name is in ink, the body colour. **Queued, waiting and finished**
- names are muted — a step quieter — and the room you are standing in is the one name in
- the accent and bold, with a colour band across its whole row.
-- The tree connectors (`├─ `, `└─ `, `│ `), the id at the end of a row (`#7`), every
- detail line under a title, and every footer line are dim. The one loud exception is a
- branch that did not merge: `conflicted · task/fix-nil` is in the bad hue.
+ names are muted, a step quieter, and the room you are standing in is the one name in the
+ accent and bold, with a colour band across its whole row. The time at the right of a row
+ is dim.
- The state glyph at the head of a row takes its own identity ring hue and no role
colour, so a glyph can never be misread as a state paint.
-**Rows that are running never scroll off**, however long the list gets: they are pinned to
-the top of the column and everything under them scrolls. The `tasks` label above them is
-pinned too, so the heading is still there when you have scrolled a long way down.
+**The header and the band never scroll**, however long the list gets: they are pinned to
+the top of the column and everything under them scrolls.
-**A task that has finished is one line.** While a task is running, waiting on you, or
-held, its row carries the detail lines under its title — what it is doing, what it is
-costing, what it waits on, where a job's log is going. Once it is over, the row is its
-glyph, its name and its `#7` and nothing else, so the column's height goes to what is
-still moving rather than to a day's history. Queued work is not finished and keeps its
-`waiting on Collect sources` line; a finished task that is still **your call** —
-`your call · conflicts with your branch`, `your call · nobody could check it` — keeps its
-line too, because it is something to act on rather than something that is over.
-Nothing is thrown away: see *Opening a finished row on the task column*.
+**Every task is one line**, running or finished. What a row used to carry underneath
+(what it is doing, what it is costing, what it waits on, the reason it is your call, the
+merge word, the branch) is on the hint line while the pointer is on the row, with the whole
+name first. So the column's height goes to rows rather than to a day's history. Nothing is
+thrown away: see *Opening a finished row on the task column*.
**The roster is this conversation's work and nothing else.** No rows of the project's
-record are drawn under it. It used to carry a dulled footnote of up to six of them — a
-sample of two thousand, standing where this conversation's task rows go, with the
-cursor walking out of this conversation into another one without the column saying so.
-What stands in their place is **one dim door** at the foot of the column: `ctrl+. earlier`.
+record are drawn under it. What stands in their place is **one dim door** at the foot of
+the column: `ctrl+. earlier`.
**Everything earlier lives one press away.** `ctrl+. earlier` opens the full-screen task
page (`ctrl+.`, `/history`), which holds every task the project has ever run, across every
@@ -3126,70 +3152,66 @@ session, with the filter, the cards and the mention. Home (`/home`, or space twi
empty box) is the other place old work is listed. Running work belonging to *other*
windows is not on the column at all, and never was; `/history` carries that too.
-The footer is up to three dim lines of counts — `3 running · 1 needs you`, `148 waiting ·
-12 done` — then the standing count `◦ 2 standing orders` when anything stands over this
-project, and then up to two more dim lines, each of which is a button as well as a key:
+The footer has no counts: the header counts the work and each heading counts its group.
+It is the standing count `◦ 2 standing orders` when anything stands over this project, and
+then up to two dim lines, each of which is a button as well as a key:
```
ctrl+. earlier
alt+w widen · click seam
```
-**The first of them is one door with two spellings, never two doors.** It is drawn only
-when the full-screen task page (`/history`) has something this column cannot give, and the
-words on it say which: `ctrl+. earlier` when the project's record holds work this
-session never ran, and `ctrl+. view more` when the only thing held back is a family the
-column has folded. There is never more than one such line.
-`alt+w widen · click seam` appears only while a title is actually being cut by its own
-indent, **and only from 120 columns up** — that is the only width with a wider tier to
-offer, so on a 100-to-119 column frame there is no line and the column's two leftmost
-cells are part of the row rather than a handle. The `❯` hide control sits immediately above `+ /task`, after the visible task rows.
-Its `❯` is drawn in ink rather than dim
-because it is the control the pointer presses. With no foreground command to keep it
-reads `❯ ctrl+g hide`; while a command owns that chord it reads only `❯ hide`, because
-a hint may name only a key that works on that frame.
+**The first is drawn only when the full-screen task page has work this session never
+ran.** There is never more than one such line, and there is no `view more` line any more:
+a folded group is opened by its own heading. `alt+w widen · click seam` appears while the
+column holds the keyboard or the pointer is on it, **and only from 120 columns up**: that
+is the only width with a wider column to offer, so on a 100-to-119 column frame there is no
+line and the column's two leftmost cells are part of the row rather than a handle. The
+column's way out is the `alt+l` at the right of its header.
+
+**A plain or sixteen-colour terminal gets the linear marks**: `x` for `✕`, `-` for the
+band's rule, `>` and `v` for the folds, and `*` for a running glyph. The amber is the one
+colour the band leans on, and where there is none the `?` still says it.
## Opening a finished row on the task column: how to see the log path of a job that already finished, and where the merge word, price or branch went
-A finished task's row is one line, and the line it used to carry underneath is **folded,
-not deleted**. It is the same fold a family of tasks uses, on the same keys and the same
-cell:
-
-- **From the keyboard:** `alt+t` hands the column the keyboard, `↑` and `↓` walk to the
- row, `→` opens it, `←` folds it away again. `esc` gives the keyboard back.
-- **With the pointer:** hover the row and its state glyph turns into `▸`; click that one
- cell to open it, and `▾` in the same cell to close it. Clicking anywhere else on the
- row opens that task's room, as it always did — and so does that same cell on any frame
- where the triangle is **not** drawn in it, because then it is holding the row's state
- and a state is not a control.
-- The fold is remembered per row, exactly as a family's fold is, and it lasts as long as
- the conversation does.
-
-What comes back is that task's own last word: `merged · $0.42` for work that came home
-clean. A background job is not a finished task row: it lives in the `jobs` section, and
-its log path is on the job's page, not folded under a roster line. A row with nothing to
-say — a task that ended with no merge word and no price — offers no `▸` at all, because a
-mark that answered a press with silence would be a lie. The full record of any task is on
-the task page (`ctrl+.`, `/history`) whether the row is folded or not.
+A finished task's row is one line, and the line it used to carry underneath is **on the hint
+line, not deleted**. Point at the row and the hint line under the message box says the
+whole of it: the whole name, then that task's own last word (`merged · $0.42` for work that
+came home clean, the branch it kept, the reason it is your call), then `click opens it`.
+There is no fold on a task row and no `▸` in its state cell; the cell is the task's state,
+and a press on it opens the task's room like the rest of the row.
+
+With the keyboard, `alt+t` hands the column the keyboard and `↑` `↓` walk to the row; the
+room behind `enter` has everything the row does and more.
+
+Finished work sits under `Done`, which starts folded to its heading: press `Done 6 ▸`, or
+`enter` on it, to open it, and it stays open for the session.
+
+A background job is not a finished task row: it lives in the `jobs` section, and its log
+path is on the job's page. The full record of any task is on the task page (`ctrl+.`,
+`/history`).
## Scrolling the task column: the mouse wheel over the sidebar, and the keys that walk it
**Turn the wheel with the pointer over the column and the column scrolls.** It moves the
column's own window and leaves the conversation beside it exactly where it was; a wheel
turned over the conversation still scrolls the conversation. Three rows a notch, the same
-as everywhere else on this screen. Work that is running is pinned to the top and does not
-scroll away, and the `tasks` label stays with it.
-
-From the keyboard it is `alt+t` to take the column, then `↑` `↓` to walk it — the window
-follows the cursor — `→` `←` to open and fold, `enter` to walk into a task's room, `alt+w` to
-widen the column, and `esc` to give the keyboard back. The column's hint line says the
-same: `↑↓ move · →← tree · enter open · alt+w wide · esc`, and it gains `alt+e think harder`
-before the `esc` while the row under the cursor is work that has not finished — that chord
-moves the task's own thinking rung. And on a row that says **`your call`** the whole slot
-becomes that row's own answers and an `esc` — `a` says yes to what it is asking, `n` says
-no — and those letters answer that landing from the column without opening its room.
-While the column holds the keyboard the wheel walks that cursor instead of the window, so
-the two never fight.
+as everywhere else on this screen. The header and the needs-you band are pinned to the top
+and do not scroll away.
+
+From the keyboard it is `alt+t` to take the column, then `↑` `↓` to walk it (the band, the
+group headings and the tasks; the window follows the cursor), `enter` to walk into a task's
+room or to open or fold a group, `←` `→` to switch between Tasks and Traffic in a chat in a
+team, `alt+w` to widen the column, and `esc` to give the keyboard back. The column's hint
+line says the same: `↑↓ move · enter open · x stop · alt+w wide · esc`, led by
+`←→ tasks/traffic · ` in a chat in a team, and it gains `alt+e think harder` before the `esc`
+while the row under the cursor is work that has not finished; that chord moves the task's
+own thinking rung. And on a row that says **`your call`**, in the band or in the list, the
+whole slot becomes that row's own answers and an `esc` (`a` says yes to what it is asking,
+`n` says no), and those letters answer that landing from the column without opening its
+room. While the column holds the keyboard the wheel walks that cursor instead of the window,
+so the two never fight.
Widen is `alt+w` and not the bare letter `w`: the roster is read before the message box, so
a bare `w` there ate the `w` out of every sentence somebody typed with the column still
@@ -3198,103 +3220,121 @@ no message box on the screen at all.
There is **one scrollbar-less window and no second one**: the wheel, the arrow keys and a
landing task all move the same offset. What the window cannot show is said at the foot of
-the column — the totals, and `ctrl+. earlier` onto the full task page.
+the column: `ctrl+. earlier` onto the full task page.
## What is that column on the right — tasks, standing, jobs and an empty rail
-The column beside the conversation carries the two things that govern a conversation, and
-a third for the background work that conversation started.
-A section with rows earns a dim lowercase label; an empty section keeps only its dim `+`
-door. (An untouched empty conversation has no column at all until the first keystroke,
-a task, or a standing order — *The empty screen* page says why.) Once it stands:
+The column beside the conversation is one column in every chat. It carries this
+conversation's work, the orders standing over it, and the background work it started, and
+in a chat in a team it carries the team's Traffic as its second view. (An untouched empty
+conversation has no column at all until the first keystroke, a task, or a standing order;
+*The empty screen* page says why.) Once it stands:
```
-tasks
-⠙ Fix the nil-map #7
-❯ ctrl+g hide
+Tasks 3 · Traffic 2 new alt+l
+? Port the parser · your call · nobo…
+──────────────────────────────────────
+Running 1
+ ⠙ Fix the nil-map 4m
+Done 1 ▸
+ /task
standing
◦ keep the tests green
-◦ never touch the public API everywhere
+◦ never touch the public API everywhere
+ /standing
▸ jobs · 1 running · 4m12s
-1 running · 1 needs you
◦ 2 standing orders
ctrl+. earlier
```
-- **`tasks`** is the roster — this conversation's work, one line per task, a click on a row
- opening that task's room. Workers under a task are rows of that roster too — a task's
- parts, an adaptive run's workers — hung under their parent with connectors, folded and
- walked like any other row; there is no separate preview list beneath a row. When more
- than one worker is running, the label itself says the count, such as `tasks · 4 working`;
- at zero or one it remains simply `tasks`.
+- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. The word
+ in front is bold ink and the other dim; each is a button with a hover ground and a hint,
+ and the other word keeps its count, so what arrives on the Traffic while you read the
+ tasks still says `2 new`. Which word is in front is remembered for the session per kind
+ of chat: a manager's chat opens on the Traffic, every other chat on the Tasks. The `alt+l`
+ at the right closes the column.
+- **The needs-you band** is everything that needs you now, from both views: a task whose
+ next step is yours, a member's question to you, a decision put to you (each led by `?` in
+ amber), and a task that failed while you watched and that you have not opened (its `✕` in
+ ordinary ink). Three rows, then `+N more`; gone when empty; nothing in it is drawn again
+ below it. Each row opens its thing.
+- **The Tasks view** is the roster: this conversation's work under `Running`, `Queued`,
+ `Waiting` and `Done`, one line per task, newest first. Running is always open; the others
+ start folded to one heading line, and a press on the heading opens it for the rest of the
+ session. A click on a task row opens that task's room. Workers under a task (a task's
+ parts, an adaptive run's workers) are rows of their own in the same groups; there is no
+ tree and no preview list beneath a row.
+- **The Traffic view**, in a chat in a team, is what passes in the team, one line a row,
+ newest first, with a thin `new` line under what arrived since you last looked. The team
+ manager page has the whole of it.
- **`standing`** is the standing orders reaching this conversation, one line each: a mark,
what the order is called, and a dim tail naming its reach **only when that reach is not
- the ordinary one** — `everywhere` for machine-wide, `just here` for this conversation
- only, and nothing at all for an order governing this project. A row's mark becomes the
+ the ordinary one** (`everywhere` for machine-wide, `just here` for this conversation
+ only, and nothing at all for an order governing this project). A row's mark becomes the
spinner while that order is being checked or fired right now. Clicking one opens
`/standing` with the cursor already on it.
-- **`jobs`** is this conversation's background work — a server, a build, a watch, a render —
- as a third section under the other two, collapsed by default to one line (`jobs · 2
- running`, `jobs · 1 running · 4m12s` when exactly one is live, `jobs · 6 ran` when
- nothing is). Enter or a click toggles it. Expanded, every running job draws, then
- finished ones fill whatever room is left, newest first, with `▸ N earlier` counting the
- rest. Zero jobs draws nothing at all — no label, no empty row, no `0 jobs`. A click on a
- job opens its page, not a chat. The tasks page has the whole of it under *Background
- jobs on the column*.
+- **`jobs`** is this conversation's background work (a server, a build, a watch, a render)
+ as a section under the other two, collapsed by default to one line (`jobs · 2 running`,
+ `jobs · 1 running · 4m12s` when exactly one is live, `jobs · 6 ran` when nothing is).
+ Enter or a click toggles it. Expanded, every running job draws, then finished ones fill
+ whatever room is left, newest first, with `▸ N earlier` counting the rest. Zero jobs draws
+ nothing at all. A click on a job opens its page, not a chat. The tasks page has the whole
+ of it under *Background jobs on the column*.
- **The `standing` section keeps its rows however long the roster gets.** The sections
- do not compete for the column: the roster is given what is left over after the label,
- the doors, the standing rows and the jobs section have been reserved, and it is the
- roster that scrolls. A session with forty tasks in it still shows the orders standing
- over it, and the jobs that are running, without scrolling.
-- **At most three orders are drawn, and the label counts the rest** — `standing · 7 more`,
- in the same shape as `tasks · 4 working`. Past a handful the rows stop being read one at
- a time; `+ /standing` (or `/standing`) opens the page that lists them all. A section
- showing every order it has says nothing extra: the label is simply `standing`.
+ do not compete for the column: the roster is given what is left over after the doors,
+ the standing rows and the jobs section have been reserved, and it is the roster that
+ scrolls. A session with forty tasks in it still shows the orders standing over it, and
+ the jobs that are running, without scrolling.
+- **At most three orders are drawn, and the label counts the rest**: `standing · 7 more`.
+ Past a handful the rows stop being read one at a time; `+ /standing` (or `/standing`)
+ opens the page that lists them all. A section showing every order it has says nothing
+ extra: the label is simply `standing`.
- On a **short terminal** the standing rows give way one at a time so that the roster
keeps at least six rows, and under that the whole standing section stands down rather
than drawing a label over nothing. Live jobs are reserved before standing spends: a
running job is not a thing this column hides to make room for furniture.
-- **An empty section has no label and no absence sentence.** When tasks and standing are
- both empty, only `+ /task` and `+ /standing` remain as the discoverable doors. Jobs add
- no `+` door — a job is started by a tool, not typed.
+- **An idle column draws nothing to mark an absence**: no `none` rows, no empty group
+ headings, no band. The header keeps its words with a dim `0`, and `+ /task` and
+ `+ /standing` remain as the discoverable doors. Jobs add no `+` door; a job is started by
+ a tool, not typed.
- **The `+` rows type, they do not arm.** Pressing `+ /task` or `+ /standing` puts that
- command and a space at the head of your message box and hands the keyboard back — plain
+ command and a space at the head of your message box and hands the keyboard back: plain
text you can edit or delete, no mode, no form.
-Under the sections come the column's dim totals and its door lines. The separation between
-the sections is one blank line: this surface separates with whitespace and never with a
-rule. On a build with no ambient side the `standing` section is absent entirely.
+**It is one width everywhere**: a quarter of the frame, never under 28 columns or over 40,
+the same in every chat and in either view, so nothing drawn in it moves the conversation.
+The separation between the sections under the list is one blank line; the band's rule is
+the one rule in the column. On a build with no ambient side the `standing` section is
+absent entirely.
-## The right edge: the chevron that opens and closes the task column
+## The right edge: the chevron that brings the task column back, and the key that closes it
-**The right edge always carries one chevron, and clicking it goes both ways.**
+**The column closes from its header and comes back from its edge**, and the pointer can go
+round the whole cycle without touching the keyboard.
-- While the column **stands**, the line immediately above `+ /task` reads `❯ ctrl+g hide` when no
- foreground command can be kept, and `❯ hide` while a command owns that key. The
- `❯` is drawn in ordinary ink, not dim, because it is a control and not a reading;
- the words beside it stay dim. Click the line and the column closes either way.
+- While the column **stands**, its header ends in the key itself, `alt+l` (`opt+l` on a
+ Mac), dim, at the right. It is a button: under the pointer it takes a ground and the hint
+ line says `Hide this column · alt+l`. Press it and the column closes.
- While the column is **away**, what is left is **two columns down the right of the frame**
- with a `❮` handle at the middle of them, also in ink. The whole strip is a door: click
- anywhere on it and the column comes back.
+ with a `❮` handle at the middle of them, in ink. The whole strip is a door: click
+ anywhere on it and the column comes back. Under the pointer the handle brightens further
+ and the strip takes a background, which is how everything pressable on this screen says
+ so. On a terminal that cannot draw it the handle is `<`.
-So one control in two states — `❯` to close, `❮` to open — and the pointer can go round the
-whole cycle without touching the keyboard. With no foreground command to keep, `ctrl+g`
-does the same thing from the keyboard; while one can be kept, the key backgrounds it.
-Under the pointer the chevron brightens further and its line
-takes a background, which is how everything pressable on this screen says so. On a terminal
-that cannot draw them the two chevrons are `>` and `<`.
+`alt+l` does the same thing from the keyboard in both directions, and `ctrl+g` does too
+while no foreground command can be kept; while one can, `ctrl+g` backgrounds it.
One cell above the closed edge's handle carries what the work is doing while there is
-anything to carry — `?` in the question colour for a task waiting on you, `◐` in the accent
-for something running, and nothing at all otherwise. Those keep their own colours; they are
-about the work, not about the door. The edge costs the conversation its two columns, so the
-text re-wraps around it and nothing is ever drawn underneath. Under 100 columns there is no
-edge, because at that width there is no column to bring back.
+anything to carry: `?` in the needs-you amber for a task or a team member waiting on you,
+`◐` in the accent for something running, and nothing at all otherwise. In a chat in a team,
+two cells below the handle count what arrived on the Traffic since you last had it in front
+of you. Those keep their own colours; they are about the work, not about the door. The edge
+costs the conversation its two columns, so the text re-wraps around it and nothing is ever
+drawn underneath. Under 100 columns there is no edge, because at that width there is no
+column to bring back; `alt+t` or `alt+l` lays the column over the body.
## Light terminals, and why there is no theme setting
@@ -3805,7 +3845,7 @@ nothing for it to open — `nothing made yet.` from `/files`, or
`no subharnesses here yet — a subharness is a saved program for work that comes round
again.` from `/subharness`. None of them is ever sent to the model.
-The **seven places** are not among them: `/standing`, `/history` and `/memory` open their
+The **eight places** are not among them: `/standing`, `/history` and `/memory` open their
page whatever is in it and let the page say so, rather than writing a line here (the Places
page states the law).
diff --git a/internal/manual/chat/standing-orders.md b/internal/manual/chat/standing-orders.md
index ff1ec381c2..63f175534f 100644
--- a/internal/manual/chat/standing-orders.md
+++ b/internal/manual/chat/standing-orders.md
@@ -339,7 +339,7 @@ Where you said it sets the default; the words in your sentence can move it:
So "always run the tests before you say you are done, everywhere" said in a chat stands
over every project, and "keep this branch green, just in this conversation" dies when
the chat does. If you say nothing about it, the card still tells you which one it picked
-before anything stands — read the `where ·` band before you press `1 yes, set it up`.
+before anything stands. Read the `where ·` band before you press `1`.
**And if the reach is wrong, change it on the card.** `o other` turns the box
below into a place to say either one: "only in this project", "everywhere", "just this
@@ -558,7 +558,7 @@ empty standing page* below has them.
## Nothing stands here yet — the empty standing page
-A machine nothing stands on **still opens the page**. `alt+6`, `/standing` and
+A machine nothing stands on **still opens the page**. `alt+7`, `/standing` and
`/orders` all reach it, and what they reach is the page's heading and one dim line naming
what arrives there and what puts it there:
diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md
index a3e89d4a84..dab3e33fcf 100644
--- a/internal/manual/chat/tasks.md
+++ b/internal/manual/chat/tasks.md
@@ -66,7 +66,8 @@ because the question about a design is the card at the end of it. The page on sa
of work has it in full.
A task can also break its own brief into smaller tasks when it finds independent parts in
-it, and those are drawn as a family under it — see *When a task splits its own work*.
+it, and those are rows of their own on the column, each under what it is doing (see *When a
+task splits its own work*).
**And a copy of its own is the ordinary task, not every task.** The other kind is the
**quick task**: no copy of the folder, no branch, no check and no merge — it works in the
@@ -358,9 +359,9 @@ tasks queue behind it with everything else.
## A quick task started inside a task — a quick row appeared under my task, and who reads its answer
-A quick task started by a task hangs **under that task** on the column, in its family,
-folding and unfolding with it. Its row reads the same `quick · 2/4 · …` it would read
-anywhere.
+A quick task started by a task is a row of its own on the column, under what it is doing,
+beside the task that started it; its room names that task in its breadcrumb trail. Point at
+its row and the hint line reads the same `quick · 2/4 · …` it would read anywhere.
**Its answer goes to the worker that started it, not to you.** The note with its last
message in it is delivered to its parent, which reads it and carries on — the same road a
@@ -1783,8 +1784,8 @@ instant — the stamp is **absent** rather than invented.
`incomplete · the check found gaps: …`. Its branch is kept. The row is dim unless
something actually broke, in which case it reads `incomplete · a fault: ` and
is coloured bad. **There is no `failed` on any card** — that word is the engine's own.
-- **`your call`** — a `?` in the accent colour. Its family rises above running work on the
- roster. The `?` is deliberately neither a tick nor a cross: it claims neither a finding
+- **`your call`**: a `?` in the accent colour. On the column it is in the needs-you band
+ above all the work, in amber. The `?` is deliberately neither a tick nor a cross: it claims neither a finding
nor a judgement nobody made. The card carries the reason it is asking on its own row and
the answers under that — unless you have set `task.settle` to `auto`, in which case the
reason row says `codeaf is deciding` and the answers are drawn beside it.
@@ -1869,7 +1870,7 @@ The strip is one row under the pinned header — a tab bar of doors into live wo
It appears only while something is running, and goes away the moment nothing is. It needs a
frame at least 24 columns wide and 6 rows tall. It is the narrow-frame door: wherever the
roster stands — as the right column or open over the whole frame — the strip stands down.
-A column you closed with `ctrl+g` is a roster standing down, so the strip comes back and
+A column you closed with `alt+l` is a roster standing down, so the strip comes back and
running work stays reachable. The one exception is a running sub-harness: its chip raises
the strip even beside a standing roster, because the roster's rows are tasks and a harness
run is not one — the chip is the only place on the screen that run exists.
@@ -1884,7 +1885,7 @@ appear on it — the strip is the live set, the roster is this session's whole r
A chip carries one glyph and the name cut to 18 cells, and nothing else: no clock, no
spend, no tool name, no tree connector, no cursor mark, and no stop button. The room you
are standing in takes a colour band. The strip is one flat row even when a task has
-children; the roster is where the family tree is drawn.
+children, and so is every row of the roster; a task's own page has its family.
The strip is pointer-only and adds no keys or cursor of its own.
@@ -1904,145 +1905,173 @@ land between chips a few cells apart, so the phone tier trades the tab row for o
## The roster: the column of all the work
-The roster is the **top section of the column on the right**, separated from the pinned
-hide hint by one blank row. There is no `sessions` heading. Under it the same column carries a second section labelled `standing` —
-the orders standing over this conversation — and, when this conversation has started any,
-a third labelled `jobs`. The standing orders page has that half; *Background jobs on the
-column* below has the jobs section.
+The roster is the **Tasks view of the column on the right**. The column is one column in
+every chat, with one header, and it reads like this:
+
+```
+Tasks 14 · Traffic 3 new alt+l
+? Port the parser · your call · nobody co…
+✕ parser bench incomplete
+──────────────────────────────────────────
+Running 4
+ ⠙ rebase the parser onto dev 2m
+ ⠙ port the key table 40s
+Queued 3 ▸
+Done 6 ▸
++ /task
+```
+
+**The header is the column's two words.** `Tasks 14` is this conversation's work, counted
+whole. `Traffic 3 new` is what passes in the team this chat is in; a chat in no team has
+only the first word. The word in front is bold ink and the other is dim, and the other one
+keeps its count, so traffic that arrives while you read the tasks still says `3 new` on the
+header. Each word is a button with a hover ground and a hint (`Show this chat's tasks ·
+click`); press the other word to bring it to the front. A count of `0` is dim. At the right
+of the header is the column's own key, `alt+l` (`opt+l` on a Mac), and it is a button too:
+press it and the column goes away (*Hiding the task column* below).
+
+**Which word is in front is remembered for the session, per kind of chat.** A manager's
+chat opens on the Traffic and every other chat opens on the Tasks. Change it in a member's
+chat and every member's chat you visit this session opens the same way; the manager's chat
+keeps its own answer. The team manager page has the Traffic view.
+
+**Under the header is the needs-you band**, when anything needs you, and nothing at all
+when nothing does:
+
+- a task whose next step is yours (a landing nobody could judge, a branch that
+ conflicted), led by its own state mark, `?` for your call, in the needs-you amber;
+- in a chat in a team, a member's question to you, or a decision put to you, led by `?` in
+ amber;
+- a task that failed while this window watched it and that you have not opened yet, led by
+ its `✕` in ordinary ink. A failure is news, not a demand, so it is never amber.
+
+The band draws at most three rows. A fourth item is drawn as a fourth row, and five or
+more show the first three and a `+2 more` row; press it to show them all, and `fewer` to go
+back to three. A thin rule closes the band. **An item in the band is not drawn again in
+the list under it**, and opening a failure is looking at it: it leaves the band and takes
+its place under Done. Each band row opens its thing: a task's room, the member's chat at the
+question, or the teams page for a decision.
+
+**Under the band the work is grouped by what it is doing:** `Running`, `Queued` (nothing in
+its way but a slot), `Waiting` (behind other work) and `Done`. Running work is always open
+and its heading wears no mark, because it does not fold. The other three start folded to
+one line, `Done 6 ▸`; press the heading, or `enter` on it, to open the group (`Done 6 ▾`)
+and again to fold it. What you open stays open for the rest of the session, through new
+work, new landings and switching chats. A group with nothing in it is not drawn at all;
+there are no empty headings and no `none` rows.
+
+**Every task is one line**: its state glyph two cells in, its name cut with `…` where the
+column is too narrow, and how long it has been at it (or how long it took) in the muted ink
+at the right, `40s`, `2m`, `1h`. The time gives way before the name does: it is left off
+rather than cut a name that would fit whole without it. Inside each group the newest work
+is first. Point at a row and the hint line says the whole of it: the whole name, then what
+the row has no room for, the way it used to be written under it (what it is doing, what it
+waits on, how its branch came home, what it cost), then `click opens it`.
The roster holds every task **this conversation** has admitted, not just the live ones.
-Background jobs are **not** rows among the families: they have their own section under
-`standing`. Tasks
-*other* sessions ran are not on it; the
-page `/history` opens is the one that has them, and one dim line at the foot of the column,
-`ctrl+. earlier`, is the door onto it. Work finishing never puts the column away, and
-neither does `/new` — that takes this session's tasks with it and leaves the column
-standing, with the door onto the project's record still at its foot. With no foreground
-command to keep, `ctrl+g` closes it and leaves the work exactly where it was. While a
-command can be kept, that command takes the key instead; the column's `❯` pointer door
-still closes it. The pinned top line names whichever keyboard action is available.
-
-The column is permanent: it stands from the session's first keystroke, before any task
-exists, at a frame width of 100 columns or more — 30 columns wide from 120 up, a slim 24
-columns from 100 to 119. Work fills it rather than raising it. The one frame without it is
-the untouched empty conversation, which opens on a centred greeting and no column at all
-until you type, a task lands, or a standing order reaches it (*The empty screen* page). A conversation that has run nothing
-draws no empty label or absence sentence, **whatever the project has behind it** — the
-typeable `+ /task` door remains, and in a directory whose earlier sessions ran tasks the door
-`ctrl+. earlier` sits at the foot of the column. Under 100 columns there is no
-column, and `alt+t` opens the same roster over the body instead once this session has
-tasks.
-
-Closed with `ctrl+g` when no foreground command owns the key, the column leaves a
-two-column edge at the right of the frame that opens it again on a click — see *The task
-bar disappeared* below.
-
-The roster is a forest. Each root task is followed by its whole family, with children
-joined by three-cell connectors (`├─ `, `└─ `, `│ `). Root tasks appear in creation
-order, oldest first, and children keep creation order within their parent. Changes in
-state do not move rows. The kin line on a task's page uses the same child order.
-
-The sidebar begins with `❯ ctrl+g hide`, pinned above the task list. New tasks and
-scrolling never move that control. `+ /task` follows the task list, above any standing
-orders or jobs. The footer keeps state totals, such as `3 running · 2 needs you · 12 done`.
-
-Folding belongs to each node. Families with a running, needs-you, or queued member start
-open. Settled families and families containing only waiting work start folded to their
-root; the root then carries the family's aggregate state glyph and a `▸ +N` badge for the
-hidden descendants.
+Tasks *other* sessions ran are not on it; the page `/history` opens is the one that has
+them, and one dim line at the foot of the column, `ctrl+. earlier`, is the door onto it.
+Work finishing never puts the column away, and neither does `/new`: that takes this
+session's tasks with it and leaves the column standing.
+
+Under the task list the same column carries `+ /task`, then a section labelled `standing`
+(the orders standing over this conversation) and, when this conversation has started any, a
+third labelled `jobs`. The standing orders page has that half; *Background jobs on the
+column* below has the jobs section. The header and the band stand still at the top while
+the list under them scrolls.
+
+**The column is permanent** from 100 columns up: it stands from the session's first
+keystroke, before any task exists, and work fills it rather than raising it. It is a
+quarter of the frame, never under 28 columns or over 40: 28 at 110, 30 at 120, 40 at 160.
+It is the same width in every chat and whichever word is in front, so switching words,
+opening a group, a new row and the band appearing never move the conversation beside it.
+From 120 columns up `alt+w` widens it by 16 columns, when the conversation keeps at least
+56. The one frame without it is the untouched empty conversation, which opens on a centred
+greeting and no column at all until you type, a task lands, or a standing order reaches it
+(*The empty screen* page). Under 100 columns there is no column and no edge, and `alt+t` (or
+`alt+l`) lays the same column over the body instead.
## Where did the workers go on the task page — find them as rows under their task
-**Workers under a task are the family's own rows and nothing else** — there is no second,
-smaller list drawn beneath a row. A task that split itself into parts, and an
-adaptive run and its workers, each announce themselves as tasks with a parent, so every one
-of them is an ordinary row of the forest above: its own state glyph, its own name, its own
-`#id`, reachable with `↑`/`↓` and openable with `→`. A worker you can see is a row you can
-walk to.
-
-The blank row above the tasks stays blank even while several workers are running.
-
-**A run's rows are on this column too.** The column draws this conversation's own tree, and
-under the conversation that started a run it draws that run's tree out of the same reading:
-one line per task — the connector, the state mark and the fitted title — with
-`waits: ` at the end of a line held behind named work, and the run's own row
-ending in the dot row (*what are the dots next to a task?* has the cells). While a task's
-worker is on a step, its row spends one line under it — the running glyph `◐`, the shell
-lead `$` and the command its worker is on right now — and nothing else, because the steps
-and the cost are the tasks place's own rows. That page (the page `/history`, `ctrl+.` and
-`alt+3` open) has the run whole.
+**Workers under a task are rows of their own.** A task that split itself into parts, and an
+adaptive run and its workers, each announce themselves as tasks, so each one is an ordinary
+row of the list under the group it is in: its own state glyph, its own name, reachable with
+`↑`/`↓` and opened with `enter`. The column draws what each piece of work is doing, not a
+tree of who started whom; the task's own page (its kin line) and the task page have the
+family.
+
+**A run's rows are on this column too.** Under the conversation that started a run, the
+column draws that run's tree out of the tasks place's reading: one line per task (the
+connector, the state mark and the fitted title) with `waits: ` at the end of a
+line held behind named work, and the run's own row ending in the dot row (*what are the
+dots next to a task?* has the cells). While a task's worker is on a step, its row spends one
+line under it: the running glyph `◐`, the shell lead `$` and the command its worker is on
+right now. That page (the page `/history`, `ctrl+.` and `alt+4` open) has the run whole.
## What is the diamond symbol next to each task? — why the sidebar has no diamond, the mark on the cards
**On the column at the right there is no diamond.** A task's row there opens with one cell,
-the **state** glyph, which changes as the work does. Then the name, then its id as `#7`,
-dim, at the far end — and the id stands down when the name would be left under 12 cells.
-Every row of that column leads the same way, family rows included, so it reads downward as
-one column of states.
+the **state** glyph, which changes as the work does. Then the name, then how long it has been
+at it, muted, at the far end. There is no `#7` on the row: the hint line names the task
+whole when you point at it. Every row of that column leads the same way, so it reads
+downward as one column of states.
The `◆` is still on the surfaces that hold more than tasks: the proposal card, the card
that lands, the notes in the conversation, and a queued task's chip on the strip above the
conversation. It is one marker, the same on every task, saying only that this row is a
-piece of work — which is worth a cell where tasks sit among other things, and worth nothing
-in a column that is only tasks. It used to be on the column too, where it cost two of the
-twenty-two cells the name has and pushed each row's detail line two cells out of line under
-its own title. On a terminal with no colours or no unicode the marker is `#`.
+piece of work, which is worth a cell where tasks sit among other things and worth nothing
+in a column that is only tasks. On a terminal with no colours or no unicode the marker is
+`#`.
The marker used to be one of eight shapes in one of six colours, picked from the task's id,
so that a given task wore `◆` teal everywhere. That is gone. It had to be learned, it was
relearned every session because ids start again in each conversation, and it told you
-nothing you could act on — the `#7` already says which task, and the state glyph and its
-word already say what it is doing.
+nothing you could act on: the state glyph and its word already say what the task is doing.
-**The name is the task's own title, cut to its first three words** — `Fix the nil-map`,
-`Collect the sources` — and that is the name it wears everywhere: the column, the strip
+**The name is the task's own title, cut to its first three words** (`Fix the nil-map`,
+`Collect the sources`), and that is the name it wears everywhere: the column, the strip
above the conversation, its room's header, the card that lands, the home card and the task
page. Three words is also what the `taskname` call is asked for, so a named task fits the
column whole rather than being cut to fit it. A row reading **`task 19`** means one thing
only: nothing has told codeaf what that task is called yet. It is a name you can still say
-out loud, and the row takes the real one the moment the title arrives — including a room you
-already have open on it. Under the row, at
-most two more: what it is doing, what is holding it, what it waits on, or how its branch
-came home. `conflicted · task/fix-nil` in the bad hue is the one loud row on the column.
-
-**The row of the room you are standing in is picked out.** Walk into a task — from the
-roster, a strip chip, a spawn card or a `task 7` link — and that task's row in the column
-takes a colour band across its whole width, every line of it, with its title in the accent
-and bold. It is the same mark the strip puts on the chip of the room you are in, so the two
-lists of the work never disagree about which door you went through. It follows you: opening
-another task's room moves it, and `esc` back to the conversation clears it. With no room
-open no row is marked at all. On a terminal with no background colours the accent title is
-what is left of it.
+out loud, and the row takes the real one the moment the title arrives, including a room you
+already have open on it. **Nothing is drawn under a row.** What it is doing, what is holding
+it, what it waits on, or how its branch came home (`conflicted · task/fix-nil`) is on the
+hint line while the pointer is on the row, and a branch that needs you is in the band at the
+top of the column.
+
+**The row of the room you are standing in is picked out.** Walk into a task (from the
+roster, a strip chip, a spawn card or a `task 7` link) and that task's row in the column
+takes a colour band across its whole width, with its title in the accent and bold. It is the
+same mark the strip puts on the chip of the room you are in, so the two lists of the work
+never disagree about which door you went through. It follows you: opening another task's
+room moves it, and `esc` back to the conversation clears it. With no room open no row is
+marked at all. On a terminal with no background colours the accent title is what is left
+of it.
There is no subtitle here. The column is a presence list; the proposal card and the landing
card both carry the sentence.
-Under the task rows, one dim `+ /task` row closes the section — press it and `/task ` is
-typed into your message box. Then a blank line, then the column's `standing` section.
+Under the task rows, one dim `+ /task` row closes the list; press it and `/task ` is typed
+into your message box. Then a blank line, then the column's `standing` section. There are
+no state totals at the foot: the header counts the tasks and each group's heading counts its
+own. The session's spend and tokens are on the status row. When anything stands over this
+project, a `◦ 2 standing orders` line follows and opens `/standing`.
-At the bottom, under the sections, up to three dim lines count the roster in its own words:
-`3 running · 1 needs you`, `148 waiting · 12 done`. Zero counts are left out entirely. The
-session's spend and tokens are on the status row instead; there is no `Σ` line here. When
-anything stands over this project, a separate `◦ 2 standing orders` line follows and opens
-`/standing`.
-
-Below those are up to two door lines: `ctrl+. earlier` or `ctrl+. view more` when the
-full-screen page holds something this column does not, `alt+w widen · click seam` when a
-wider column would stop cutting a title. The column's own way out is pinned at the top,
-above every task. It reads `❯ ctrl+g hide` when no foreground command can be kept and
-only `❯ hide` while a command owns that key. Click either form and the column goes away.
-The task list scrolls below it in creation order; `+ /task` stays below the visible list.
+Below those are up to two door lines: `ctrl+. earlier` when the full-screen page holds work
+this column does not, and, while the column holds the keyboard or the pointer is on it,
+`alt+w widen · click seam` from 120 columns up. The column's own way out is the `alt+l` at
+the right of its header.
**Non-running rows are drawn quieter.** A running task's name is in the ordinary text
-colour; queued, waiting and finished names are muted, the tree connectors and every detail
-line are dim, and the room you are standing in is the one row in the accent. Nothing is
-hidden by this — the column is a record and keeps everything — but a glance at it lands on
-what is moving.
+colour; queued, waiting and finished names are muted, headings are muted with dim counts,
+and the room you are standing in is the one row in the accent. A glance at the column lands
+on what is moving.
## Background jobs on the column — the jobs list on the right, what is running in the background, why a long command shows on the right, the row for a server, build or watch
**A background job is not a row among the tasks.** It used to sit in the roster with the
-families. It is now a **third section** on the same column, under `tasks` and `standing`,
+tasks. It is now a **third section** on the same column, under the tasks and `standing`,
labelled `jobs`. This covers everything the `jobs` tool can list except a task's own
worker, which already has a roster row of its own:
@@ -2285,9 +2314,9 @@ around it.
holding `fix the flaky test` becomes `/task fix the flaky test`, which is the line you
were about to type anyway.
- **Pressing it twice does nothing the second time** — the word is already at the front.
-- It is drawn whether or not this conversation has run anything, under the column's own
- `tasks` label, and it is the **pointer's** row: the roster's keyboard cursor (`alt+t`)
- walks task rows and skips it. From the keyboard you type the command, which is what the
+- It is drawn whether or not this conversation has run anything, under the Tasks view's
+ last row, and it is the **pointer's** row: the roster's keyboard cursor (`alt+t`) walks
+ the band, the headings and the task rows and skips it. From the keyboard you type the command, which is what the
row is teaching.
Finish the sentence and send it and it is `/task ` like any other: codeaf sizes the
@@ -2313,7 +2342,7 @@ still size, shape and start the work directly, with no proposal card in between
extra question. There is no third form: `/task adaptive` is retired.
**There is still no `/tasks` command**, though `sessions` is the name of the PLACE `/history`
-opens, `alt+3` and `tab` get there without typing anything. As a slash word the plural is not one this surface answers to;
+opens, `alt+4` and `tab` get there without typing anything. As a slash word the plural is not one this surface answers to;
the two things a bare `/task` and a `/task ` do are the pair of errands a person has
about tasks — go and look at the work, or give codeaf some.
@@ -2328,28 +2357,26 @@ ctrl+. earlier
```
- **It is a door and not a note.** Press `ctrl+.`, or click that line, and the full-screen
- sessions place opens with every task this machine has run on it, grouped by what you do next
- — `running` and `completed`. `/history` is the same page.
-- **It says what is behind it.** With a record behind it the line reads `ctrl+. earlier`;
- with no record, on a column that has merely folded a family away, the same line reads
- `ctrl+. view more`. There is only ever one such line.
+ sessions place opens with every task this machine has run on it, grouped by what you do
+ next: `running` and `completed`. `/history` is the same page.
- **It is drawn only when there is something behind it**, and never as `0 earlier` or any
- other count of nothing.
+ other count of nothing. There is only ever one such line.
- **A task of your own is not behind it twice.** A task this conversation ran, running or
- landed, is on the column above in its own family; the door is offered for work this
- session never ran, and for a family the column has folded.
-- It is dim, like the totals above it, and it is a button as well as a key.
+ landed, is on the column above under its group; the door is offered for work this session
+ never ran. A group folded to its heading is not a reason for the line: its heading opens it
+ in place.
+- It is dim, and it is a button as well as a key.
-**The column used to footnote the record** — up to six dulled `earlier` rows under this
+**The column used to footnote the record**: up to six dulled `earlier` rows under this
session's work, walkable with `↓`, each opening a card. They are gone. Six rows out of a
-record that runs to two thousand is a sample; they stood where the column's own
-`no tasks yet` label goes; and the cursor walked out of this conversation's work into
-another one's without the column ever saying it had. Everything they offered is on the
-other side of the door, whole: every row, the filter, the cards, and `m` for the mention.
+record that runs to two thousand is a sample, and the cursor walked out of this
+conversation's work into another one's without the column ever saying it had. Everything
+they offered is on the other side of the door, whole: every row, the filter, the cards, and
+`m` for the mention.
**Where old work is listed now:** the task page (`ctrl+.`, `/history`, or that line), and
home (`/home`, or space twice on an empty box). The chat can also read the whole project
-record for you with its `tasks` tool — just ask.
+record for you with its `tasks` tool; just ask.
**Running work in another codeaf window** is on no surface but the task page. An ordinary
task writes nothing into the project's file until it lands, so the window next door is the
@@ -2359,14 +2386,17 @@ only place that work can be read from, and `/history` is the page that reads it.
**Reopening a conversation re-draws its own tasks, and the jobs it ran.** `/resume`,
`codeaf resume`, and switching back behind home all rebuild the column from the record:
-every task admitted here comes back as a row in its family — finished work included, and
-work that was interrupted comes back saying so on its card — and the `jobs` section
+every task admitted here comes back as a row under its group (finished work included, and
+work that was interrupted comes back saying so on its card), and the `jobs` section
redraws the jobs this conversation started, settled. Tasks and jobs are not lost when the
terminal closes; the column is rebuilt, not carried. **Quick tasks are in that too** — a
finished one comes back `done` with its answer, and a conversation that ran them is never
left with an empty column, which it was until this was fixed (*A quick task after a
restart* has the whole of it).
+A failure that comes back this way is under `Done` and not in the needs-you band: it is
+news from before you left, and the band carries only what failed while this window watched.
+
The rebuilt row reads its start and landing times from that same record. Work that landed
in a previous session therefore keeps the time it actually landed instead of taking the
time you reopened the conversation. A record made before those times were kept still
@@ -2379,42 +2409,42 @@ twice — a row on the column is not also an `earlier` row.
## Hiding the task column: closing the right sidebar, panel or task bar
-With no foreground command that can be kept, `ctrl+g` closes the column of tasks on the
-right and gives its columns back to the conversation. Press it again and the column
-comes back with the current state of the work in it, including anything that started or
-finished while it was gone — nothing here is a snapshot; the column is redrawn from the
-tasks every frame. While a foreground command can be kept, that command takes the key
-instead and the column stays exactly where it was.
+`alt+l` (`opt+l` on a Mac) closes the column on the right and gives its columns back to the
+conversation. Press it again and the column comes back with the current state of the work
+in it, including anything that started, finished or arrived on the Traffic while it was
+gone: nothing here is a snapshot; the column is redrawn every frame. It is the same key in
+every chat, whichever word is in front. `ctrl+g` does the same when no foreground command
+can be kept; while one can, `ctrl+g` keeps the command instead and the column stays where it
+was.
-**The pointer can do the whole cycle on its own.** The line immediately above `+ /task` reads
-`❯ ctrl+g hide` with no foreground command to keep and `❯ hide` while one owns the
-key. The chevron is in ink: click either form and the column closes. What is left
-behind is a thin edge carrying `❮`: click that and the column comes back. One control, two
-states — `❯` to close, `❮` to open — so a closed column is never a thing you need to know a
-chord to recover. See *The task bar disappeared* below.
+**The pointer can do the whole cycle on its own.** The column's header ends in the key
+itself, `alt+l`, dim, at its right; it takes a ground under the pointer, the hint line says
+`Hide this column · alt+l`, and a press closes the column. What is left behind is a thin
+edge carrying `❮`: click that and the column comes back. So a closed column is never a thing
+you need to know a chord to recover. See *The task bar disappeared* below.
The choice is remembered. It is written to your profile the moment the column moves, as
the `ui.task_column` setting, which also appears in the settings panel (`ctrl+,`) on the
Display tab as **task column**. A change made in the panel lands the next time codeaf
-starts; `ctrl+g` acts immediately and wins for this session.
+starts; `alt+l` acts immediately and wins for this session. On the teams page the column
+starts folded, because that page has a rail of its own on the left, and `alt+l` there opens
+it for that visit only.
With the column closed, work is still visible:
-- Anything **running** draws the task strip along the top — `⠙ Fix nil-map · ◆ Auth tests
- · +2` — because the strip stands up wherever the roster stands down. Click a chip for
+- Anything **running** draws the task strip along the top (`⠙ Fix nil-map · ◆ Auth tests
+ · +2`), because the strip stands up wherever the roster stands down. Click a chip for
that task's room, or the `+N` for the whole roster.
-- The keys row under the message box carries `ctrl+g tasks` for as long as
- this session has any tasks at all, running or not. A session that has run nothing says
- nothing there — the column you closed was empty, and `ctrl+g` still brings it back.
+- The legend's hint slot under the message box carries `alt+l tasks` (`alt+l traffic` in a
+ manager's chat) for as long as this session has any tasks at all. A session that has run
+ nothing says nothing there; the column you closed was empty, and `alt+l` still brings it
+ back.
- `alt+t` still works: asking for the roster brings the column back and gives it the
keyboard in one press.
-When no command can be kept, `ctrl+g` works whether or not the session has tasks — the
-column stands empty, so an empty column is still a column to close. It does nothing, and
-is not swallowed, only when there is no roster on the frame at all: a frame under 100
-columns where nothing has raised the roster over the body. On
-the untouched empty screen, where no column has stood yet, the key is a first keystroke
-first — the greeting goes — and then closes the column as usual.
+`alt+l` works whether or not the session has tasks: the column stands empty, so an empty
+column is still a column to close. Under 100 columns, where there is no column beside the
+conversation, it lays the column over the body instead, and a second press takes it off.
## The task bar disappeared — how do I get the task column back
@@ -2426,108 +2456,100 @@ rather than dim so the eye can find it:
…and the parser suite passes now. ❮
```
-**Click anywhere on that edge and the column comes back** — the whole strip is the door,
-not just the handle, so there is nothing to aim at. It is the column act `ctrl+g` performs
-whenever no foreground command can be kept.
+**Click anywhere on that edge and the column comes back**: the whole strip is the door, not
+just the handle, so there is nothing to aim at. `alt+l` does the same from the keyboard.
- Under the pointer the handle brightens further and the whole two-cell strip takes a
- background, which is how everything pressable on this screen says so.
-- **The chevron points the way the column goes**, and it is the same control in its other
- state: `❮` while the column is away, `❯` above `+ /task` while it stands.
- That line says `ctrl+g hide` only when the key is available, and says `hide` otherwise.
- Clicking one gives you the other, so the pointer goes round the full cycle. On a terminal
- that cannot draw them they are `<` and `>`.
+ background, which is how everything pressable on this screen says so. The hint line says
+ `Show this column · alt+l`.
- **One cell above the handle says what the work is doing**, while there is anything worth
- saying: `?` in the question colour when a task is waiting on you, `◐` in the accent when
- something is running. Nothing at all otherwise — a session with nothing running and
- nothing waiting leaves the edge silent, and so does one that has run no work.
+ saying: `?` in the needs-you amber when a task or a team member is waiting on you, `◐` in
+ the accent when something is running. Nothing at all otherwise: a session with nothing
+ running and nothing waiting leaves the edge silent.
+- **In a chat in a team, two cells below the handle count what arrived on the Traffic**
+ since you last had it in front of you, dim, up to `99`. The hint says it too, `3 new in the
+ traffic`.
- The edge costs the conversation two columns, exactly as the column it stands for costs it
its own width. The text re-wraps; nothing is ever drawn underneath it.
- **On a frame narrower than 100 columns there is no edge**, because there is no column at
- that width to bring back. The roster still opens over the whole frame with `alt+t`.
-- The edge is for the hand that does not type chords. With no foreground command to keep,
- `ctrl+g` is the keyboard's way around the same cycle; with one, it backgrounds the command.
+ that width to bring back. `alt+t` or `alt+l` lays the column over the whole frame.
+- On a terminal that cannot draw `❮` it is `<`.
-The legend above the message box also carries `ctrl+g tasks` while the column is away and
+The legend above the message box also carries `alt+l tasks` while the column is away and
this session has run something.
## Using the roster from the keyboard
-`alt+t` (`opt+t`) hands the keyboard to the roster. It is asked for, never taken: the draft is the
-rest state, so a person who starts typing is typing, not navigating.
+`alt+t` (`opt+t`) hands the keyboard to the column. It is asked for, never taken: the draft
+is the rest state, so a person who starts typing is typing, not navigating. The cursor starts
+on the first row that is work: a band row if there is one, else the first task.
| key | what it does |
| --- | --- |
-| `↑` `↓` | walk the visible tree |
-| `→` | open a folded family, or step to the first child |
-| `←` | fold an open family, or jump to the parent row |
-| `enter` | open the task's room, or a job's page; on the `jobs` label, toggle the section |
-| `alt+w` | toggle the wider 46-column tree (bare `w` only on the full-frame roster) |
+| `↑` `↓` | walk the rows: the band, the group headings, the tasks, then the jobs |
+| `enter` | on a task, open its room; on a folded or open heading, fold or open that group; on a band row, open its thing; on a job, its page; on the `jobs` label, toggle the section |
+| `←` `→` | in a chat in a team, switch between Tasks and Traffic; the cursor goes to the first row of the other view |
+| `x` | stop the task under the cursor, where it can be stopped |
+| `alt+w` | toggle the column 16 columns wider (bare `w` only over the full frame) |
| `esc` or `alt+t` | give the keyboard back |
-| `ctrl+g` | keep a foreground command when one can be kept; otherwise close the column altogether, or bring it back — this one works whether or not the roster holds the keyboard |
+| `alt+l` | close the column altogether, or bring it back; this one works whether or not the column holds the keyboard |
-The legend hint while it holds the keyboard is `↑↓ move · →← fold · enter open · esc`.
-When depth has forced a title to be cut, the footer adds `alt+w widen · click seam` (or
+The legend hint while it holds the keyboard is `↑↓ move · enter open · x stop · alt+w wide ·
+esc`, led by `←→ tasks/traffic · ` in a chat in a team. On a row waiting on your look the
+slot says that row's answers instead. The footer adds `alt+w widen · click seam` (or
`alt+w narrow · click seam` once it is wide); that hint is clickable as well as available
-from the keyboard, and so is the `❯` door line under it. The latter includes `ctrl+g`
-only when no foreground command owns the chord. **Both the hint and the seam appear only
-from 120 columns up**, which is the only width that lends a wider tier — on a 100-to-119
-column frame the footer says nothing about widening and the column's left edge is part of
-the row, not a handle.
-
-Every other key is given back. The roster cannot take the keyboard while the exit
-confirmation, a permission question, a task proposal, or any overlay is up, and with no
-tasks **and no jobs** of this conversation's on the column, `alt+t` falls through rather
-than being swallowed — including in a directory whose earlier sessions ran plenty. There
-are no rows down there to put a cursor on; the door `ctrl+. earlier` at the foot of the
-column is how that work is reached. A session that has only started a server still has
-the jobs section to put a cursor on, so `alt+t` takes it.
-
-**The walk stops at this conversation's last job, after its last task.** `↓` walks the
-families and then the jobs section under them, and clamps there rather than carrying on
-into the project's record. `enter` on a task row opens that task's **room**; `enter` on a
-job row opens that job's **page**; `enter` on the `jobs` label toggles the section. It
-used to walk into six dulled `earlier` rows below, which meant holding `↓` took you out of
-this conversation's work and into another one's; old work is walked on the task page now
-(`ctrl+.`), where `enter` goes inside its card.
-
-The cursor follows the task, not the row, when families reorder or fold around it.
+from the keyboard. **Both the hint and the seam appear only from 120 columns up**, which is
+the only width that lends a wider column; on a 100-to-119 column frame the footer says
+nothing about widening and the column's left edge is part of the row, not a handle.
+
+Every other key is given back. The column cannot take the keyboard while the exit
+confirmation, a permission question, a task proposal, or any overlay is up, and with
+nothing on it to put a cursor on, `alt+t` falls through rather than being swallowed,
+including in a directory whose earlier sessions ran plenty. The door `ctrl+. earlier` at
+the foot of the column is how that work is reached. A session that has only started a
+server still has the jobs section to put a cursor on, so `alt+t` takes it.
+
+**The walk stops at this conversation's last job, after its last task.** `↓` walks the band,
+the groups and then the jobs section under them, and clamps there rather than carrying on
+into the project's record. Old work is walked on the task page (`ctrl+.`), where `enter`
+goes inside its card.
+
+The cursor follows the task, not the row, when a task moves from one group to another. If
+its group is folded, the cursor rests on that group's heading.
With the pointer, a row takes the hover background step. The next section has the whole of
what a click on the column does.
## Clicking the tasks on the right — I cannot open a task from the sidebar, clicking a row does nothing, the list jumps instead of opening
-**Anywhere on a task's row opens that task's room.** The state glyph, the name, the `#7`,
-the blank cells after it — the whole visible row is that one door, at every width the
-column stands at. A click moves the column's cursor to what was clicked but does not hand
-the roster the keyboard; the draft is still where you type.
+**Anywhere on a task's row opens that task's room.** The state glyph, the name, the time,
+the blank cells between them: the whole visible row is that one door, at every width the
+column stands at, and the hover ground covers exactly that row. A click moves the column's
+cursor to what was clicked but does not hand the column the keyboard; the draft is still
+where you type.
-Exactly two things on a row mean something else, and **both of them are drawn on the screen
-at the moment you press them**:
+The other things on the column that answer a press say so by lighting under the pointer:
-- **The `▸ +N` badge** at the right of a folded family's root. It is what says work is
- hidden under that row, so pressing it opens the family. It is on screen all the time.
-- **The disclosure triangle**, which appears in the leading cell of a family root — and of
- a finished row holding a detail block back — **only while the pointer is on that row**
- (`▾` open, `▸` folded). While it is drawn, pressing it folds; the rest of the row still
- opens the task.
+- **A group's heading** (`Queued 3 ▸`, `Done 6 ▾`) opens or folds that group. The hint line
+ says `Show what is done · click` or `Fold what is done · click`. The `Running` heading does
+ not fold, wears no mark and does not light.
+- **A band row** opens its thing: a task's room, the member's chat at the question, or the
+ teams page for a decision put to you. `+2 more` shows the whole band and `fewer` folds it.
+- **The header's words.** In a chat in a team, the word that is not in front brings its view
+ to the front; the `alt+l` at the right closes the column. The space between them lights
+ nothing and does nothing.
-With the pointer anywhere else, that leading cell is the task's **state**, and pressing a
-state opens the work it is the state of. It used to fold the list instead, whether or not
-a triangle was drawn there — so a press aimed at a family root or a finished task made the
-list jump and opened nothing. It was easiest to hit straight after walking into or out of
-another task, because that is when the surface forgets where the pointer is and the
-triangle is not drawn.
+There is no disclosure triangle on a task row and no `▸ +N` badge: a task row is never a
+fold. The folds are the group headings, and a press on a task's state glyph opens the task.
**Two other cells used to swallow presses and no longer do.** The column's two leftmost
-cells are the resize handle only from 120 columns up; on a narrower frame there is no
-wider tier to pull to, so those cells are part of the row like any other. And the column
-answers only for the rows it actually draws — a press below its last row belongs to the
-message box, the legend or the status line under it, not to the column.
+cells are the resize handle only from 120 columns up; on a narrower frame there is no wider
+column to pull to, so those cells are part of the row like any other. And the column answers
+only for the rows it actually draws: a press below its last row belongs to the message box,
+the legend or the status line under it, not to the column.
-A click inside the column that lands on no task at all still belongs to the column and
-does nothing, rather than closing the task page you are reading.
+A click inside the column that lands on no row at all still belongs to the column and does
+nothing, rather than closing the task page you are reading.
**A second click on the selected row keeps its task open**, with the same draft and
reading position. Press `esc` or the back control to return to the conversation.
@@ -2544,11 +2566,10 @@ nothing else, so a task you ran last week, in a session you have closed, is nowh
screen until you open this.
**There is no `/tasks` command** — though `sessions` is what the PLACE this opens is called on
-the tab bar, reached with `alt+3` or `tab`. `/task ` starts work; `/history` opens the same sessions place
+the tab bar, reached with `alt+4` or `tab`. `/task ` starts work; `/history` opens the same sessions place
started — and so does a **bare `/task`**, which opens this very page rather than printing a
usage line. The page is also reached from the one dim door line at the bottom of the task
-column — `ctrl+. earlier`, or `ctrl+. view more` where the column has merely folded a
-family away.
+column, `ctrl+. earlier`, whenever the project has work this conversation never ran.
The page takes the whole frame, the way the settings panel does. `esc` closes it. Three
pages here take the frame — the settings panel, this one, and `/home` — and **only one of
@@ -2669,7 +2690,7 @@ it does not shrink as you type a filter. All levels start expanded. Folding a br
hand adds the number of hidden rows to the section heading as `folded away`.
**Every door onto this place opens it, on a machine that has run nothing too.** `/history`,
-a bare `/task`, `ctrl+.`, `alt+3` and `tab` all reach the same page, and with nothing on it
+a bare `/task`, `ctrl+.`, `alt+4` and `tab` all reach the same page, and with nothing on it
the page is its heading and one line naming what arrives there, instead of counts:
```
@@ -3172,30 +3193,30 @@ A task **this** session ran opens its room instead, which is the live thing: the
`enter`, a strip chip and a `task 7` link all land there. Only work from a conversation that
is closed opens the card.
-## The one door line at the bottom of the task column: `ctrl+. earlier`, `view more`
+## The one door line at the bottom of the task column: `ctrl+. earlier`, and where `view more` went
-When there is more work than the column is showing, the roster's footer grows one more dim
-line above the column's final `❯` hide door:
+When the project's record holds work this column is not showing, the roster's footer grows
+one more dim line:
```
ctrl+. earlier
```
Click it, or press `ctrl+.`, and the full-screen task page opens. The column is left exactly
-as it was — the page is somewhere you go and come back from, not a state the column enters.
+as it was: the page is somewhere you go and come back from, not a state the column enters.
-**There is exactly one such line, never two**, and the words on it say what is behind it:
+**There is exactly one such line, never two.** It is drawn when the record holds tasks
+**this session never ran**: work from an earlier conversation, or from a window still open
+beside this one. This is the common case: any directory you have worked in before has one.
-- `ctrl+. earlier` when the project's record holds tasks **this session never ran** —
- work from an earlier conversation, or from a window still open beside this one. This is
- the common case: any directory you have worked in before has one.
-- `ctrl+. view more` when the only thing held back is a **folded** family, so the column
- is standing one row for work it is not drawing, and there is no earlier work to promise.
+**`ctrl+. view more` is gone.** It stood for a family the column had folded away. The column
+folds its own groups now (`Done 6 ▸`), and the heading is the door: press it and the rows
+open in place.
-A landed task of this session's, already drawn on the column, does not earn the line: it
-would be offering to show you what you are looking at. So a first-ever session in a fresh
-directory, with nothing folded and no record behind it, has no door line at all, and that
-is not a bug. It is never drawn as a count of nothing.
+A landed task of this session's, already on the column, does not earn the line: it would be
+offering to show you what you are looking at. So a first-ever session in a fresh directory,
+with no record behind it, has no door line at all, and that is not a bug. It is never drawn
+as a count of nothing.
## Which task a row opens — two tasks numbered 7, a row opened a different task, the wrong transcript
@@ -3262,8 +3283,9 @@ far task id is itself the room door, including while the task is running.
## Task roster and rooms while running on another machine
Over `--host`, the roster beside the conversation lists that far conversation's tasks
-from the far machine's record. `ctrl+g` closes or restores it exactly as it does for
-a local conversation; it never falls back to tasks on the machine holding the screen.
+from the far machine's record, under the same header, band and groups. `alt+l` closes or
+restores it exactly as it does for a local conversation; the column reads only what this
+window already holds, so drawing it never waits on the connection, and it it never falls back to tasks on the machine holding the screen.
The far engine **pushes** every task update to every window attached to that
conversation, so a row appears when the work is admitted, moves when it starts running,
@@ -3326,7 +3348,7 @@ local conversation the same page tails that log live.
| how you stop the work | `esc` | `x` over an empty box, which raises the confirmation card |
| the box's own line | the bare `› ` | a tinted segment naming the task, in its state's hue, then `› ` |
| box placeholder | the draft prompt | `Steer this task… (esc: main)`, or `Steer … (esc: main)` where the frame is too narrow for the segment |
-| pinned top rows | the pulse line, the tab strip under it, one thin rule and a blank — the same four rows every place draws; a dim `+N` at the strip's right end counts the tabs it could not spell, and `alt+k` opens the chats card | the same four rows — pulse, tab strip, rule, blank — so the rule does not move when you walk in; then a breadcrumb row (conversation → ancestor tasks → current task) and a quiet facts row under it |
+| pinned top rows | the pulse line, then the tab strip, one thin rule and a blank. That strip is the chat's own row, so a place draws three rows (pulse, rule, blank) and the page starts one higher. A dim `+N` at the strip's right end counts the tabs it could not spell, and `alt+k` opens the chats card | the same four rows as the conversation: pulse, tab strip, rule, blank. Then a breadcrumb row (conversation, ancestor tasks, current task) and a quiet facts row under it |
| legend word | the model, effort and approvals, with the remote machine when connected | `room · esc/←← main`, and `room · esc your line back` while a history walk is on |
| legend hint | `esc interrupt` while a turn runs | `x stop` while there is work to stop, `↑↓ history` mid-walk, nothing otherwise |
| the model on the status row | the conversation's model | `task ` |
@@ -3659,8 +3681,8 @@ the nearest ancestor it hides. The current task is inert. Unknown parents are om
the UI never substitutes a bare task id for a name. Guest ancestry remains visible but
inert because this window cannot use another conversation's local task ids.
-The roster shows children with their parent and state. That tree and the breadcrumbs
-use the same parent links; the separate `part of:` sentence no longer repeats
+The roster shows each child as its own row under what it is doing. The breadcrumbs use
+the parent links; the separate `part of:` sentence no longer repeats
the parent. A compact `handed out:` row still names children and their state. Prerequisites that hold work back are still named in the
header's state. A task awaiting an actionable decision keeps its answer row.
@@ -3789,12 +3811,12 @@ task's own proposals begin the moment they are made. What you see instead is the
Where they show up:
-- **the strip along the top** keeps one flat row of live chips on narrow frames; it does
- not draw the family tree.
-- **the roster** draws the whole family together, with each piece joined to its parent by
- tree connectors and carrying its own id and state.
+- **the strip along the top** keeps one flat row of live chips on narrow frames.
+- **the roster** draws each piece as a row of its own under what it is doing (`Running`,
+ `Queued`, `Waiting`, `Done`), with its own state; it does not draw a tree. The hint line
+ over a piece's row names it whole.
- **the parent's room** shows the `propose_task` calls as they are made, and the parent's
- own words when the reports come back; its children remain grouped on the roster.
+ own words when the reports come back; its header lists its children and their state.
- **the piece's own room** names the parent in its breadcrumb trail, so a task
you walked into knows it is a piece of something.
@@ -4168,9 +4190,8 @@ own cost row. The standing orders page has the rest of what an unattended run is
## Where the parts show up on the screen, and how to stop them
-**Where you see it:** the parts appear in the task column under their parent, joined by tree
-connectors and carrying their own id and state, exactly as pieces handed out from the brief
-do. Walk into the parent's room and its header lists each part by name with the state it is
+**Where you see it:** the parts appear in the task column as rows of their own, each under
+what it is doing, with their own state, exactly as pieces handed out from the brief do. Walk into the parent's room and its header lists each part by name with the state it is
in; walk into a part and its breadcrumb trail names the parent.
**Stopping.** Stop the parent and its unfinished parts stop with it, their branches kept.
@@ -4222,7 +4243,8 @@ same time.
The setting `task.parallel` exists for anyone who wants a number anyway — settings panel
(`ctrl+,` or `/settings`), category "spending". Blank means no limit. A cap is a queue and
never a refusal: work past the cap waits and starts when a slot frees, and while it waits
-its roster row reads `waiting · slot`; a waiting-only family starts folded.
+its roster row says `waiting · slot` on its hint line, and it sits under `Queued`, which
+starts folded to its heading.
What actually runs out is the machine, not a count of tasks. Two real ceilings hold new
starts instead:
@@ -4580,9 +4602,9 @@ files, the branch, the model, the cost, the done-condition and the report.
**A nested task asks the same way — a sub-task needs my look, a piece of a bigger task
nobody checked.** Depth changes nothing about whether you are asked: the card, the roster
row and the sub-task's own room all offer the same answers from the moment it lands. What
-depth changes is how LOUD it is. While the task above it is still running, the sub-task
-**folds** under its family head, because that task's own agent is the one being asked and
-has the diff to read; when the head settles, one line says the question changed hands
+depth changes is how LOUD it is. While the task above it is still running, the sub-task is
+**not in the needs-you band**: it sits under `Waiting`, because that task's own agent is the
+one being asked and has the diff to read; when the head settles, one line says the question changed hands
(`task 4 has finished, and the piece of work it handed out that nobody could check — task
6, Port the parser — is now waiting on you rather than on it.`). It is never filed under
`done`.
diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md
index bdd2ea3035..a45fb814bc 100644
--- a/internal/manual/chat/team-manager.md
+++ b/internal/manual/chat/team-manager.md
@@ -34,10 +34,18 @@ happens on the far machine, where the conversation and the model are.
In the manager's replies, in the team's quoted cards and in a team tool's call, every member's
handle is a link: point at it for its title in the hint line, press it to open that member,
-which resumes it first when this window does not have it open.
+which resumes it first when this window does not have it open. A team's name in those same
+places, written as a team (`team harbor`, `the harbor team` or `"harbor"`), is a link too. A
+press opens the **teams page** with that team selected. The hint says `Open harbor on the
+teams page · click`. Over `--host`, against an engine with no teams doors, the press opens the
+conversations view on that team instead, and the hint says so.
## Making a manager
+The **teams page** (`/teams`, `alt+2`) offers `+ Manager` on every team that has none, and
+`All teams` offers one manager over every team. Choosing a team there puts its manager's
+conversation in the page's pane, where you talk to it beside the tree of your teams.
+
A team has no manager until you make one, and until then nothing about managers costs anything.
While a team is shown, the first place on the tab strip is the manager's, pinned at the left
like a pinned browser tab so it never scrolls away:
@@ -62,62 +70,82 @@ While the manager is in front, the message box says `to ◆ manager`, and keeps
rule above the box once you start typing: everything you type goes to the manager and nowhere
else. With a member in front the box says `to @web` the same way.
-On the right is the **Traffic** rail: who told whom inside the team, as threads. A thread is
-one message and everything that answered it, and the thread that moved last is at the top,
-straight under the header:
+On the right is the conversation's column, the same one every chat has. Its header is two
+words, `Tasks 14 · Traffic 8` with `alt+l` at its right: the word in front is in bold ink and
+the other is dim, each is a button (point at it for what it shows, press it to bring it to
+the front), and while the tasks are in front the Traffic word keeps counting what arrived,
+`Traffic 3 new`. With the manager in front the column opens on the **Traffic**; with a member
+in front it opens on the tasks. Each kind of chat remembers the word you last chose for the
+rest of the session. With the column holding the keyboard (`alt+t`), `←` and `→` switch the
+words. Nothing here moves the conversation: both words are drawn in the same columns.
+
+Under the header is what needs you, from both words: a member's question to you (`? @model → ◆
+keep the old schema?`), a decision put to you, and your tasks that are waiting on
+you, in the needs-you amber; a task that failed and that you have not opened yet is its `✕` in
+ordinary ink. Three rows at most, then `+2 more`; a line closes the band, and when nothing
+needs you there is no band at all. Pressing a question opens the member at the question; a
+decision opens the teams page. What is in the band is not drawn again under it.
+
+In the manager's chat the Traffic is the team's **work**, one line each, the newest at the
+top. Every row reads who it is from and who it is for, then the words:
```
-Traffic hide alt+l
-◆ manager → @agent @checking @review do 2m
- Please provide a brief status update on your part
-├ @checking ✓ Status update: the lexer is in 1m
-├ @agent working…
-└ @review asking: may I run the migration? now
+Tasks 14 · Traffic 8 alt+l
+? @model → ◆ keep the old schema?
+✕ parser bench incomplete
+──────────────────────────────────────────
+◆ → @scrape +2 Please provide a st… ▸
+◆ → @model Refactor the rail… ▾
+ ↳ @model → ◆ ✓ done, 3 files changed
+ ↳ @model → ◆ working…
+General 2 msgs ▸
```
-- The header says who sent it, to whom, and `do` for a directive or `fyi` for a note. When the
- rail is too narrow for every handle it names whom it can and counts the rest, `+2`.
-- The message is on its own dim line under it.
-- Each member who answered, or was started on it, has one line of the tree, in the order it
- happened. `working…` is a member the message woke that has not answered yet; `✓` is a member
- that answered and finished its turn, `✗` one whose turn failed, and `asking:` is a member
- waiting on you, the only line in the needs-you amber.
-- `◆ manager stopped @web · going in circles`, `◆ manager started @lexer` with the brief under
- it, and `codeaf @review is now @security` are lines of their own, as is anything written
- before threads.
-
-Headers and answers end with their age (`now`, `2m`, `3h`); on a narrow rail only the header
-does. Your own messages are not on the rail; they are in the manager's conversation. Every
-`@handle` is a link, as in the chat: point at it for the member's title in the hint line, press
-it to open that member (resumed first when this window does not have it open) **scrolled to the
-message**: a handle on a thread's header opens the member at the directive as it was told it,
-and a handle on an answer opens it at its own post. The message is brought into view and
-lifted for a moment; the focus stays on that conversation. A message from before the
-conversation's history opens it at the bottom, and the hint line says `that message is older
-than this chat's history`. Point at a message's words to read them whole in the hint line,
-press them to lay them out in full under the row, and press again to fold them; the press also
-brings that thread's card in the manager's conversation into view. Nothing on the rail moves
-your focus but a handle.
-
-With the manager in front the right-hand column is the Traffic, whatever you last told the task
-column with `ctrl+g`: the task column is not drawn beside it, not even as an edge. When the
-manager has live tasks of its own the header grows a second word, `Traffic · Tasks 2`; press
-`Tasks 2`, or `ctrl+g`, to lay the manager's tasks in the same column at the same width, and
-press the header line, or `ctrl+g` again, to go back to the Traffic. With no tasks there is no
-second word and `ctrl+g` does nothing here. Every other conversation's task column works as it
-always has. The rail's header reads `Traffic` with `hide alt+l` at its right. Put away, the rail is the word
-`Traffic` down the right edge with a count of what arrived since you last looked; press it or
-`alt+l` to bring it back. This window remembers whether you put it away.
-
-On a window too narrow for the column (under 84 columns) the rail is only that edge. Pressing it, or `alt+l`, lays the Traffic over the lower part of the
-conversation as a card with `Close esc` in its foot; the top of the conversation stays in view.
-`esc` closes it.
+- A row is a thread (below): `◆ → @scrape +2` is the manager to the first member, and `+2`
+ for the ones that do not fit, then what it said. The state its answers leave it in
+ (`running`, `asking`, `done`, `failed`) and how many messages it holds sit at the right
+ when they fit, and the hint line always says them. The age is not on the row at any width.
+ Pointing at the row says the age on the hint line, and the band's questions do the same.
+ In a narrow column the arrow and the names stay, and only the words are cut, at a word,
+ with `…`.
+- `▸` lays the thread's replies open under it, one `↳` line per member, each `from → to`
+ the same way (`↳ @model → ◆ ✓ done, 3 files changed`): `✓` on an answer from a member
+ that finished its turn, `✗` for one that failed, `working…` for a member the message woke
+ that has not answered. The reply's time is on the hint line, not on the row. `▾` (or
+ `enter` on the row, with the column holding the keyboard) folds them.
+- Everything that answers nothing and is answered by nothing (notes, starts, stops, handle
+ changes, anything written before threads) is the one **General** thread, laid open the
+ same way. A start that made a sub-team reads `◆ manager started @api to run backend`; the
+ name is the team's name now, so a rename replaces `backend` on the next frame.
+- A thin `new` line stands under what arrived since you last looked, and stays put while you
+ read.
+
+Every `@handle` is a link: point at it for the member's title in the hint line, press it to open
+that member (resumed first when this window does not have it open) **scrolled to the
+message**: a handle on a thread's row opens the member at the directive as it was told it, and a
+handle on a reply opens it at its own post. The message is brought into view and lifted for a
+moment; the focus stays on that conversation. A message from before the conversation's history
+opens it at the bottom, and the hint line says `that message is older than this chat's history`.
+Point anywhere else on a row to read it whole in the hint line; press it to bring that message
+in the manager's conversation into view. Nothing in the column moves your focus but a handle or a question in the band.
+
+In a member's chat the Traffic is the messages to or from that member (and to the whole team),
+one line each, newest first, and that member is `you`: `◆ → you parser numbers?` from the
+manager, `you → ◆ ✓ p50 41ms, p99 180ms` back, `you → @gravity rebase done` to another
+member. Press one to go to it in the member's conversation. General, laid open, uses the same
+`from → to` on each of its lines.
+
+`alt+l` puts the column away and brings it back, and this window remembers the answer (`ctrl+g`
+is the same key under its older name). Put away, the column is an edge down the right with a
+count of what arrived in the Traffic since you last looked; press it or `alt+l` to bring it
+back. On a window under 100 columns there is no column and no edge: `alt+l` lays the column
+over the conversation, and `esc` or `alt+l` takes it off again.
Over `--host` teams and the manager work as they do locally. The teams and their Traffic are
kept on the machine the conversations run on, and the window reads and writes them there, so
-the rail is the team's own and a manager set from the laptop is the one the far session
+the Traffic is the team's own and a manager set from the laptop is the one the far session
follows. Against a far machine running an older codeaf, `+ Manager` and the menus say managers
-are not available over `--host`, and there is no rail.
+are not available over `--host`, and the column has no Traffic word.
When the manager starts a member with `team_start`, you are asked first, on a card that reads
`◆ manager wants to start @lexer`, with the brief under it and the clause `a new conversation;
@@ -134,6 +162,16 @@ the same way. When the manager stops a member,
this window stops it the way your own Stop would. Both happen only in a window that has those
conversations open.
+## Who a Traffic row is from and who it is to
+
+Every Traffic row reads `from → to`, then the words. The manager is `◆`. Several recipients
+are the first handle and `+2` for the rest. In a member's chat that member is `you`, so a
+question to it reads `◆ → you` and its answer reads `you → ◆`. A reply under a thread is the
+same shape after `↳` (`↳ @model → ◆ working…`). The band's question is the same shape in
+amber (`? @model → ◆ keep the old schema?`). The age and the reply's time are on the hint
+line at every width, never on the row. A narrow column keeps the arrow and the names and cuts
+only the words, at a word, with `…`. Press a handle to open that member at the message.
+
## What members say without being asked
A member of a team that has a manager tells the manager, through the traffic, three things it
@@ -156,6 +194,16 @@ It does not. `asking` means a live process is held on the prompt. If that proces
transcript lock is free and the member reads as idle. The same happens when the wait is older
than 30 minutes, even if a process still holds the transcript: nothing is still asking.
+## Who a member reports to
+
+A conversation can be in more than one team, but it **reports to** exactly one manager, its
+home: the nearest manager above it, picked for you and never changed by itself. Its home
+manager directs it; every other manager whose team it is in is a **link**, which may read it
+(`team_read`) and send it a note, and nothing more. A link's `team_send` of a directive, and
+its `team_stop`, are refused with a sentence saying whose the member is; a directive to
+`everyone` goes to the members who report to that manager and names the ones it left out.
+In `team_status` a shared member reads `reports to dock`, and `busy for dock` while it runs.
+
## Who outranks whom
Your own words come first. What you say in a member's own conversation stands over anything
@@ -174,24 +222,103 @@ on one waits for you.
| `team_read` | the end of one member's conversation, bounded; the member is not told | no |
| `team_send` | a message to one member, to several (one message, every handle in `to`), or to everyone, as a note (information, which waits) or a directive (an instruction, which starts an idle member) | no |
| `team_stop` | ends one member's current turn, the way your own Stop does: nothing is deleted, and its background tasks and jobs keep running. It is carried out by a window that has the member open; a member codeaf opened in the background, with no window on it, is not stopped and its turn runs to its end | no |
-| `team_start` | a new member conversation with a handle and a brief; it opens in the team's folder and is handed the brief, marked as the manager's, on its first request | yes |
-
-`team_start` asks because a new conversation spends money for as long as it runs. The others
-act only inside the team you made, and every one of them is logged in the team's traffic.
+| `team_start` | a new member conversation with a handle and a brief; it opens in the team's folder and is handed the brief, marked as the manager's, on its first request. With kind `team` it starts a sub-team instead (see **Sub-teams**) | yes |
+| `team_decide` | answers a decision packet waiting on the manager, most often a member's question: an option, or its own words | no |
+| `team_escalate` | sends a packet waiting on the manager up, to its own manager or to you, with the reason it is not the manager's to decide | no |
+| `team_close_report` | brings you the team's closing report (done, left, where the files are) after you asked it to wrap up | no |
+| `team_raise` | raises a conflict to the manager above every party (see **Conflicts between members and teams**); members have it too | no |
+
+`team_start` asks because a new conversation spends money for as long as it runs, and it is
+refused while the team is at its daily cap. The others act only inside the team you made, and
+every one of them is logged in the team's traffic. Questions, packets, caps and wrapping up are
+on the page **Team questions, decisions and caps**.
Like any tool, each can be set to ask or allow in `/settings` under the tool approvals.
-## The member's verb
+## The member's verbs
A member of a team that has a manager has `team_post`: a message to the room (every member and
the manager), to one teammate by handle, or to the manager. Members use it to report progress,
-share a finding, ask a teammate, or say they are blocked.
+share a finding, ask a teammate, or say they are blocked. It also has `team_raise`, for a
+conflict it cannot settle with the other side itself.
+
+## A manager is told when a member moves
+
+Moving a conversation from one team to another, or moving a team under another, writes one
+line to the Traffic of each team it touches. The team that lost the member says
+`@web moved to harbor`. The team that gained the member says `@web joined from ops`. For a
+team moved under another, that is the moved team's Traffic and its new parent's. A manager
+reads those lines the next time it wakes, in the Traffic it already reads. Nothing about the
+move wakes it by itself, and a move that was refused writes no line.
+
+## Sub-teams
+
+A manager can start a **sub-team**: `team_start` with kind `team`, a name for the new team, a
+handle and a brief for its manager, and optionally members of its own team to move into it. You
+are asked first, on the same card as any start, which then reads `a new team "backend" under
+yours`. When you allow it:
+
+- the new team is made under the manager's team, with its share of the pool: the parent's daily
+ cap times `sub-team share` (`/settings`, **Teams**; 50% by default), written on the new team.
+ A parent with no cap gives none, and the new team spends from whatever pool is above it;
+- the members named move into it, and its manager is the new conversation, which opens behind
+ the one you are in like any start, is a member of the parent team, and is handed the brief
+ marked as the manager's together with the words `you were started to manage the team
+ "backend"`. It makes itself that team's manager and reports to the manager that started it;
+- the traffic of both teams says so: the start in the parent's, and in the new team's a line
+ naming who made it and who runs it.
+
+It is refused, with the reason, past the team depth (`team depth` in `/settings` under
+**Teams**, three levels by default, a team's own override first), under a closed team, at the
+team's daily cap, or when the name or the handle is taken.
+
+**Orders go one level down, reports one level up.** A manager directs its own team's members,
+and a sub-team's manager is one of them; it never directs a sub-team's members. A `team_send`
+directive or a `team_stop` naming one is refused with a sentence that names the sub-team's
+manager to send to instead. A message to `everyone` reaches the manager's own members only. A
+manager may still read a conversation in a team under its own with `team_read`, naming it
+`backend/@parser`. A sub-team's manager reports to the manager above: its `team_post` to the
+manager and its own questions go there first.
+
+**A sub-team with no manager of its own** answers to the nearest manager above it: its members'
+questions go to that manager, and their `team_post` to the manager reaches it, marked with the
+team it came from. Their posts to the room stay in their own team. That manager does not direct
+them; give the sub-team a manager, or move them up, for that.
+
+## The global manager
+
+With several top-level teams there is no one manager over all of them until you make one on the
+teams page's `All teams` row. That conversation is the **global manager**: the manager of a
+team that holds every other team. Its members are the managers of the top-level teams, and
+only them: codeaf adds each one to `All teams` for you, its account of its team lists only
+those managers, and its directives reach them and never their members. The top-level managers
+report to it, so their questions and conflicts between teams come to it before they come to
+you. It can start a new top-level team with `team_start` of kind `team`. Without a global
+manager, nothing here changes: each top-level manager reports to you.
+
+## Conflicts between members and teams
+
+When two or more conversations need incompatible things (the form posts JSON, the endpoint
+takes form data) and cannot settle it between themselves, one of them raises it with
+`team_raise`: the question, the other parties by handle (`@api`, or `back/@api` for a member
+of another team), its own side, and the options, each with what happens if it is chosen. A
+conflict is never detected for you; a party declares it.
+
+It goes, as one decision packet, to the **lowest manager above every party** who is not one of
+them, in one hop: two members of one team go to its manager, members of two sibling sub-teams to
+the manager of the team both sit under, and two top-level teams to the global manager. With no
+such manager it comes to you, in the inbox on the teams page. That manager is woken and handed
+the packet whole; the other parties are told it was raised. It rules with `team_decide`, or
+sends it up with `team_escalate`, never sideways. **The ruling reaches every party as a
+directive**, marked as a ruling on that conflict, in each party's own team's traffic, and wakes
+each of them, whoever ruled: a manager, or you. A party never decides its own case, even when
+it manages the team the packet waits on.
## Threads: what answers what
Every line a member is handed carries its number, `◆ directive from manager #42: …`. A
member's `team_post` to the manager answers the last message the manager sent it, by itself;
to answer another it names it, `thread: #41`. The member's finishing, failing or asking, and
-the wake that started it, answer the same message, which is how the rail and the chats draw
+the wake that started it, answer the same message, which is how the Traffic and the chats draw
them under it. A message to several members is one message, so the question and its answers
are one thread however many were asked.
@@ -201,7 +328,9 @@ answer attached under that in muted ink as it arrives, one line each. Press an a
to read it in full and again to fold it; point at them for the whole text in the hint line; a
handle opens its member at the message: on an answer, at the member's own post, and on the
card's header, at the message as the member was told it. A turn that sent one is not folded into a `worked` chip, so the
-thread stays where you can see it.
+thread stays where you can see it. The step over it is captioned as the work it is,
+`messaged @agent @checking @review`, never by the tool's name, and a run of sends reads
+`sending 3 messages`.
The answers also reach the manager as its team's note. So nothing is shown twice, a note
whose answers are already under their question reads as one dim line,
@@ -227,16 +356,18 @@ A member no window has open is opened by codeaf in the background so it can run,
that opens it later joins the running conversation. When that cannot be done, the traffic says
`could not wake @web:` and why, and the message waits for the member's next turn.
-Every wake is a line in the traffic, `◆ woke @web` or `@web woke ◆`, so the rail shows why a
+Every wake is a line in the traffic, `◆ woke @web` or `@web woke ◆`, so the Traffic shows why a
conversation is running. A wake spends through the same limits a turn you start does, and two
more bound it: one conversation is woken at most 20 times an hour, and a manager woken 10 times
by its team with nothing from you stops being woken and asks you instead, as a waiting line in
the traffic. It is woken again after you next say something to it.
-A team's auto-wake can be turned off: its entry in `teams.json` in the profile carries
-`"wake": false`. With it off, every message waits for each conversation's next turn, and a
-member the manager starts with `team_start` is opened and handed its brief on that next turn,
-with no turn started for it. The traffic says so.
+A team's auto-wake can be turned off. `team messages wake` in `/settings` under **Teams** is
+the default every team inherits (on), and a team can override it for itself and the teams
+under it: its entry in `teams.json` in the profile carries `"wake": false` (or `true`). With it
+off, every message waits for each conversation's next turn, and a member the manager starts
+with `team_start` is opened and handed its brief on that next turn, with no turn started for
+it. The traffic says so.
A member busy in one long command reads a message when that command returns, which is what
`team_stop` is for. Nothing is delivered twice, and a conversation that joins a team is not
diff --git a/internal/manual/chat/team-questions-and-caps.md b/internal/manual/chat/team-questions-and-caps.md
new file mode 100644
index 0000000000..fdc2607ce5
--- /dev/null
+++ b/internal/manual/chat/team-questions-and-caps.md
@@ -0,0 +1,107 @@
+# Team questions, decisions and caps
+
+## Where a member's question goes
+
+In a team with a manager, a member's clarifying question goes to **its manager first**, not to
+you. When the member asks with `ask` (a question it needs answered to go on: which of two
+shapes, what a word means, whether to go ahead), nothing appears on your screen: the question
+becomes a **decision packet** for the manager it reports to, and the member is told it went
+there. The manager answers it with `team_decide`, the member is handed the answer marked
+`◆ answered: …` and carries on, and the traffic rail shows `answered @web: …`. A member that
+was idle is woken by the answer.
+
+When the manager cannot or should not answer, it sends the question up with `team_escalate`:
+to its own manager, or to you. It reaches you only when no manager above could decide it.
+
+**Permission prompts never go up.** A member asking to run a command or edit a file asks you,
+always, and no manager can answer that for it.
+
+This is the setting `questions go to the manager` in `/settings` under **Teams**, on by default.
+A team can override it for itself and the teams under it, on its card (the **teams page**);
+with it off, a member's questions come to you as they always did.
+
+The **Teams** tab of `/settings` holds the defaults every team inherits: `questions go to the
+manager`, `team messages wake`, `daily cap per team`, `team depth` and `sub-team share`. Its
+dim line says `a team can override any of these on its card · saved to your profile`. Over
+`--host` the same five rows are the other machine's, and a change is saved there. Each value
+says `from Settings`, the same words a team's card uses when it inherits them. An older
+engine that cannot take the change keeps the tab read only and says
+`changing them is not available over this connection`.
+
+## What a decision packet is
+
+Everything that has to be decided above the conversation that met it travels as one packet you
+can answer without reading a transcript: the question, who raised it and what each side said,
+the options with what happens if each is chosen, and a recommendation with its reason. The same
+packet is answered by a manager or by you. Packets waiting on you are in the **inbox** on the
+teams page, one card each, with the options as buttons and `Your own answer…` for words of your
+own. A packet waiting on a manager shows as `waiting on ◆ harbor`, and you can still decide it
+yourself: you outrank every manager.
+
+A manager's own questions reach you the same way: as a packet addressed to you, in the inbox,
+when it has no manager above it. Your answer is handed to the manager marked `◆ answered: …`
+and wakes it.
+
+Packets are kept in the profile of the machine the conversations run on, in
+`teams//decisions.jsonl` for the team each was raised from. Past a megabyte the file starts
+a new one, keeping every packet still waiting.
+
+## A team's daily cap
+
+A team can have a daily cap: `daily cap per team` in `/settings` under **Teams** is the default
+every team inherits (no cap), and a team can set its own. A cap counts the team and every team
+under it together, one pool.
+
+When the pool reaches its cap:
+
+- nothing new starts in it: a directive no longer wakes a member, replies no longer wake the
+ manager, a new member's brief waits, and `team_start` is refused. A turn already running is
+ never cut off; it finishes, and what was held is delivered at each conversation's next turn;
+- you are asked once, with a card `harbor reached its $5 cap today`: **Raise to $10** (the
+ team goes on until $10 today) or **Stop for today** (members finish their current turn and
+ start no new one until tomorrow), with a recommendation;
+- your own messages in a member's conversation are never held; the cap is on the work the team
+ starts by itself.
+
+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`.
+
+## Two windows ask once when a team reaches its cap
+
+You are asked once for that team, that day, and that ceiling. A second codeaf window, or a
+wake while the terminal is closed, finds the card already raised and adds nothing, so you do
+not get two copies of `harbor reached its $5 cap today`. The next day asks again. The same
+day asks again only after you choose **Raise to $10** and the team then crosses that new
+ceiling.
+
+## Wrapping up and closing a team
+
+`Close…` on the teams page, `Close team…` on a team's card, or `D` on the conversations view
+closes a team; with nothing running it closes at once and offers Undo (the **teams page** has
+every path). Closing a team with work running offers **Wrap up first**. The manager is asked to tell every
+member to finish the piece in hand and commit, to answer what it can, and then to bring you a
+**closing report** with `team_close_report`: what was done, what is left, where the files are,
+and what the team spent today. It arrives as a card with **Close** and **Keep going**, and the
+team closes only when you pick Close.
+
+The wrap-up has 15 minutes and $2 of team spend. When it runs out of either before the
+manager reports, codeaf brings you the report itself, marked `wrap-up incomplete`, with
+**Close now** and **Keep going**.
+
+## 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
+sent by codeaf itself, marked `wrap-up incomplete`, with **Close now** and **Keep
+going**. If that write cannot take the decisions file because another writer still
+holds it, the countdown stays due. The next look tries again. It does not wait for
+a restart, and it does not send the report twice. A report that did go out clears
+the countdown in memory and on disk, once.
+
+## What happens to a wrap-up when codeaf restarts, does a wrap-up keep going if I quit codeaf
+
+The countdown is kept with the team, in `teams.json`: the moment the wrap-up began, and the
+15 minutes it was given. Quitting codeaf, or the engine restarting, does not drop it and
+does not hand out another 15 minutes. The next time the manager's conversation is open, the
+countdown continues with the time that is left. If that time already ran out while codeaf
+was closed, codeaf brings the incomplete report once, the same way it does when the clock
+runs out with codeaf open. Opening codeaf again does not bring that report a second time.
diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md
new file mode 100644
index 0000000000..279960b45f
--- /dev/null
+++ b/internal/manual/chat/teams-page.md
@@ -0,0 +1,365 @@
+# The teams page
+
+## What the teams page is, and how to open it
+
+The **teams page** is where you run your teams: every team you have, what waits on you from
+them, and the manager of the team you choose, which you talk to right there. It is the second
+place on the tab bar, right after home: `home teams chats sessions spend settings`. Open it with
+`/teams`, `alt+2` (`opt+2` on a Mac), a click on the word `teams`, `tab` from home, or the map
+(`alt+.`). A number beside the word on the bar counts the decisions waiting on you that arrived
+since you last looked.
+
+A team is a group of conversations you name, and a team's **manager** is a conversation that
+runs it for you (the **team manager** page). The conversations view (`alt+v`) is where you see
+every conversation at once and group them; this page is where you steer the teams you made.
+
+## Where a team link in a chat opens
+
+A team link in a chat opens this page. That is a team's name written as a team (`team harbor`,
+`the harbor team`, `"harbor"`), a team name on a team card or in a team tool's row, and a
+`●harbor` you sent with `@`. A press selects that team: the rail's cursor on it, the pane
+showing it. A closed team is selected inside `Closed`, and that fold is opened. The hint says
+`Open harbor on the teams page · click`. Over `--host`, when the engine has no teams doors,
+the press opens the conversations view on that team instead, and the hint says so.
+
+## The rail: your teams as a tree
+
+The left column is the **rail**:
+
+```
+ All teams + Manager │
+ ● harbor ◆ ? 1 │
+ ● orbit ⠿ │
+ ● docs │
+ │
+ + New team in harbor │
+ ✦ Organize │
+ │
+ ▸ Closed · 2 │
+```
+
+- **`All teams`** is the top row. With no manager over every team it offers `+ Manager`,
+ which starts one conversation that manages all of your teams: you talk to it, and it talks
+ to each team's own manager. Once there is one, the row is that manager's.
+- **Every open team**, a sub-team indented under the team it belongs to, with its colour dot.
+ A team with a manager wears a dim `◆`.
+- **A mark only when something is happening.** A dim `⠿` says a member of the team is
+ working; an amber `? 2` says two things wait on you from it or from a team under it: a
+ decision addressed to you, or a member stopped on a question only you can answer. A team
+ where nothing is happening draws no mark at all.
+- **`+ New team`** makes a team of the conversation in front (the new-team card of the
+ conversations view). With a team chosen it reads **`+ New team in harbor`** and the new team
+ is made inside that team; on a narrow rail it takes two rows, `+ New team` and `in harbor`.
+ A team already at its depth limit dims it and says why. **`✦ Organize`** suggests teams for
+ your conversations and offers to close the quiet ones (see *Organize closes quiet teams*
+ below).
+- **`▸ Closed · N`**, folded at the foot, holds the teams you closed. A press opens the fold
+ and lists them; a press on one shows what it left behind.
+
+`↑` `↓` walk the rail, `enter` or a press chooses a team, and `←` `→` cross between the rail
+and the pane beside it. Choosing a team with a manager brings that manager's conversation in
+front, in the pane. The tab strip is not drawn on this place. The conversation you were
+in stays open behind: `chats` on the top line, or `tab`, brings it back, and its tab is
+on the strip once that chat is in front.
+
+## The pane: the team you chose
+
+The right side is the team you chose, from the top:
+
+**The header** is one line:
+
+```
+ ● harbor ◆ Manager @news ⠿ working @review ? asking +4 idle $0.42 today Settings Close… Open ▦
+```
+
+The team's name; **`◆ Manager`**, a door to its manager (the conversation below it on this
+page); then only the members that are doing something, each a door with its title and state
+in the hint (`@news · weekly news digest · working · click opens`): `⠿ working`, `? asking` in
+amber when a member is stopped on a question for you, `✗ failed` in red when its last turn
+failed. Everyone else is one quiet word, **`+4 idle`**, or **`6 members`** when nobody is doing
+anything, and a press on it opens the members card. Then what the team spent today, shown
+only when there is a spend or a cap to compare it with: `$0.42 today`, or `$0.42 of $5 today`
+under a daily cap. When the cap is inherited from the team above, the header names whose it
+is, `$1.20 of $5 today · harbor's cap`, because a cap is one pool for a team and every team
+under it. Then three word buttons: **`Settings`** (the team's card, `s`), **`Close…`** (`c`),
+and **`Open ▦`** (the conversations view narrowed to this team, `w`).
+
+On a narrow screen the line gives up its parts in order: the idle word first, then the spend,
+then the members' chips from the last, then `◆ Manager`, then the buttons from the right. The
+team's name always stays. `p` opens the members card whatever the width.
+
+**The members card** (`+4 idle`, `6 members`, or `p`) is titled the way the header counts,
+`harbor · ◆ Manager · 1 member`, and lists every member, one row each: its
+handle, its title, what it is doing, when it last moved, and **`Open`** for a conversation this
+window has open or **`Resume`** for one it does not. A member that reports to another team's
+manager carries a small `also in test` tag, and its hint says `reports to test's manager`.
+`Open` goes to the conversation. `Resume` opens it **behind**, in a tab of its own, without
+moving you: the page says `@docs is open behind, in its own tab`, and the row turns to `Open`.
+`↑` `↓` walk the rows, `enter` opens or resumes, `esc` or `Close` puts the card away. The
+page never says `not open`: whether a conversation is open is a fact about this window, not
+about the team.
+
+**What waits on you.** Each decision addressed to you is a card, led by the same `?` the rail
+and the tabs use for something that needs you:
+
+```
+ ? conflict · raised by @boss waiting on you
+ Which lexer do we keep?
+ @parser the new lexer is 3x faster and passes every test
+ @model the old one is what the grammar tool emits
+ Keep the new lexer we maintain a fork ✓ recommended
+ Keep the old lexer 3x slower, no fork
+ recommended because speed is the goal of this team
+ Your own answer…
+```
+
+One press on an option's word decides it, and the answer goes back to whoever raised it.
+`Your own answer…` opens a line for words of your own; `enter` decides with them and `esc`
+puts it away. A closing report shows what the team did, what is left, where the files are and
+what it spent; a cap card shows `spent $5.20 of $5.00 today · harbor's cap` and offers
+`Raise to $10` or `Stop for today`, which only you can decide. A member stopped on a
+permission prompt shows `? @web asks` with the same answer buttons home offers for it, the
+prompt's own options. The newest three cards show whole; older ones fold to one line each,
+`▸ question · which port? waiting on you`, and a press unfolds one. While the manager's
+conversation is under them, the cards take about a third of the window: the newest (or the
+one you unfolded) is whole, older ones stay whole while they fit, and the rest fold, so
+there is always room to read and steer the manager. A card waiting on a manager instead of
+you leads with `◆`, reads `waiting on ◆ harbor`, dim, and you can still decide it: you
+outrank every manager. The **team questions and caps** page says what a packet is and where questions
+go.
+
+**The manager's conversation.** Under all of that is the team manager's own conversation, the
+real one, with its transcript, its prompts and its message box, which says
+`to ◆ harbor manager`. Typing talks to the manager. The conversation's right-hand column
+(`Tasks` and `Traffic`) is folded on this page, because the teams rail already has the left;
+`alt+l` or its edge unfolds it. A press on a handle in the Traffic goes to that member's
+conversation, off the page, exactly as it does in the conversation's own screen.
+
+## Renaming a team updates the message box
+
+The box reads the team's name when it draws. After a rename it says
+`to ◆ manager` on the next frame, and a Traffic line
+`to run ` does the same. Neither keeps the name from when the page opened.
+
+While the manager's conversation is on its way in, the pane says `opening ◆ harbor's
+manager…`. It never waits silently: if the conversation cannot be opened, or has not answered
+within four seconds, the pane says why, `couldn't open ◆ harbor's manager: `, and
+offers **`Retry`** and **`Open in chats`** (the manager as an ordinary conversation, off the
+page, where anything else wrong is said on its own line). When the manager's conversation is
+gone from the disk, the pane offers **`+ Manager`** instead, which starts a new conversation and
+makes it the team's manager.
+
+## The pane said the manager was open in another window
+
+A refusal about a conversation this window already holds is not said. That includes
+a connection that keeps one conversation at a time: if the swap is told the
+transcript is locked and this window already holds that manager, behind or in
+front, the pane does not say `open in another window`. The manager is brought
+forward instead.
+
+**A team with no manager** shows `+ Manager` under its members, beside one line on what a
+manager does; a press starts a new conversation in the team's folder and makes it the manager.
+Choosing `All teams` shows every decision waiting on you from any team.
+
+## Moving a team inside another team, and adding a chat to a team
+
+Teams nest: a team can sit inside another, the way `orbit` sits inside `harbor` on the rail.
+You move a team by choosing where it goes.
+
+**Move into…** Choose a team on the rail and press `m`, or open the team's card and press its
+**`Inside: harbor ▾`** row. A picker opens with every team as a tree and `Top level` first:
+
+```
+╭─ Move dock into ─────────────────────────────╮
+│ Filter ▏type to filter │
+│ ─────────────────────────────────────────── │
+│ Top level │
+│ ● harbor │
+│ ● orbit │
+│ ● dock │
+│ ─────────────────────────────────────────── │
+│ orbit is 2 levels deep · limit 2 · Settings │
+╰──────────────────────────────────────────────╯
+```
+
+Typing filters the list and the tree keeps its indent. `↑` `↓` walk, `enter` moves, `esc`
+cancels. **A team that cannot take the move is dimmed, not hidden**, and the line at the foot
+(and the hint line) says why: the team itself (`a team cannot go inside itself`), a team inside
+it (`orbit is inside dock`), a closed team, a team already at the **depth limit**
+(`orbit is 2 levels deep · limit 2 · Settings`, and `set on harbor` when a team above set the
+limit), or where it already is. The depth limit is `team depth` under **Teams** in
+`/settings`, and any team can override it on its card.
+
+**Several teams at once.** `space` on a team's row picks it, and it wears `☑` in place of its
+dot; `m` then moves every picked team with one choice. `esc` clears the picks. A picked team
+inside another picked team moves along inside it.
+
+**Dragging.** On the rail you can also drag a team with the pointer. A drag starts only after
+you move two cells with the button held, so a click still only chooses the team. While you
+drag, only a team that can take it is highlighted, and the hint line says
+`Drop to move dock into harbor`, or why the team under the pointer cannot take it. The empty
+rail under the teams is the top level, marked `↳ Top level` while you drag. `esc` drops the
+drag and nothing moves.
+
+**Adding a chat to another team.** Open the members card and drag a member's row onto a team
+on the rail: the hint says `Add @crane to harbor`, and the conversation is **added** to that
+team. It stays in the team it came from; a drag never takes a conversation out of a team.
+Removing one is always its own step, in the team switcher or the conversations view.
+
+**When a move changes who is in charge, you are asked first**, in one line at the top of the
+pane (or on the team's card):
+
+```
+ dock will report to harbor's manager · its $3/day becomes part of harbor's $10 pool Move Cancel
+```
+
+It appears only when the move changes one of three things: which manager the team's
+conversations report to, which capped pool its spending counts toward, or which manager decides
+a conflict inside it. Any other move happens at once.
+
+**Every move can be undone.** For a few seconds after a move, confirmed or not, the pane (or the
+card) says `dock is in harbor now Undo`; `Undo` or `u` puts it back where it was.
+
+## A move is written to Traffic
+
+When a move is kept, Traffic records it. The team that moved, and the team it left, each get
+`@crane moved to harbor`. The team it joined gets `@crane joined from ops`. One line per
+member, once. A move that was refused (the team could not go there) writes nothing. A manager
+whose team gained or lost someone reads that line the next time it wakes, from the Traffic it
+already reads. The move does not start a wake of its own.
+
+## Keys on the teams page
+
+While the manager's conversation has the message box, keys type into it, as in any
+conversation. The page keeps these:
+
+| Key | What it does |
+|---|---|
+| `alt+↑` `alt+↓` | put the keyboard on the page's buttons and walk them (`opt+↑` `opt+↓` on a Mac) |
+| `esc` | from the page's buttons, back to the message box |
+| `tab`, `shift+tab` | the next or previous place |
+| `alt+1` … `alt+9`, `alt+.` | jump to a place, draw the map |
+
+On the page's buttons, and on a team with no manager in the pane:
+
+| Key | What it does |
+|---|---|
+| `↑` `↓` | walk the rail, or the pane |
+| `←` `→` | along a row, and across between the rail and the pane |
+| `enter`, `space` | press the button the cursor is on |
+| `s` | the chosen team's card (its settings) |
+| `c` | close the chosen team |
+| `w` | open the conversations view on the chosen team |
+| `n` | new team (inside the chosen team) |
+| `o` | Organize |
+| `m` | Move into…: move the chosen team, or the picked ones, inside another team |
+| `space` | on a team's row, pick it for a move of several |
+| `p` | the members card |
+| `M` | start a manager for the chosen team |
+| `r` | reopen a closed team |
+| `d` | delete a closed team (it asks first) |
+| `u` | Undo a close or a move, while it is offered |
+| `esc` | cancel a drag or a move's question, clear the picks, then back to the message box, or home when there is none |
+
+Any letter not in that list goes back to the message box and types there.
+
+## A team's card: its settings, and where each value comes from
+
+`Settings` on the header, `Team settings…` in the team switcher on the tab strip, `e` on the
+conversations view, or `s` here opens the team's **card**:
+
+```
+╭─ Team settings ────────────────────────────────────────────╮
+│ Name orbit │
+│ Colour ◉ ● ● ● ● ● │
+│ ──────────────────────────────────────────────────────── │
+│ questions go to the manager on · from Settings │
+│ team messages wake on · from Settings │
+│ daily cap $5.00 a day · from harbor │
+│ team depth 2 levels reset │
+│ sub-team share 50% · from Settings │
+│ ──────────────────────────────────────────────────────── │
+│ Close team… Done ⏎ │
+╰────────────────────────────────────────────────────────────╯
+```
+
+The card holds only what the team **overrides**. A value the team takes from somewhere else
+is dim and says where: `· from Settings` for the defaults under **Teams** in `/settings`, or
+`· from harbor` when a team above it set it. A value the team sets itself is drawn plain with
+`reset` beside it, which gives the value back to what it inherits. `enter` on a row changes
+it (the two on and off rows flip; the others take a figure), and `r` resets the row the
+cursor is on. A cap is dollars a day (0 for none), a depth is 1 to 10 levels, a share is 1 to
+100 percent. The name is edited as you type and kept with `enter` or when the card is put
+away; `←` `→` choose a colour. `esc` or `Done` puts the card away.
+
+## Closing a team
+
+`Close…`, `c`, `Close team…` on the card, or `D` on the conversations view closes a team.
+
+- **Nothing running:** it closes at once, and `harbor is closed Undo` stays at the top of the
+ pane for a few seconds. `Undo` or `u` reopens it with its tabs.
+- **Something running:** a card says who is still working and offers **Wrap up first**,
+ **Close now** and **Cancel**. When the team has a manager, `Wrap up first` leads: the manager
+ is asked to have everyone finish and commit and to bring you a closing report, which arrives
+ as a card on this page with `Close` and `Keep going`; the team closes when you choose Close.
+ `Close now` stops every member's turn and closes their tabs at once. `Cancel` or `esc`
+ changes nothing.
+
+Closing a team closes the teams under it. A conversation that is also in another open team is
+never stopped by the close. The conversation you are looking at keeps its tab, so a close never
+moves you. A closed team spends nothing, is not on the conversations view or the strip, and
+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
+
+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:
+
+- **`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
+ sub-team cannot be open under a closed team.
+- **`Delete…`** (`d`) asks first, then forgets the team, its Traffic and its decisions. Its
+ conversations stay in your history. Only a closed team can be deleted.
+
+## Organize closes quiet teams
+
+`✦ Organize` on the rail (or on the conversations view) suggests teams for your conversations,
+and when some teams have had no activity for a week and nothing waiting, it also suggests
+**`Close 3 quiet teams`**, ticked like every other suggestion. Nothing closes until you Apply,
+and `Undo` on the Teams row for a few seconds after reopens them. Organize never closes a team
+by itself. Over `--host` this suggestion is not offered yet.
+
+## With no teams yet
+
+The page says what a team is in one sentence and offers two buttons: **`✦ Organize my
+conversations`**, which suggests teams from the conversations you have open, and
+**`+ New team`**. `o` and `n` press them.
+
+## Over --host
+
+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.
+
+## Why the page looks the way it does
+
+- **Marks appear only when something happens**, so a glance down the rail finds the one team
+ that needs you. Amber means a person is needed, and nothing else on the page is amber.
+- **The pane is the manager's own conversation**, not a copy of it, because talking to the
+ team means talking to its manager. Everything you could do in that conversation you can do
+ here.
+- **Choosing a team is the one thing that changes which conversation is in front.** Nothing
+ else on the page moves you, and resuming a member opens it behind.
+- **A team moves by choosing where it goes**, not by indenting it: the rail keeps teams in the
+ order they were made, so a move changes one team's place and nothing else's. A drag adds a
+ chat and never removes one, so a slip of the pointer cannot lose a conversation from a team.
+- **`m` is Move into…, so starting a manager is `M`.** The move is the everyday gesture; a
+ manager is started once per team.
+- **A team is deleted only once it is closed**, so the everyday gesture is a close you can undo,
+ and the one that forgets things asks first.
diff --git a/internal/manual/chat/what-i-remember.md b/internal/manual/chat/what-i-remember.md
index e28981f05e..5962cd21af 100644
--- a/internal/manual/chat/what-i-remember.md
+++ b/internal/manual/chat/what-i-remember.md
@@ -37,7 +37,7 @@ Claude Code, Codex and other skill folders still reach the conversation, carried
with a message that suits them and attached by `/skill`
(`skills-from-other-tools` says how).
-**The memory PLACE still opens with it off.** `alt+7` and `/memory` both reach it, and
+**The memory PLACE still opens with it off.** `alt+8` and `/memory` both reach it, and
what they reach is the heading `memory` and its one line, with that same sentence written
once into the rule above the composer — *What the memory place shows when there is nothing
in it* below. It used to refuse to open at all, which made the memory key on a fresh machine a key
@@ -90,7 +90,7 @@ they are gone; nothing else keeps a second copy.
## How do I see what codeaf remembers about me?
-Open `/memory`, or `/memories`, or `alt+7` from anywhere (memory is not on the tab bar). **Memory is a
+Open `/memory`, or `/memories`, or `alt+8` from anywhere (memory is not on the tab bar). **Memory is a
place**, one of seven, taking the whole screen with the tab bar above it and a composer at
the foot.
diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md
index e903ddccb9..33a0bcca7e 100644
--- a/internal/manual/chat/worker-harness.md
+++ b/internal/manual/chat/worker-harness.md
@@ -243,7 +243,7 @@ to the column's width; the glyph and the `$` are never spent on it.
**The line is there only while a step is in flight.** A task that has not started, one held
behind named work, and one that has landed all draw their ordinary row and no live line — the
store clears the step the moment its command ends. These rows are a run's **plan rows**, drawn
-in the tasks place (`/history`, `ctrl+.`, `alt+3`, and the roster raised over the frame), not
+in the tasks place (`/history`, `ctrl+.`, `alt+4`, and the roster raised over the frame), not
on the always-on column, which draws this conversation's own tree.
## What a run task's room shows while it runs
diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go
index 67c9cc39e2..66e8cec1bc 100644
--- a/internal/manual/chat_test.go
+++ b/internal/manual/chat_test.go
@@ -29,8 +29,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
page string
}{
{"what can you do", "what-i-can-do"},
+ {"why does a wrapped help line stay under its key", "keys"},
// The conversations view and its teams (conversations-and-teams.md).
{"how do I see all my conversations at once", "conversations-and-teams"},
+ {"what is the tabs dock under the message box", "conversations-and-teams"},
// The product's own words for that view are the wall: `/wall`, `alt+v`
// opens the wall. A person asks for it by that word.
{"what is the conversations wall", "conversations-and-teams"},
@@ -40,20 +42,65 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"how do I switch teams from the tab strip", "conversations-and-teams"},
{"does deleting a team close its conversations", "conversations-and-teams"},
{"where are my teams saved", "conversations-and-teams"},
+ // The teams page (teams-page.md).
+ {"how do I see all my teams and what waits on me", "teams-page"},
+ {"does renaming a team update the message box", "teams-page"},
+ {"how do I close a team", "teams-page"},
+ {"the pane said the manager was open in another window", "teams-page"},
+ {"how do I reopen a closed team", "teams-page"},
+ {"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).
+ {"how do I move a team inside another team", "teams-page"},
+ {"can I drag a team onto another team", "teams-page"},
+ {"how do I add a chat to another team from the teams page", "teams-page"},
+ {"why is a team greyed out when I move a team", "teams-page"},
+ {"how do I undo moving a team", "teams-page"},
+ {"is a team move written to traffic", "teams-page"},
+ {"what happens in traffic when I move a conversation between teams", "conversations-and-teams"},
+ {"does the manager learn when a member moves to another team", "team-manager"},
+ {"what does +4 idle mean on a team", "teams-page"},
{"how do I mention a team or another conversation with @", "conversations-and-teams"},
+ {"what does clicking a team name in a chat do", "conversations-and-teams"},
+ {"where does a team link in a chat open", "teams-page"},
// The team manager (team-manager.md).
{"what can the team manager do", "team-manager"},
{"can the manager answer a member's permission prompt", "team-manager"},
{"how does a member post to the room", "team-manager"},
{"does a directive wake an idle member", "team-manager"},
+ // Questions, packets, caps and wrapping up (team-questions-and-caps.md).
+ {"does a member's question go to the manager or to me", "team-questions-and-caps"},
+ {"what is a decision packet", "team-questions-and-caps"},
+ {"what happens when a team reaches its daily cap", "team-questions-and-caps"},
+ {"why did two windows both ask me about the team cap", "team-questions-and-caps"},
+ {"how do I wrap up a team before closing it", "team-questions-and-caps"},
+ {"what happens to a wrap-up when codeaf restarts", "team-questions-and-caps"},
+ {"does a wrap-up keep going if I quit codeaf", "team-questions-and-caps"},
+ {"what if the wrap-up report could not be sent", "team-questions-and-caps"},
+ {"can a manager direct a member of another team", "team-manager"},
+ {"can a manager start a sub-team", "team-manager"},
+ {"what is the global manager", "team-manager"},
+ {"how do two members of different teams settle a conflict", "team-manager"},
+ {"who decides a conflict between two sub-teams", "team-manager"},
{"what happens when auto-wake is off and the manager starts a member", "team-manager"},
{"why is a member still asking after it crashed", "team-manager"},
{"does it ask again if the handle model times out", "team-manager"},
{"how does a member reply to a thread", "team-manager"},
{"why is the traffic rail drawn as threads", "team-manager"},
{"open a member's chat at the message it answered from the traffic", "team-manager"},
+ // The one column (sidecol.go): the two words in a team chat, and the
+ // band of what waits on the person above them.
+ {"how do I switch between tasks and traffic in a manager chat", "team-manager"},
+ {"what does Traffic 3 new mean in the column header", "team-manager"},
+ {"who is a traffic row from and who is it to", "team-manager"},
{"what does chats on the tab bar do", "places"},
{"how do I get back to my conversation from a place", "places"},
+ // The places sit on the top line. The chat strip is only inside a chat
+ // (places.md, head.go).
+ {"where did the places go", "places"},
+ {"what does more on the top line do", "places"},
+ {"why are my chat tabs showing on the home page", "places"},
+ {"why is there no tab strip on the teams page", "places"},
{"can you use my claude code skills", "skills-from-other-tools"},
{"why is my claude code plugin skill missing", "skills-from-other-tools"},
{"do codex skills work here", "skills-from-other-tools"},
@@ -1073,6 +1120,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"why does the status line not show the cost before I type", "empty-screen"},
{"what does try what is in this folder mean", "empty-screen"},
{"where did the recent sessions list go", "empty-screen"},
+ {"why does home show a session id for an untitled chat", "home"},
+ {"the hint line stayed after I resized", "places"},
// A task's page with heavy tool use, asked the ways the screenshot
// provoked: the wheel doing nothing, the calls that are not there, and
@@ -2214,6 +2263,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"how do I cancel this card", "keeping-an-eye"},
{"I don't understand these options", "keeping-an-eye"},
{"what does just once mean", "keeping-an-eye"},
+ {"what kind of standing card is this", "keeping-an-eye"},
+ {"why did it say the reminder was never set up after I said just once", "keeping-an-eye"},
{"can I change everywhere to just this project", "standing-orders"},
// The twelfth wave: answering a question from home. Both are asked by
// somebody looking at a `▲` row and wondering whether they have to walk
@@ -2485,6 +2536,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"how do I work on the same conversation from two computers", "running-on-another-machine"},
{"what is the difference between another window and another machine", "running-on-another-machine"},
{"does home work over --host", "running-on-another-machine"},
+ {"can I change the Teams settings on another machine", "running-on-another-machine"},
+ {"where do team defaults go over a connection", "running-on-another-machine"},
+ {"can I edit team defaults over --host", "commands"},
// The wave that stopped a rebuild on the far machine from trapping
// somebody. These are the words a person actually uses at the moment it
diff --git a/internal/remote/delegation.go b/internal/remote/delegation.go
new file mode 100644
index 0000000000..f202048a3a
--- /dev/null
+++ b/internal/remote/delegation.go
@@ -0,0 +1,215 @@
+package remote
+
+import (
+ "encoding/json"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE ENGINE HALF OF THE DELEGATION DOORS, AND THE SURFACE HALF ───────────
+//
+// wire_delegation.go says what the doors are. This answers them from the
+// engine's own profile and asks them, in a file of its own for teams.go's
+// reason.
+
+// delegationCall answers the delegation methods, and says whether the method
+// was one of them at all.
+func (s *server) delegationCall(call Frame) (json.RawMessage, bool, error) {
+ switch call.Method {
+ case MethodTeamsDefaults, MethodTeamsApplyDefault, MethodTeamsPackets, MethodTeamsRaise, MethodTeamsDecide,
+ MethodTeamsEscalate, MethodTeamsSpend, MethodTeamsDelete,
+ MethodTeamsWrapUp, MethodTeamsAcceptClosing:
+ default:
+ return nil, false, nil
+ }
+ sess := s.session
+ sess.mu.Lock()
+ dir := sess.engine.ProfileDir
+ sess.mu.Unlock()
+ answer, err := delegationAnswer(dir, call)
+ if err != nil {
+ return nil, true, err
+ }
+ payload, err := json.Marshal(answer)
+ return payload, true, err
+}
+
+// delegationAnswer is one delegation method answered from dir.
+func delegationAnswer(dir string, call Frame) (any, error) {
+ switch call.Method {
+ case MethodTeamsDefaults:
+ return teamstore.DefaultsAt(dir), nil
+
+ case MethodTeamsApplyDefault:
+ args, err := arg[TeamDefaultArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ // THE SAME WRITE THE LOCAL TAB MAKES. ApplyTeamDefault is the registry
+ // row's own Apply, so a value the panel would refuse is refused here
+ // in the same words, and a value it would keep is the file the far
+ // session reads.
+ if err := config.ApplyTeamDefault(dir, args.Key, args.Raw); err != nil {
+ return nil, err
+ }
+ return teamstore.DefaultsAt(dir), nil
+
+ case MethodTeamsPackets:
+ args, err := arg[PacketsArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ // The stamp is taken before the read, for teamsRead's reason.
+ stamp := teamstore.PacketsStamp(dir)
+ if args.Stamp != "" && args.Stamp == stamp {
+ return PacketsReading{Stamp: stamp, Same: true}, nil
+ }
+ packets, _, err := teamstore.OpenPackets(dir, args.Scope)
+ if err != nil {
+ return nil, err
+ }
+ return PacketsReading{Stamp: stamp, Packets: packets}, nil
+
+ case MethodTeamsRaise:
+ args, err := arg[teamstore.Packet](call)
+ if err != nil {
+ return nil, err
+ }
+ return teamstore.Raise(dir, args)
+
+ case MethodTeamsDecide:
+ args, err := arg[DecideArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ return teamstore.Decide(dir, args.ID, args.By, args.Decision, args.Reason)
+
+ case MethodTeamsEscalate:
+ args, err := arg[EscalateArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ return teamstore.Escalate(dir, args.ID, args.By, args.To, args.Reason)
+
+ case MethodTeamsSpend:
+ args, err := arg[SpendArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ day := args.Day
+ if day == "" {
+ day = teamstore.Today()
+ }
+ stamp := teamstore.TeamSpendStamp(dir, args.Team, day)
+ if args.Stamp != "" && args.Stamp == stamp {
+ return SpendReading{Stamp: stamp, Same: true}, nil
+ }
+ spend, err := teamstore.TeamSpend(dir, args.Team, day)
+ if err != nil {
+ return nil, err
+ }
+ return SpendReading{Stamp: stamp, Spend: &spend}, nil
+
+ case MethodTeamsWrapUp:
+ args, err := arg[WrapUpArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ return struct{}{}, teamstore.AppendTraffic(dir, args.Team, teamstore.WrapUpRequest(args.Text))
+
+ case MethodTeamsAcceptClosing:
+ args, err := arg[AcceptClosingArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ p, err := teamstore.PacketByID(dir, args.ID)
+ if err != nil {
+ return nil, err
+ }
+ closed, err := teamstore.AcceptClosing(dir, p)
+ if err != nil {
+ return nil, err
+ }
+ return AcceptClosingReply{Closed: closed, Stamp: teamstore.Stamp(dir)}, nil
+
+ default: // MethodTeamsDelete
+ args, err := arg[DeleteTeamArgs](call)
+ if err != nil {
+ return nil, err
+ }
+ gone, err := teamstore.Delete(dir, args.Team)
+ if err != nil {
+ return nil, err
+ }
+ return DeleteTeamReply{Gone: gone, Stamp: teamstore.Stamp(dir)}, nil
+ }
+}
+
+// ── THE SURFACE HALF ────────────────────────────────────────────────────────
+
+// TeamsDefaults is the engine profile's `teams.` defaults.
+func (c *Client) TeamsDefaults() (teamstore.Defaults, error) {
+ return delegationAsk[teamstore.Defaults](c, MethodTeamsDefaults, struct{}{})
+}
+
+// TeamsApplyDefault writes one `teams.` row on the engine and answers the
+// five defaults as they stand after the write.
+func (c *Client) TeamsApplyDefault(key, raw string) (teamstore.Defaults, error) {
+ return delegationAsk[teamstore.Defaults](c, MethodTeamsApplyDefault, TeamDefaultArgs{Key: key, Raw: raw})
+}
+
+// TeamsPackets is the packets waiting on scope, or Same when the packet files
+// are still at stamp.
+func (c *Client) TeamsPackets(scope, stamp string) (PacketsReading, error) {
+ return delegationAsk[PacketsReading](c, MethodTeamsPackets, PacketsArgs{Scope: scope, Stamp: stamp})
+}
+
+// TeamsRaise records p on the engine and answers it as written.
+func (c *Client) TeamsRaise(p teamstore.Packet) (teamstore.Packet, error) {
+ return delegationAsk[teamstore.Packet](c, MethodTeamsRaise, p)
+}
+
+// TeamsDecide records a decision on the engine.
+func (c *Client) TeamsDecide(id, by, decision, reason string) (teamstore.Packet, error) {
+ return delegationAsk[teamstore.Packet](c, MethodTeamsDecide, DecideArgs{ID: id, By: by, Decision: decision, Reason: reason})
+}
+
+// TeamsEscalate sends a packet up on the engine.
+func (c *Client) TeamsEscalate(id, by, to, reason string) (teamstore.Packet, error) {
+ return delegationAsk[teamstore.Packet](c, MethodTeamsEscalate, EscalateArgs{ID: id, By: by, To: to, Reason: reason})
+}
+
+// TeamsSpend is team's spend on day ("" the engine's today), or Same when
+// nothing moved since stamp.
+func (c *Client) TeamsSpend(team, day, stamp string) (SpendReading, error) {
+ return delegationAsk[SpendReading](c, MethodTeamsSpend, SpendArgs{Team: team, Day: day, Stamp: stamp})
+}
+
+// TeamsDelete forgets a closed team on the engine.
+func (c *Client) TeamsDelete(team string) (DeleteTeamReply, error) {
+ return delegationAsk[DeleteTeamReply](c, MethodTeamsDelete, DeleteTeamArgs{Team: team})
+}
+
+// TeamsWrapUp asks the engine's manager of team to wrap up.
+func (c *Client) TeamsWrapUp(team, text string) error {
+ _, err := delegationAsk[struct{}](c, MethodTeamsWrapUp, WrapUpArgs{Team: team, Text: text})
+ return err
+}
+
+// TeamsAcceptClosing closes the team a decided closing packet reports on,
+// on the engine.
+func (c *Client) TeamsAcceptClosing(id string) (AcceptClosingReply, error) {
+ return delegationAsk[AcceptClosingReply](c, MethodTeamsAcceptClosing, AcceptClosingArgs{ID: id})
+}
+
+// delegationAsk is one round trip decoded as T.
+func delegationAsk[T any](c *Client, method string, args any) (T, error) {
+ var out T
+ payload, err := c.call(nil, method, args)
+ if err != nil {
+ return out, err
+ }
+ err = json.Unmarshal(payload, &out)
+ return out, err
+}
diff --git a/internal/remote/delegation_test.go b/internal/remote/delegation_test.go
new file mode 100644
index 0000000000..4593fc6aea
--- /dev/null
+++ b/internal/remote/delegation_test.go
@@ -0,0 +1,210 @@
+package remote
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// THE DELEGATION DOORS CROSS, AND THEY ANSWER FROM THE ENGINE'S PROFILE AND
+// LEDGER. The welcome says the engine has them; the defaults are the engine
+// profile's; a packet raised over the wire lands in the engine's file; a read
+// at the held stamp is a few bytes; a decision and an escalation cross; the
+// spend is the engine's ledger; and a delete is refused for an open team and
+// takes a closed one's files.
+func TestTheDelegationDoorsCrossFromTheEnginesProfile(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ loop, dir := teamsLoop(t)
+ if !loop.Client.Welcome().Delegation {
+ t.Fatal("an engine of this build does not say it has the delegation doors")
+ }
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"teams.cap_usd_day": 7}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ d, err := loop.Client.TeamsDefaults()
+ if err != nil || d.CapUSDDay != 7 || d.DepthLimit != 3 || !d.QuestionsUp {
+ t.Fatalf("the engine's defaults: %+v, %v", d, err)
+ }
+
+ session := "0123456789abcdef"
+ key := filepath.Join("/srv", session, "transcript.jsonl")
+ if err := teamstore.Save(dir, []teamstore.Team{
+ {ID: "0a0a0a0a0a0a", Name: "harbor", Manager: "hm",
+ Members: []teamstore.Member{{Key: "hm", Handle: "boss"}, {Key: key, Handle: "web"}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ p, err := loop.Client.TeamsRaise(teamstore.Packet{Team: "0a0a0a0a0a0a", Kind: teamstore.PacketJudgement,
+ RaisedBy: "web", Question: "ship today?",
+ Options: []teamstore.Option{{Label: "yes", Consequence: "it ships"}, {Label: "no", Consequence: "it waits"}}})
+ if err != nil || p.ID == "" || p.State != teamstore.PacketOpen {
+ t.Fatalf("raise: %+v, %v", p, err)
+ }
+ if on, _ := teamstore.PacketByID(dir, p.ID); on.Question != "ship today?" {
+ t.Fatal("the packet is not in the engine's file")
+ }
+ first, err := loop.Client.TeamsPackets("0a0a0a0a0a0a", "")
+ if err != nil || len(first.Packets) != 1 || first.Same {
+ t.Fatalf("packets: %+v, %v", first, err)
+ }
+ same, err := loop.Client.TeamsPackets("0a0a0a0a0a0a", first.Stamp)
+ if err != nil || !same.Same || same.Packets != nil {
+ t.Fatalf("a read at the held stamp: %+v, %v", same, err)
+ }
+ if raw, _ := json.Marshal(same); len(raw) > 64 {
+ t.Fatalf("an unchanged packets answer is %d bytes: %s", len(raw), raw)
+ }
+ up, err := loop.Client.TeamsEscalate(p.ID, "boss", teamstore.Person, "not mine to call")
+ if err != nil || up.Team != teamstore.Person {
+ t.Fatalf("escalate: %+v, %v", up, err)
+ }
+ if _, err := loop.Client.TeamsDecide(p.ID, "boss", "1", ""); err == nil ||
+ !strings.Contains(err.Error(), "only the manager") {
+ t.Fatalf("the manager it left decided over the wire: %v", err)
+ }
+ done, err := loop.Client.TeamsDecide(p.ID, teamstore.Person, "2", "tomorrow")
+ if err != nil || done.State != teamstore.PacketDecided || done.DecidedBy != teamstore.Person {
+ t.Fatalf("decide: %+v, %v", done, err)
+ }
+
+ day := teamstore.Today()
+ ledger := teamstore.UsageLedgerPath()
+ if err := os.MkdirAll(filepath.Dir(ledger), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ line := `{"at":"` + time.Now().Format(time.RFC3339Nano) + `","day":"` + day + `","usd":1.2,"calls":1,"session":"` + session + `"}` + "\n"
+ if err := os.WriteFile(ledger, []byte(line), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ spent, err := loop.Client.TeamsSpend("0a0a0a0a0a0a", "", "")
+ if err != nil || spent.Spend == nil || spent.Spend.USD != 1.2 || spent.Spend.Day != day {
+ t.Fatalf("spend: %+v, %v", spent, err)
+ }
+ quiet, err := loop.Client.TeamsSpend("0a0a0a0a0a0a", "", spent.Stamp)
+ if err != nil || !quiet.Same || quiet.Spend != nil {
+ t.Fatalf("a quiet spend: %+v, %v", quiet, err)
+ }
+
+ if _, err := loop.Client.TeamsDelete("0a0a0a0a0a0a"); err == nil {
+ t.Fatal("an open team was deleted over the wire")
+ }
+ if err := teamstore.Update(dir, func(f *teamstore.File) error {
+ return f.Close("0a0a0a0a0a0a", time.Time{}, p.ID)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ gone, err := loop.Client.TeamsDelete("0a0a0a0a0a0a")
+ if err != nil || len(gone.Gone) != 1 {
+ t.Fatalf("delete: %+v, %v", gone, err)
+ }
+ if _, err := os.Stat(teamstore.TeamDir(dir, "0a0a0a0a0a0a")); !os.IsNotExist(err) {
+ t.Fatalf("the team's files are still there: %v", err)
+ }
+}
+
+// A TEAMS SETTINGS WRITE LANDS IN THE ENGINE'S PROFILE, and a key that is not
+// one of the five defaults is refused. The value a team inherits still says
+// it came from Settings.
+func TestATeamDefaultWrittenOverTheWireLandsInTheEngineProfile(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ loop, dir := teamsLoop(t)
+ if !loop.Client.Welcome().TeamSettings {
+ t.Fatal("an engine of this build does not say it can change team defaults")
+ }
+ d, err := loop.Client.TeamsApplyDefault("teams.questions_up", "off")
+ if err != nil || d.QuestionsUp {
+ t.Fatalf("apply: %+v, %v", d, err)
+ }
+ if teamstore.DefaultsAt(dir).QuestionsUp {
+ t.Fatal("the engine profile still has questions going up")
+ }
+ if words := (&teamstore.File{}).Effective("missing", d).QuestionsUpFrom.Words(); words != "from Settings" {
+ t.Fatalf("provenance %q", words)
+ }
+ if _, err := loop.Client.TeamsApplyDefault("daily_budget", "1"); err == nil {
+ t.Fatal("a row that is not a team default was written")
+ }
+}
+
+// THE WRAP-UP'S DOORS CROSS: the request lands in the engine's Traffic as
+// exactly the marker the manager's session reads, and accepting a decided
+// closing report closes the team on the engine, once.
+func TestTheWrapUpDoorsCrossToTheEngine(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ loop, dir := teamsLoop(t)
+ if !loop.Client.Welcome().WrapUp {
+ t.Fatal("an engine of this build does not say it has the wrap-up doors")
+ }
+ if err := teamstore.Save(dir, []teamstore.Team{
+ {ID: "0a0a0a0a0a0a", Name: "harbor", Manager: "hm",
+ Members: []teamstore.Member{{Key: "hm", Handle: "boss"}, {Key: "w", Handle: "web"}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := loop.Client.TeamsWrapUp("0a0a0a0a0a0a", ""); err != nil {
+ t.Fatal(err)
+ }
+ log, err := teamstore.ReadTraffic(dir, "0a0a0a0a0a0a", "", 0)
+ if err != nil || len(log) != 1 || !teamstore.IsWrapUp(log[0]) {
+ t.Fatalf("the engine's traffic: %+v %v", log, err)
+ }
+ p, err := teamstore.Raise(dir, teamstore.Packet{Team: teamstore.Person, Origin: "0a0a0a0a0a0a", Kind: teamstore.PacketClosing,
+ RaisedBy: teamstore.FromManager, Question: "close harbor?", Report: &teamstore.ClosingReport{Done: "all of it"},
+ Options: []teamstore.Option{{ID: teamstore.OptionClose, Label: "Close", Consequence: "it closes"},
+ {ID: teamstore.OptionKeepGoing, Label: "Keep going", Consequence: "it stays"}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loop.Client.TeamsDecide(p.ID, teamstore.Person, teamstore.OptionClose, ""); err != nil {
+ t.Fatal(err)
+ }
+ reply, err := loop.Client.TeamsAcceptClosing(p.ID)
+ if err != nil || !reply.Closed {
+ t.Fatalf("accept: %+v %v", reply, err)
+ }
+ if again, err := loop.Client.TeamsAcceptClosing(p.ID); err != nil || again.Closed {
+ t.Fatalf("a second accept: %+v %v", again, err)
+ }
+ f, _ := teamstore.Load(dir)
+ if team, _ := f.Team("0a0a0a0a0a0a"); !team.Closed() {
+ t.Fatal("the engine's team did not close")
+ }
+}
+
+// A CONFLICT RAISED AND RULED OVER THE WIRE REACHES EVERY PARTY ON THE ENGINE.
+// The window's Teams.Raise and Teams.Decide are the engine's own store calls,
+// so the ruling is written as a directive to each party in its own team's log
+// on the engine, and a manager who is a party is refused there too.
+func TestAConflictRuledOverTheWireReachesEveryPartyOnTheEngine(t *testing.T) {
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ loop, dir := teamsLoop(t)
+ if err := teamstore.Save(dir, []teamstore.Team{
+ {ID: "0a0a0a0a0a0a", Name: "harbor", Manager: "hm", Members: []teamstore.Member{{Key: "hm", Handle: "boss"}}},
+ {ID: "0b0b0b0b0b0b", Name: "front", Parent: "0a0a0a0a0a0a", Members: []teamstore.Member{{Key: "w", Handle: "web"}}},
+ {ID: "0c0c0c0c0c0c", Name: "back", Parent: "0a0a0a0a0a0a", Members: []teamstore.Member{{Key: "a", Handle: "api"}}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ p, err := loop.Client.TeamsRaise(teamstore.Packet{Team: "0a0a0a0a0a0a", Origin: "0b0b0b0b0b0b", Kind: teamstore.PacketConflict,
+ RaisedBy: "web", Question: "JSON or form data?",
+ Parties: []teamstore.Party{{Key: "w", Handle: "web", Team: "0b0b0b0b0b0b"}, {Key: "a", Handle: "api", Team: "0c0c0c0c0c0c"}},
+ Options: []teamstore.Option{{Label: "JSON", Consequence: "the handler changes"}, {Label: "form data", Consequence: "the form changes"}}})
+ if err != nil {
+ t.Fatalf("raise: %v", err)
+ }
+ if _, err := loop.Client.TeamsDecide(p.ID, teamstore.Person, "1", "matches the rest"); err != nil {
+ t.Fatalf("decide: %v", err)
+ }
+ for team, handle := range map[string]string{"0b0b0b0b0b0b": "web", "0c0c0c0c0c0c": "api"} {
+ log, _ := teamstore.ReadTraffic(dir, team, "", 0)
+ ruling := log[len(log)-1]
+ if !teamstore.IsRuling(ruling) || ruling.To != handle || ruling.From != teamstore.FromYou || !strings.Contains(ruling.Text, "JSON: the handler changes") {
+ t.Fatalf("%s on the engine: %+v", team, log)
+ }
+ }
+}
diff --git a/internal/remote/server.go b/internal/remote/server.go
index 09b9815fd4..1a9ae3a2ac 100644
--- a/internal/remote/server.go
+++ b/internal/remote/server.go
@@ -1187,6 +1187,13 @@ func (sess *Session) welcomeLocked(s *server) Welcome {
// Every engine of this build answers the teams doors from its own
// profile (teams.go), so the flag is about the build, not the agent.
Teams: true,
+ // And the delegation doors beside them (delegation.go), for the same
+ // reason: the build answers them, whatever agent is open.
+ Delegation: true,
+ // And the settings tab's write of those defaults, for the same reason.
+ TeamSettings: true,
+ // And the wrap-up's two doors, for the same reason.
+ WrapUp: true,
// The two model asks are the agent's, so they are asked of it.
TeamAsk: teamAskKnown(sess.agent),
// This revision checks it in the handler, for every engine behind it
@@ -3002,6 +3009,9 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) {
if payload, handled, err := s.teamsCall(call); handled {
return payload, err
}
+ if payload, handled, err := s.delegationCall(call); handled {
+ return payload, err
+ }
if payload, handled, err := teamAskCall(agent, call); handled {
return payload, err
}
diff --git a/internal/remote/wire.go b/internal/remote/wire.go
index b49bdc2b4a..62cb74017f 100644
--- a/internal/remote/wire.go
+++ b/internal/remote/wire.go
@@ -1172,6 +1172,35 @@ type Welcome struct {
// back to the laptop's own teams file, which the far session cannot see.
Teams bool `json:"teams,omitempty"`
+ // Delegation says this engine ANSWERS THE DELEGATION DOORS
+ // ([MethodTeamsDefaults], [MethodTeamsPackets], [MethodTeamsRaise],
+ // [MethodTeamsDecide], [MethodTeamsEscalate], [MethodTeamsSpend],
+ // [MethodTeamsDelete]) from its own profile, beside the teams doors.
+ //
+ // IT IS CARRIED FOR [Welcome.Teams]' REASON, and it is a second flag
+ // because an engine can have the first without it: one built between the
+ // two answers the teams file and its Traffic and not the packets, the
+ // spend or a delete. ABSENCE IS false, and false leaves those seam doors
+ // nil, which the window reads as "not over this connection" and says so
+ // rather than reading this laptop's files.
+ Delegation bool `json:"delegation,omitempty"`
+
+ // TeamSettings says this engine ANSWERS [MethodTeamsApplyDefault]: the
+ // settings tab can change the five `teams.` defaults on this machine.
+ //
+ // IT IS A FLAG OF ITS OWN beside [Welcome.Delegation] for that flag's
+ // reason. An engine can read the defaults and still have no door that
+ // writes them. ABSENCE IS false, and false leaves the Teams tab read-only
+ // over the connection, said as such, rather than writing this laptop's file.
+ TeamSettings bool `json:"team_settings,omitempty"`
+
+ // WrapUp says this engine ANSWERS THE WRAP-UP'S TWO DOORS
+ // ([MethodTeamsWrapUp], [MethodTeamsAcceptClosing]). A third flag for
+ // [Welcome.Delegation]'s reason: an engine built between the two has the
+ // packets and not these. ABSENCE IS false, and false leaves the window
+ // with `Close now` only over that connection, said as such.
+ WrapUp bool `json:"wrap_up,omitempty"`
+
// TeamAsk says this engine ANSWERS THE WALL'S TWO MODEL ASKS
// ([MethodTeamsName], [MethodTeamsPropose]): its agent names a group of
// conversations and proposes teams on its own naming role.
diff --git a/internal/remote/wire_delegation.go b/internal/remote/wire_delegation.go
new file mode 100644
index 0000000000..8c5fe6a3ae
--- /dev/null
+++ b/internal/remote/wire_delegation.go
@@ -0,0 +1,150 @@
+package remote
+
+import (
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── DELEGATION ACROSS THE WIRE ──────────────────────────────────────────────
+//
+// The delegation store (internal/teams' decision.go, spend.go, lifecycle.go
+// and the four `teams.` defaults) lives in the engine's profile for
+// wire_teams.go's reason: the far session's team tools read and write it
+// there. So the teams page over --host asks for it through these doors,
+// answered from [Engine.ProfileDir] and the engine machine's usage ledger,
+// and never from this laptop.
+//
+// THEY ARE SHAPED FOR A CLOCK, like the teams doors. The two reads a page
+// repeats, the open packets and a team's spend, carry the stamp the window
+// last got and are answered `{"stamp":"…","same":true}` when nothing moved,
+// which the engine learns from stats (a directory listing and a stat per team
+// for the packets, two stats for the spend). The writes are single calls: a
+// packet write is an append under the engine's lock, validated there, and
+// needs no compare-and-swap because an append cannot undo another writer.
+//
+// THEY ARE ADDITIVE, and [Welcome.Delegation] says an engine has them. A
+// window facing an engine without it leaves the delegation seam doors nil and
+// says the teams page's inbox and spend are not available over that
+// connection; it never reads the laptop's packet files, which the far
+// manager never sees. Closing and reopening a team are edits to the teams
+// file and cross by [MethodTeamsUpdate] like any other; only deleting, which
+// removes the team's Traffic and packet files too, needs its own door.
+const (
+ // MethodTeamsDefaults is the engine profile's five `teams.` defaults.
+ MethodTeamsDefaults = "Teams.Defaults" // struct{} → teamstore.Defaults
+ // MethodTeamsApplyDefault writes one of those rows, the same way the
+ // settings tab writes it locally (config's ApplyTeamDefault). The answer
+ // is the five defaults after the write. [Welcome.TeamSettings] says an
+ // engine has the door; an engine without it leaves the tab read-only.
+ MethodTeamsApplyDefault = "Teams.ApplyDefault" // TeamDefaultArgs → teamstore.Defaults
+ // MethodTeamsPackets is the packets waiting on a scope, or word that the
+ // packet files have not moved since the stamp the window holds.
+ MethodTeamsPackets = "Teams.Packets" // PacketsArgs → PacketsReading
+ // MethodTeamsRaise records a new packet and answers it as written.
+ MethodTeamsRaise = "Teams.Raise" // teamstore.Packet → teamstore.Packet
+ // MethodTeamsDecide records a decision on a packet.
+ MethodTeamsDecide = "Teams.Decide" // DecideArgs → teamstore.Packet
+ // MethodTeamsEscalate sends a packet up.
+ MethodTeamsEscalate = "Teams.Escalate" // EscalateArgs → teamstore.Packet
+ // MethodTeamsSpend is one team's spend on a day, or word that neither the
+ // teams file nor the ledger moved since the stamp the window holds.
+ MethodTeamsSpend = "Teams.Spend" // SpendArgs → SpendReading
+ // MethodTeamsDelete forgets a closed team and its files.
+ MethodTeamsDelete = "Teams.Delete" // DeleteTeamArgs → DeleteTeamReply
+)
+
+// THE WRAP-UP'S TWO DOORS ([Welcome.WrapUp]). Traffic is the channel between
+// the interface and the session, and over --host the window reads it
+// ([MethodTeamsTraffic]) but has no door to write it: so the one line the
+// person's `Wrap up first` writes crosses by a door of its own, which appends
+// exactly [teamstore.WrapUpRequest] to the engine's log and nothing else; and
+// accepting a closing report, which closes the team and logs it, crosses by
+// [teamstore.AcceptClosing] on the engine. Neither is a general Traffic
+// writer: a window cannot put words in a member's mouth through them.
+const (
+ MethodTeamsWrapUp = "Teams.WrapUp" // WrapUpArgs → struct{}
+ MethodTeamsAcceptClosing = "Teams.AcceptClosing" // AcceptClosingArgs → AcceptClosingReply
+)
+
+// WrapUpArgs is the team to wrap up and the person's words, "" for the
+// standard ones.
+type WrapUpArgs struct {
+ Team string `json:"team"`
+ Text string `json:"text,omitempty"`
+}
+
+// AcceptClosingArgs names a decided closing packet.
+type AcceptClosingArgs struct {
+ ID string `json:"id"`
+}
+
+// AcceptClosingReply says whether this call closed the team, and the teams
+// file's stamp after.
+type AcceptClosingReply struct {
+ Closed bool `json:"closed"`
+ Stamp string `json:"stamp"`
+}
+
+// TeamDefaultArgs is one `teams.` row, as the settings tab would apply it:
+// Key is the registry key, Raw is what was typed (on, off, a number, or blank).
+type TeamDefaultArgs struct {
+ Key string `json:"key"`
+ Raw string `json:"raw"`
+}
+
+// PacketsArgs is a scope (a team id, teamstore.Person, or "" for every
+// waiting packet) and the stamp the window last got, "" for none.
+type PacketsArgs struct {
+ Scope string `json:"scope,omitempty"`
+ Stamp string `json:"stamp,omitempty"`
+}
+
+// PacketsReading is the waiting packets and their stamp; Same says the stamp
+// is the one asked about, and then Packets is absent.
+type PacketsReading struct {
+ Stamp string `json:"stamp"`
+ Same bool `json:"same,omitempty"`
+ Packets []teamstore.Packet `json:"packets,omitempty"`
+}
+
+// DecideArgs is a decision on one packet.
+type DecideArgs struct {
+ ID string `json:"id"`
+ By string `json:"by"`
+ Decision string `json:"decision"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// EscalateArgs sends one packet to To: a team above, or teamstore.Person.
+type EscalateArgs struct {
+ ID string `json:"id"`
+ By string `json:"by"`
+ To string `json:"to"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// SpendArgs is one team's day ("" is the engine's today) and the stamp the
+// window last got.
+type SpendArgs struct {
+ Team string `json:"team"`
+ Day string `json:"day,omitempty"`
+ Stamp string `json:"stamp,omitempty"`
+}
+
+// SpendReading is the team's spend and its stamp; Same says nothing moved and
+// then Spend is absent.
+type SpendReading struct {
+ Stamp string `json:"stamp"`
+ Same bool `json:"same,omitempty"`
+ Spend *teamstore.Spend `json:"spend,omitempty"`
+}
+
+// DeleteTeamArgs names the closed team to forget.
+type DeleteTeamArgs struct {
+ Team string `json:"team"`
+}
+
+// DeleteTeamReply is every team id forgotten and the teams file's stamp after.
+type DeleteTeamReply struct {
+ Gone []string `json:"gone"`
+ Stamp string `json:"stamp"`
+}
diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go
index 9e282e813d..6200e06fd6 100644
--- a/internal/session/actioncategory.go
+++ b/internal/session/actioncategory.go
@@ -249,14 +249,16 @@ func ActionCategoryForTool(tool string) ActionCategory {
// Saying something to a person.
case "slack_send", "gmail_send", "speak", "ask",
- // A line into a team's traffic, from its manager or one of its members.
- "team_send", "team_post":
+ // A line into a team's traffic, from its manager or one of its members,
+ // a packet sent up, and a closing report to the person.
+ "team_send", "team_post", "team_escalate", "team_close_report", "team_raise":
return ActionCommunicate
// Work handed out, or this mind copied to run beside itself.
case "propose_task", "quick_task", "divide_work", "workspace_fork", "stand",
- // A manager starting a member or ending its turn.
- "team_start", "team_stop":
+ // A manager starting a member or ending its turn, or deciding what a
+ // member asked it.
+ "team_start", "team_stop", "team_decide":
return ActionCoordinate
// Keeping the account of the work rather than doing it. `items` is a quick
diff --git a/internal/session/answers.go b/internal/session/answers.go
index 0f92a91e4c..73f80e537a 100644
--- a/internal/session/answers.go
+++ b/internal/session/answers.go
@@ -241,11 +241,11 @@ func AnswerOptions(kind QuestionKind) []AnswerOption {
{Key: "2", Label: "no", Safe: true},
}
case QuestionStanding:
- return []AnswerOption{
- {Key: "1", Label: "yes"},
- {Key: StandingOnceKey, Label: "just once"},
- {Key: StandingNoKey, Label: "not set up"},
- }
+ // THE KIND'S ROW, WITH NO ITEM TO NARROW IT. A card is built from
+ // [StandingOptions], which says the words for THAT item. This list is
+ // the repeating check, the shape a bare key still has to name, and the
+ // yes carries no cadence because there is no when to read one from.
+ return standingCheckOptions("")
case QuestionConnect:
return []AnswerOption{
{Key: "1", Label: "connect"},
@@ -397,51 +397,137 @@ const StandingOnceKey = "3"
// it in the one place there is an esc to spare.
const StandingNoKey = "0"
-// StandingOnceIsAnAnswer reports whether "once, not standing" MEANS anything
-// for one item, and it is the ONE PLACE that is decided.
+// StandingOnceIsAnAnswer reports whether "do it once, now" MEANS anything for
+// one item, and it is the ONE PLACE that is decided.
//
// "Once" means: do the action NOW, as an ordinary turn, and leave nothing
-// behind ([StandingAnswer.Once]). For a watch, a rule, a routine or overnight
-// work that is a real answer — the person wants the thing done, not the
-// arrangement. FOR A ONE-OFF REMINDER IT IS NOT AN ANSWER AT ALL: the whole
-// content of "remind me at six" is the SIX, and doing it now says "time to
-// leave" hours early or says nothing. A person met that chip after asking for a
-// one-minute timer, pressed it because it was the only answer that was not a
-// commitment, and was told the build could not hold a timer — which it can, and
-// does, and had just offered to.
-//
-// So the card does not draw it there. Everywhere else it stays.
+// behind ([StandingAnswer.Once]). A repeating check and a watch are things a
+// person may want done once. A reminder's whole content is the moment, and
+// doing it now says the thing at the wrong time. A rule never runs, so "once"
+// has nothing to do.
func StandingOnceIsAnAnswer(item standing.Item) bool {
- return !(item.When.Kind == standing.WhenAt && item.Does.Kind == standing.ActionSay)
+ switch item.CardKindOf() {
+ case standing.CardCheck, standing.CardWatch:
+ return true
+ default:
+ return false
+ }
+}
+
+// The heads a standing card opens with. The person's own sentence is the next
+// line, not this one: this line says what KIND of thing is being asked.
+const (
+ StandingHeadReminder = "wants to remind you"
+ StandingHeadCheck = "wants to set up a repeating check"
+ StandingHeadWatch = "wants to watch for something"
+ StandingHeadRule = "wants to keep a rule"
+)
+
+// StandingHead is the card's first line for this item.
+func StandingHead(item standing.Item) string {
+ switch item.CardKindOf() {
+ case standing.CardReminder:
+ return StandingHeadReminder
+ case standing.CardCheck:
+ return StandingHeadCheck
+ case standing.CardRule:
+ return StandingHeadRule
+ default:
+ return StandingHeadWatch
+ }
}
-// StandingOptions is [AnswerOptions](QuestionStanding) narrowed to ONE item:
-// the answers this particular card offers, in the order chips are drawn.
+// StandingChangeWord is the correction button. A rule's correction is about
+// where it reaches. Everything else is about the arrangement.
+func StandingChangeWord(item standing.Item) string {
+ if item.CardKindOf() == standing.CardRule {
+ return "Change where…"
+ }
+ return "Change…"
+}
+
+// StandingChangeHint is what the correction button does, in one sentence.
+func StandingChangeHint(item standing.Item) string {
+ switch item.CardKindOf() {
+ case standing.CardReminder:
+ return "Say a different time. Nothing is set up yet."
+ case standing.CardRule:
+ return "Say where it should reach. Nothing is kept yet."
+ case standing.CardWatch:
+ return "Say what to watch for instead. Nothing is set up yet."
+ default:
+ return "Say a different time or place. Nothing is set up yet."
+ }
+}
+
+// standingCadenceMark separates a yes label from the cadence it carries. A
+// narrow row drops everything from this mark on before it cuts a character.
+const standingCadenceMark = " · "
+
+// StandingPlainLabel is a label with its cadence removed. A label that carries
+// none is returned as it is.
+func StandingPlainLabel(label string) string {
+ if at := strings.Index(label, standingCadenceMark); at > 0 {
+ return label[:at]
+ }
+ const stem = "Remind me "
+ if strings.HasPrefix(label, stem) && len(label) > len(stem) {
+ return "Remind me"
+ }
+ return label
+}
+
+// StandingOptions is the answers THIS card offers, in the order they are drawn.
//
-// THE ONLY CHIP THAT IS EVER MISSING IS THE `once`. A yes and a no are answers
-// to every standing card there is — "set it up" and "set nothing up" are what
-// the question means — so [StandingNoKey] is on every row this returns, and a
-// person who learned the decline on a watch finds it under the same key on a
-// reminder.
+// THE WORDS ARE THIS ITEM'S. A reminder, a repeating check, a watch and a rule
+// are different questions, and a label that could mean two of them is how a
+// person presses the wrong one. Every surface, including another window and the
+// record of what was pressed, reads this list.
//
-// IT IS WHAT BOTH SURFACES DRAW FROM. The engine puts it on the card
-// ([StandingNotice.Options]) and into the presence file another window answers
-// through ([Agent.presenceAsking]), so the conversation's chip row, home's chip
-// row and the keys the session will actually accept are one decision made once
-// — a chip that does nothing is exactly what this file's third law forbids.
+// THE KEYS DO NOT MOVE. 1 is the yes, 3 is once, 0 is the no. Once is missing
+// on a reminder and on a rule, and it is never the answer a cursor may rest on.
+// The no is last and marked safe, so a row that has to drop an answer drops
+// one in front of it.
func StandingOptions(item standing.Item) []AnswerOption {
- options := AnswerOptions(QuestionStanding)
- if StandingOnceIsAnAnswer(item) {
- return options
- }
- kept := make([]AnswerOption, 0, len(options))
- for _, option := range options {
- if option.Key == StandingOnceKey {
- continue
+ cadence := item.When.ShortWords()
+ switch item.CardKindOf() {
+ case standing.CardReminder:
+ yes := "Remind me"
+ if cadence != "" {
+ yes = "Remind me " + cadence
+ }
+ return []AnswerOption{
+ {Key: "1", Label: yes, Consequence: "Reminds you then. Nothing repeats."},
+ {Key: StandingNoKey, Label: "Don't remind me", Consequence: "You are not reminded.", Safe: true},
+ }
+ case standing.CardWatch:
+ return []AnswerOption{
+ {Key: "1", Label: "Watch for it", Consequence: "It watches until you stop it."},
+ {Key: StandingOnceKey, Label: "Check once now", Consequence: "Checks once now. Nothing keeps watching."},
+ {Key: StandingNoKey, Label: "Don't watch", Consequence: "Nothing watches.", Safe: true},
+ }
+ case standing.CardRule:
+ return []AnswerOption{
+ {Key: "1", Label: "Keep this rule", Consequence: "The rule is kept until you stop it."},
+ {Key: StandingNoKey, Label: "Don't keep it", Consequence: "The rule is not kept.", Safe: true},
}
- kept = append(kept, option)
+ default:
+ return standingCheckOptions(cadence)
+ }
+}
+
+// standingCheckOptions is a repeating check's row. An empty cadence leaves the
+// yes as the stem, which is also what a bare key names when no item is in hand.
+func standingCheckOptions(cadence string) []AnswerOption {
+ yes := "Set it up"
+ if cadence != "" {
+ yes = "Set it up" + standingCadenceMark + cadence
+ }
+ return []AnswerOption{
+ {Key: "1", Label: yes, Consequence: "It repeats on that cadence until you stop it."},
+ {Key: StandingOnceKey, Label: "Only now, don't repeat", Consequence: "Runs the check one time now. Nothing repeats."},
+ {Key: StandingNoKey, Label: "Don't set it up", Consequence: "Nothing is set up, and nothing runs.", Safe: true},
}
- return kept
}
// AnswerLabel is the word for one key, and "" for a key that kind does not
diff --git a/internal/session/answers_test.go b/internal/session/answers_test.go
index d9a21e3311..f09575266f 100644
--- a/internal/session/answers_test.go
+++ b/internal/session/answers_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -219,7 +220,7 @@ func TestTheStandingCardTravelsInPresenceAndAnAnswerComesBack(t *testing.T) {
if question.Kind != QuestionStanding || question.ID != proposal.Standing.ID {
t.Fatalf("the question reads %+v, the card is %d", question, proposal.Standing.ID)
}
- if question.Text != "wants to keep an eye on: tell me when ci goes red" {
+ if question.Text != StandingHeadWatch {
t.Fatalf("the line is %q", question.Text)
}
// `2 change when` is not offered from another window: it is a request for a
@@ -292,6 +293,76 @@ func TestAStandingCardIsDeclinedFromAnotherWindowWithOneKey(t *testing.T) {
waitForNoQuestion(t, dir)
}
+// EACH KIND OF CARD SAYS WHAT IT IS, AND EACH BUTTON SAYS WHAT IT DOES.
+func TestStandingCardWordsFollowTheKind(t *testing.T) {
+ cases := []struct {
+ item standing.Item
+ head string
+ yes string
+ once string
+ no string
+ }{
+ {
+ item: standing.Item{When: standing.When{Kind: standing.WhenAt, Words: "at 6"}},
+ head: StandingHeadReminder, yes: "Remind me at 6", once: "", no: "Don't remind me",
+ },
+ {
+ item: standing.Item{When: standing.When{Kind: standing.WhenEvery, Words: "every 3 hours"}},
+ head: StandingHeadCheck, yes: "Set it up · every 3 hours", once: "Only now, don't repeat", no: "Don't set it up",
+ },
+ {
+ item: standing.Item{When: standing.When{Kind: standing.WhenProbe, Words: "when CI goes red"}},
+ head: StandingHeadWatch, yes: "Watch for it", once: "Check once now", no: "Don't watch",
+ },
+ {
+ item: standing.Item{When: standing.When{Kind: standing.WhenHold, Words: "always"}},
+ head: StandingHeadRule, yes: "Keep this rule", once: "", no: "Don't keep it",
+ },
+ }
+ for _, c := range cases {
+ if got := StandingHead(c.item); got != c.head {
+ t.Errorf("%s head %q, want %q", c.item.When.Kind, got, c.head)
+ }
+ var sawOnce bool
+ for _, option := range StandingOptions(c.item) {
+ switch option.Key {
+ case "1":
+ if option.Label != c.yes || option.Consequence == "" {
+ t.Errorf("%s yes %q (%q)", c.item.When.Kind, option.Label, option.Consequence)
+ }
+ if option.Safe {
+ t.Errorf("%s yes is the cursor rest", c.item.When.Kind)
+ }
+ case StandingOnceKey:
+ sawOnce = true
+ if option.Label != c.once || option.Safe {
+ t.Errorf("%s once %q safe=%v", c.item.When.Kind, option.Label, option.Safe)
+ }
+ if option.Consequence == "" {
+ t.Errorf("%s once has no hint", c.item.When.Kind)
+ }
+ case StandingNoKey:
+ if option.Label != c.no || !option.Safe || option.Consequence == "" {
+ t.Errorf("%s no %+v", c.item.When.Kind, option)
+ }
+ }
+ }
+ if sawOnce != (c.once != "") {
+ t.Errorf("%s once offered=%v want %q", c.item.When.Kind, sawOnce, c.once)
+ }
+ if plain := StandingPlainLabel(c.yes); strings.Contains(c.yes, " · ") && strings.Contains(plain, " · ") {
+ t.Errorf("%s plain label kept the cadence: %q", c.item.When.Kind, plain)
+ }
+ if c.item.When.Kind == standing.WhenAt && StandingPlainLabel(c.yes) != "Remind me" {
+ t.Errorf("reminder plain label %q", StandingPlainLabel(c.yes))
+ }
+ }
+ long := standing.When{Words: "every Monday morning after the deploy window closes and the board has signed off"}
+ if got := long.ShortWords(); len(got) > 40 || !strings.HasSuffix(got, "...") {
+ t.Fatalf("a long cadence was not shortened: %q", got)
+ }
+}
+
// THE DECLINE IS ON EVERY STANDING CARD THERE IS. The `once` chip is the only
// one that is ever missing (a one-off reminder's), because "do it now" is not a
// smaller version of "do it at six" — but "set nothing up" answers every
@@ -309,7 +380,7 @@ func TestEveryStandingCardOffersTheSameDecline(t *testing.T) {
for _, item := range []standing.Item{reminder, watch} {
var found bool
for _, option := range StandingOptions(item) {
- found = found || (option.Key == StandingNoKey && option.Label == "not set up")
+ found = found || (option.Key == StandingNoKey && option.Label != "")
}
if !found {
t.Fatalf("a %s card offers %v, with no decline on it", item.When.Kind, StandingOptions(item))
@@ -470,9 +541,9 @@ func TestTheKeysMeanWhatTheChipsSay(t *testing.T) {
// named no action at all (question.go's proposalQuestion).
{QuestionTask, "1", "start it", func(a AnswerAction) bool { return a.Task.Approved }},
{QuestionTask, "2", "no", func(a AnswerAction) bool { return !a.Task.Approved }},
- {QuestionStanding, "1", "yes", func(a AnswerAction) bool { return a.Standing.Approved && !a.Standing.Once }},
- {QuestionStanding, "3", "just once", func(a AnswerAction) bool { return a.Standing.Once && !a.Standing.Approved }},
- {QuestionStanding, "0", "not set up", func(a AnswerAction) bool {
+ {QuestionStanding, "1", "Set it up", func(a AnswerAction) bool { return a.Standing.Approved && !a.Standing.Once }},
+ {QuestionStanding, "3", "Only now, don't repeat", func(a AnswerAction) bool { return a.Standing.Once && !a.Standing.Approved }},
+ {QuestionStanding, "0", "Don't set it up", func(a AnswerAction) bool {
return a.Standing == (StandingAnswer{})
}},
} {
diff --git a/internal/session/cardanswer_test.go b/internal/session/cardanswer_test.go
new file mode 100644
index 0000000000..ad883f7bbe
--- /dev/null
+++ b/internal/session/cardanswer_test.go
@@ -0,0 +1,123 @@
+package session
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Agent-Field/agentfield/sdk/go/ai"
+)
+
+// A PERSON'S CARD ANSWER IS ON THE PAGE THE CHECKER READS, and a gap that
+// answer already chose does not carry the turn on.
+//
+// The measured failure: the person pressed `just once`, the tool said nothing
+// was set up, the digest clipped that result, and the checker raised "the
+// recurring reminder was never set up" three times. This drives [Agent.checkerPage]
+// through [Agent.checkpointReopen]. A checker that cannot see the answer line
+// raises the gap, so the test is red without the line on the page.
+func TestAPersonsCardAnswerReachesTheCheckerAndClosesTheGap(t *testing.T) {
+ const gap = "The recurring reminder was never set up"
+ var page atomic.Value
+ var done atomic.Int64
+ steps := make([]step, 20)
+ for index := range steps {
+ steps[index] = func(_ context.Context, messages []ai.Message) (*ai.Response, error) {
+ if askedForSketch(messages) {
+ return textResponse("one job, still in this conversation"), nil
+ }
+ if askedForHandoff(messages) {
+ return textResponse("a draft of what is left"), nil
+ }
+ if askedToWriteHandoff(messages) {
+ return textResponse("a brief somebody could work from"), nil
+ }
+ if askedForRemains(messages) {
+ shown := messageText(messages[len(messages)-1])
+ page.Store(shown)
+ if strings.Contains(shown, "only now, don't repeat") {
+ return textResponse(checkpointNothingLeft), nil
+ }
+ return textResponse(gap), nil
+ }
+ switch call := done.Add(1); call {
+ case 1:
+ return toolResponse("s1", "stand", aRepeatingCheck()), nil
+ case 2:
+ // The last act is a write, so the cheap turn is still read
+ // (checkpoint.go's exposure gate) without climbing a mark.
+ return toolResponse("w1", "write", `{"path":"note.txt","content":"time to leave"}`), nil
+ default:
+ return textResponse("I stopped. The reminder was never set up."), nil
+ }
+ }
+ }
+ store := newFakeStanding(t)
+ completer := &scriptedCompleter{steps: steps}
+ agent := checkpointWritingAgent(t, completer, func(config *Config) {
+ config.Standing = &Standing{}
+ config.standingItems = store
+ })
+
+ events, err := agent.Submit(watchedContext(agent), "check the marketing slack every 3 hours")
+ if err != nil {
+ t.Fatalf("Submit: %v", err)
+ }
+ collected := drainAnsweringStanding(t, events, func(event Event) {
+ if err := agent.ResolveQuestion(Answer{
+ Kind: QuestionStanding, ID: event.Standing.ID, Key: StandingOnceKey,
+ }); err != nil {
+ t.Errorf("ResolveQuestion: %v", err)
+ }
+ })
+ shown, _ := page.Load().(string)
+ if !strings.Contains(shown, `the person answered the card "`+StandingHeadCheck+`": only now, don't repeat`) {
+ t.Fatalf("the checker was not shown the card answer:\npage:\n%s\ntranscript:\n%s\nevents: %v", shown, transcriptText(agent), kinds(collected))
+ }
+ if strings.Contains(transcriptText(agent), checkpointCarryOnLead) {
+ t.Fatal("a gap the person chose carried the turn on")
+ }
+ if len(store.created) != 0 {
+ t.Fatal("a once answer created a standing item")
+ }
+ _ = collected
+}
+
+// aRepeatingCheck is the measured proposal: a check every three hours, the
+// one kind whose card offers only now.
+func aRepeatingCheck() string {
+ body := map[string]any{
+ "op": "propose",
+ "words": "check the marketing slack every 3 hours",
+ "when": map[string]any{"kind": "every", "every": "3h"},
+ "does": map[string]any{"kind": "say", "say": "check the marketing slack"},
+ "when_words": "every 3 hours",
+ "cost_words": "nothing to speak of, one line each time",
+ }
+ raw, _ := json.Marshal(body)
+ return string(raw)
+}
+
+func TestPersonCardAnswerLineKeepsOnceAndAnyOtherCard(t *testing.T) {
+ standing := Question{
+ Kind: QuestionStanding,
+ Head: "wants to keep an eye on: run the tests",
+ }
+ once := personCardAnswerLine(standing, Answer{Kind: QuestionStanding, Key: StandingOnceKey, DecidedBy: DecidedByPerson})
+ if once != `the person answered the card "wants to keep an eye on: run the tests": only now, don't repeat` {
+ t.Fatalf("once line = %q", once)
+ }
+ consent := Question{
+ Kind: QuestionConsent, Head: "may I run the tests",
+ Options: AnswerOptions(QuestionConsent),
+ }
+ allowed := personCardAnswerLine(consent, Answer{Kind: QuestionConsent, Key: "1", DecidedBy: DecidedByPerson})
+ if allowed != `the person answered the card "may I run the tests": allow once` {
+ t.Fatalf("consent line = %q", allowed)
+ }
+ if personCardAnswerLine(consent, Answer{Kind: QuestionConsent, Key: "1", DecidedBy: DecidedByDial}) != "" {
+ t.Fatal("a dial's answer was written as the person's")
+ }
+}
diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go
index 37f6d0114f..65c8e43be8 100644
--- a/internal/session/checkpoint.go
+++ b/internal/session/checkpoint.go
@@ -125,6 +125,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strconv"
"strings"
"time"
"unicode"
@@ -1884,7 +1885,7 @@ func (a *Agent) readMark(ctx context.Context) checkpointRead {
// is left, and nothing inside a tool result changes that shape.
asked := a.turnAsk()
snapshot := a.snapshot()
- page := checkpointCompletionPage(asked, snapshot)
+ page := a.checkerPage(asked, snapshot)
if page == "" {
// NOTHING TO READ IS NOT A READING. A turn with no ask, no tool call and
// nothing said has nothing for a second mind to be shown, and a call made
@@ -1963,7 +1964,7 @@ func (a *Agent) readMark(ctx context.Context) checkpointRead {
// writer can weigh beneath it (task_divide_sketch.go). The handoff page adds
// the complete ask in its own section, so carrying it here would send the
// same request twice.
- read.digest = checkpointDigest("", snapshot)
+ read.digest = withPersonCardAnswers(checkpointDigest("", snapshot), a.personCardAnswerLines())
return read
}
@@ -3799,7 +3800,7 @@ func (a *Agent) readRemains(ctx context.Context) readerLine {
}
// THE ASK IS THE ONE THIS TURN OWES, which on a woken turn is the request its
// result belongs to and not whatever was typed last (wakecause.go).
- page := checkpointCompletionPage(a.turnAsk(), a.completionSnapshot())
+ page := a.checkerPage(a.turnAsk(), a.completionSnapshot())
if page == "" {
return readerLine{}
}
@@ -3848,6 +3849,95 @@ func (a *Agent) completionSnapshot() []ai.Message {
return messages
}
+// checkerPage is the account a completion check is shown, with this turn's
+// card answers kept after the clipped digest. A person's answer is final for
+// the gap it chose, and a result that said "nothing was set up" is not.
+func (a *Agent) checkerPage(asked string, messages []ai.Message) string {
+ page := checkpointCompletionPage(asked, messages)
+ if page == "" {
+ return ""
+ }
+ return withPersonCardAnswers(page, a.personCardAnswerLines())
+}
+
+// checkpointDigestAnswered heads the card answers. It sits outside the digest
+// clip, so a long ledger cannot drop the one fact that closes a chosen gap.
+const checkpointDigestAnswered = "WHAT THE PERSON ANSWERED ON A CARD"
+
+// withPersonCardAnswers appends the lines. An empty list leaves the page
+// untouched, which is the emptiness law: a turn with no card says nothing
+// about cards.
+func withPersonCardAnswers(page string, lines []string) string {
+ if len(lines) == 0 {
+ return page
+ }
+ var out strings.Builder
+ if strings.TrimSpace(page) != "" {
+ out.WriteString(strings.TrimSpace(page))
+ out.WriteString("\n\n")
+ }
+ out.WriteString(checkpointDigestAnswered)
+ out.WriteString("\n")
+ for _, line := range lines {
+ if line = strings.TrimSpace(line); line == "" {
+ continue
+ }
+ out.WriteString(line)
+ out.WriteString("\n")
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// personCardAnswerLine is one card, compact, in the shape the checker reads.
+// A standing "just once" is spelled "only now, don't repeat" because the tool
+// result says nothing was set up, and that sentence is the gap the person
+// chose. Every other recorded answer keeps the words on the card.
+func personCardAnswerLine(q Question, answer Answer) string {
+ title := strings.TrimSpace(q.Head)
+ gloss := personCardAnswerGloss(q, answer)
+ if title == "" || gloss == "" {
+ return ""
+ }
+ return "the person answered the card " + strconv.Quote(title) + ": " + gloss
+}
+
+// personCardAnswerGloss is the half of the line after the colon.
+func personCardAnswerGloss(q Question, answer Answer) string {
+ if answer.DecidedBy != "" && answer.DecidedBy != DecidedByPerson {
+ return ""
+ }
+ if q.Kind == QuestionStanding && answer.FirstKey() == StandingOnceKey {
+ return "only now, don't repeat"
+ }
+ words := strings.TrimSpace(decisionRecordOf(q, answer).Words())
+ if change := strings.Join(strings.Fields(answer.Change), " "); change != "" {
+ if words == "" {
+ words = change
+ } else if !strings.Contains(words, change) {
+ words += "; " + change
+ }
+ }
+ return strings.Join(strings.Fields(words), " ")
+}
+
+// rememberPersonCardAnswer keeps one resolved card for the checker. A decision
+// the person did not make (a dial, an earlier record) is not their answer.
+func (a *Agent) rememberPersonCardAnswer(q Question, answer Answer) {
+ line := personCardAnswerLine(q, answer)
+ if line == "" {
+ return
+ }
+ a.mu.Lock()
+ a.personCardAnswers = append(a.personCardAnswers, line)
+ a.mu.Unlock()
+}
+
+func (a *Agent) personCardAnswerLines() []string {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return append([]string(nil), a.personCardAnswers...)
+}
+
func checkpointCompletionPage(asked string, messages []ai.Message) string {
asked = strings.TrimSpace(asked)
if len(checkpointDigestAsked)+1+len(asked) <= checkpointDigestBytes {
diff --git a/internal/session/handlepick.go b/internal/session/handlepick.go
index 504328dfdb..c34e548e14 100644
--- a/internal/session/handlepick.go
+++ b/internal/session/handlepick.go
@@ -154,7 +154,7 @@ func (a *Agent) chooseTeamHandles(ctx context.Context, title, model string) {
if err != nil {
return
}
- for _, role := range rolesFor(file.Teams, keys) {
+ for _, role := range rolesFor(file, keys, a.teamDefaults(profile)) {
if !role.derived {
continue
}
diff --git a/internal/session/loop.go b/internal/session/loop.go
index 3699704a44..5315cafe01 100644
--- a/internal/session/loop.go
+++ b/internal/session/loop.go
@@ -3752,6 +3752,11 @@ var glossFields = map[string][]string{
// here: it reads as its own sentence ([teamStartGloss]).
"team_send": {"to", "text"},
"team_post": {"to", "text"},
+ // A packet is its id and what was done with it.
+ "team_decide": {"packet", "answer"},
+ "team_escalate": {"packet", "to"},
+ "team_close_report": {"done"},
+ "team_raise": {"question"},
}
// gloss renders one call as a person-readable line: the tool name and the one
diff --git a/internal/session/question.go b/internal/session/question.go
index 073183c80c..1756028673 100644
--- a/internal/session/question.go
+++ b/internal/session/question.go
@@ -2029,6 +2029,10 @@ func (a *Agent) ResolveQuestion(answer Answer) error {
// record for the same law.
if said || (landingSaid && strings.TrimSpace(q.Head) != "") {
a.recordDecision(decisionRecordOf(q, answer))
+ // THE COMPLETION CHECK READS THIS, NOT THE TOOL RESULT. A digest clips
+ // results, and "nothing was set up" then reads as work still owed. The
+ // person's own answer is what closes that gap (checkpoint.go).
+ a.rememberPersonCardAnswer(q, answer)
}
if said {
a.rememberOverride(q, answer)
diff --git a/internal/session/session.go b/internal/session/session.go
index 510ee00204..118f0592c1 100644
--- a/internal/session/session.go
+++ b/internal/session/session.go
@@ -2652,6 +2652,11 @@ type Agent struct {
// landingOutcomes are owed landing reports returned in this turn. They are
// completion evidence, not another part of the person's ask.
landingOutcomes []string
+ // personCardAnswers are the compact lines for cards a person answered
+ // during THIS turn (checkpoint.go's [personCardAnswerLine]). Cleared when
+ // the next turn opens, with the owed asks, because a later turn is not
+ // still bound by a card this one already settled.
+ personCardAnswers []string
// turnResults are the tasks whose RESULTS ARRIVED IN THIS TURN, by id, in
// arrival order and cleared with owedAsks when a turn opens.
//
diff --git a/internal/session/standing_test.go b/internal/session/standing_test.go
index 5b628218d1..127d53bdf3 100644
--- a/internal/session/standing_test.go
+++ b/internal/session/standing_test.go
@@ -590,8 +590,19 @@ func TestStandingOnceCreatesNothing(t *testing.T) {
if len(store.created) != 0 {
t.Fatalf("a once answer created %d items", len(store.created))
}
- if output := toolOutput(t, collected, "stand"); !strings.Contains(output, "do it once, now, as an ordinary turn") {
- t.Fatalf("tool result = %q", output)
+ output := toolOutput(t, collected, "stand")
+ for _, want := range []string{
+ "Do it now as an ordinary step and report what happened.",
+ "The person chose not to repeat it.",
+ "Do not set it up again unless they ask.",
+ "Do not investigate codeaf.",
+ } {
+ if !strings.Contains(output, want) {
+ t.Errorf("tool result missing %q\n%s", want, output)
+ }
+ }
+ if strings.Contains(output, "\u2014") || strings.Contains(output, "\u2013") {
+ t.Errorf("tool result still has a dash: %q", output)
}
}
@@ -1751,15 +1762,14 @@ func TestAOneOffReminderOffersNoOnce(t *testing.T) {
}
}
-// AND EVERYWHERE ELSE IT KEEPS IT: a watch, a rule, a routine and overnight
-// work are all things a person may reasonably want done once, now.
-func TestEverythingButAOneOffReminderKeepsOnce(t *testing.T) {
+// A WATCH AND A CADENCE KEEP ONCE. A reminder has nothing to do now that is
+// different from reminding, and a rule never runs, so neither offers it.
+func TestAWatchAndACadenceKeepOnce(t *testing.T) {
for _, item := range []standing.Item{
{When: standing.When{Kind: standing.WhenProbe}, Does: standing.Action{Kind: standing.ActionSay}},
{When: standing.When{Kind: standing.WhenEvery}, Does: standing.Action{Kind: standing.ActionTask}},
{When: standing.When{Kind: standing.WhenFile}, Does: standing.Action{Kind: standing.ActionTask}},
{When: standing.When{Kind: standing.WhenIdle}, Does: standing.Action{Kind: standing.ActionTask}},
- {When: standing.When{Kind: standing.WhenAt}, Does: standing.Action{Kind: standing.ActionTask}},
} {
if !StandingOnceIsAnAnswer(item) {
t.Fatalf("%s/%s lost its `once` answer", item.When.Kind, item.Does.Kind)
diff --git a/internal/session/team.go b/internal/session/team.go
index 153297700d..da539298ae 100644
--- a/internal/session/team.go
+++ b/internal/session/team.go
@@ -86,17 +86,41 @@ type teamRole struct {
handle string
// manager says this conversation is the team's manager.
manager bool
- // managed says the team has a manager at all. A member's `team_post` is
+ // managed says the team has a manager at all, its own or, for a sub-team
+ // with none, the nearest one above it (boss). A member's `team_post` is
// offered only in a team that has one, because the manager is the one the
// room is run by (docs/design/conversations-and-teams/DESIGN.md, section 5).
managed bool
+ // boss is the managed team whose manager this membership answers to: the
+ // team itself when it has a manager, and otherwise the nearest open
+ // ancestor with one (team_nest.go's [bossOf]). A member of an unmanaged
+ // sub-team asks that manager its questions and posts to it; it is not
+ // that manager's to direct, because it is not a member of its team.
+ boss string
+ bossName string
+ // root says the team is the root, `All teams` (teams' root.go): its
+ // manager is the global manager, and its other members are the top-level
+ // teams' managers.
+ root bool
// key is the conversation key this conversation is stored under in the
// team, the one spelling of [Agent.teamKeysLocked] the file holds. An event
// this conversation writes names it ([teams.Entry.Member]).
key string
// wakes says the team's traffic starts this conversation's turn when it is
- // idle ([teams.Team.Wakes], team_wakewatch.go).
+ // idle: the team's effective `wake` ([teams.Effective], inherited from
+ // its parents and the profile's `teams.wake`; team_wakewatch.go).
wakes bool
+ // questionsUp is the effective `questions_up` of the team this
+ // conversation reports to, which is what decides where its clarifying
+ // questions go (team_questions.go).
+ questionsUp bool
+ // shared says this conversation is a member (not the manager) of this
+ // managed team but reports to a manager elsewhere (teams.File.Home): this
+ // team's manager is a LINK, who may read it and send it notes, and whose
+ // directives and stops do not reach it as its manager's. reportsTo names
+ // the team it reports to, for the sentence that says so.
+ shared bool
+ reportsTo string
// derived says handle is the word list's guess, which the title model
// chooses again once (handlepick.go).
derived bool
@@ -134,9 +158,14 @@ type teamSeat struct {
// exists ([Agent.teamKeysLocked]).
keys []string
resolved bool
- // teamsAt is the teams file as it was when roles was read from it.
- teamsAt fileStamp
- roles []teamRole
+ // teamsAt is the teams file as it was when roles was read from it, and
+ // configAt the profile's config.json, whose `teams.` rows the roles'
+ // inherited settings fall back to ([teams.DefaultsAt]); defaults is what
+ // was read from it.
+ teamsAt fileStamp
+ configAt fileStamp
+ defaults teams.Defaults
+ roles []teamRole
// cursors is the id of the last Traffic entry read, per team, and
// trafficAt is each log as it was when this conversation last caught up on
// it. cursorsRead says cursors has been loaded from the session folder.
@@ -155,6 +184,15 @@ type teamSeat struct {
// (team_wakewatch.go).
watch teamWatch
person personTurns
+ // spends is each cap pool's spend as last read, against its stamp
+ // (team_cap.go), and wraps each managed team's wrap-up in progress
+ // (team_wrapup.go).
+ spends map[string]spendMemo
+ wraps map[string]*wrapUp
+ // claims are the sub-teams this conversation's start named it to manage,
+ // found by the delivery that handed it the brief and made good after it
+ // (team_nest.go's [Agent.claimSubTeams]).
+ claims []subTeamClaim
// handleTried says this process has asked for its handle once
// (handlepick.go), so it is never asked for twice.
handleTried bool
@@ -270,7 +308,8 @@ func (a *Agent) teamRolesLocked(profile string) []teamRole {
return nil
}
now := stampOf(teams.Path(profile))
- if now == a.team.teamsAt {
+ settings := a.teamDefaultsMovedLocked(profile)
+ if now == a.team.teamsAt && !settings {
return a.team.roles
}
if !now.present {
@@ -286,30 +325,85 @@ func (a *Agent) teamRolesLocked(profile string) []teamRole {
return a.team.roles
}
a.team.teamsAt = now
- a.team.roles = rolesFor(file.Teams, keys)
+ a.team.roles = rolesFor(file, keys, a.team.defaults)
a.team.file = file
a.team.events.member.Store(eventfulRoles(a.team.roles))
return a.team.roles
}
-// rolesFor is the teams whose members include one of keys, in file order.
-func rolesFor(list []teams.Team, keys []string) []teamRole {
+// teamDefaultsMovedLocked reads the profile's `teams.` rows again when its
+// config.json has moved since the last read, and reports whether it did. A
+// config that has not moved costs one stat. The caller holds a.team.mu.
+func (a *Agent) teamDefaultsMovedLocked(profile string) bool {
+ now := stampOf(config.BudgetConfigPath(profile))
+ if now == a.team.configAt {
+ return false
+ }
+ a.team.configAt = now
+ a.team.defaults = teams.DefaultsAt(profile)
+ return true
+}
+
+// teamDefaults is the profile's `teams.` rows, read again only when they moved.
+func (a *Agent) teamDefaults(profile string) teams.Defaults {
+ a.team.mu.Lock()
+ defer a.team.mu.Unlock()
+ a.teamDefaultsMovedLocked(profile)
+ return a.team.defaults
+}
+
+// rolesFor is the open teams whose members include one of keys, in file
+// order, each with its settings resolved against d.
+//
+// A CLOSED TEAM IS NO PART AT ALL (teams' lifecycle.go): it spends nothing,
+// so a conversation whose only teams closed has no verb, no role and no
+// delivery, and takes no team turn.
+func rolesFor(file *teams.File, keys []string, d teams.Defaults) []teamRole {
+ if file == nil {
+ return nil
+ }
+ home, hasHome := teams.Report{}, false
+ for _, key := range keys {
+ if report, ok := file.Home(key); ok {
+ home, hasHome = report, true
+ break
+ }
+ }
var roles []teamRole
- for _, team := range list {
+ for _, team := range file.Teams {
+ if team.Closed() {
+ continue
+ }
member, ok := memberOf(team, keys)
if !ok {
continue
}
- roles = append(roles, teamRole{
- id: team.ID,
- name: team.Name,
- handle: member.Handle,
- manager: team.Manager != "" && team.Manager == member.Key,
- managed: team.Manager != "",
- key: member.Key,
- wakes: team.Wakes(),
- derived: member.HandleDerived(),
- })
+ effective := file.Effective(team.ID, d)
+ boss, bossName := bossOf(file, team, member.Key)
+ role := teamRole{
+ id: team.ID,
+ name: team.Name,
+ handle: member.Handle,
+ manager: team.Manager != "" && team.Manager == member.Key,
+ managed: boss != "",
+ boss: boss,
+ bossName: bossName,
+ root: team.Root,
+ key: member.Key,
+ wakes: effective.Wake,
+ questionsUp: effective.QuestionsUp,
+ derived: member.HandleDerived(),
+ }
+ if hasHome {
+ role.questionsUp = file.Effective(home.Team, d).QuestionsUp
+ }
+ if role.managed && !role.manager && hasHome && home.Team != boss {
+ role.shared = true
+ if at, ok := file.Team(home.Team); ok {
+ role.reportsTo = at.Name
+ }
+ }
+ roles = append(roles, role)
}
return roles
}
@@ -347,8 +441,22 @@ func (a *Agent) teamBoundary() string {
a.team.mu.Lock()
roles := a.teamRolesLocked(profile)
news := a.teamNewsLocked(profile, roles)
+ claims := a.team.claims
+ a.team.claims = nil
+ if len(claims) > 0 {
+ // A START THAT NAMED A TEAM FOR THIS CONVERSATION TO RUN is made good
+ // before the role is composed, so the request that carries the brief
+ // also says this conversation is that team's manager.
+ a.team.mu.Unlock()
+ a.claimSubTeams(profile, claims)
+ a.team.mu.Lock()
+ roles = a.teamRolesLocked(profile)
+ }
role := teamRoleBlock(roles, a.team.file)
a.team.mu.Unlock()
+ // A wrap-up the delivery just started is written down here, after the
+ // seat's lock, so a restart keeps the clock (team_wrapup.go).
+ a.teamWrapUpNote(profile)
a.setTeamRole(role)
a.armTeamTools(roles)
// A handle that is still the word list's guess is chosen once, beside the
@@ -367,10 +475,15 @@ func (a *Agent) armTeamTools(roles []teamRole) {
}
if manager {
arriving = append(arriving, a.managerTools()...)
+ arriving = append(arriving, a.packetTools()...)
+ arriving = append(arriving, a.wrapUpTools()...)
}
if member {
arriving = append(arriving, a.memberTools()...)
}
+ if manager || member {
+ arriving = append(arriving, a.raiseTools()...)
+ }
if len(arriving) == 0 {
return
}
@@ -414,7 +527,7 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string {
var lines []string
for _, entry := range entries {
cursor = entry.ID
- if line := teamLine(role, entry); line != "" {
+ if line := a.teamEntryLineLocked(profile, role, entry); line != "" {
lines = append(lines, line)
// WHAT THE MANAGER LAST SAID TO IT IS WHAT IT ANSWERS, until the
// manager says something else: its next reply to the manager, and
@@ -493,6 +606,29 @@ func (a *Agent) firstTeamCursor(profile string, role teamRole) string {
return cursor
}
+// teamEntryLineLocked is one entry as this conversation is told it: a packet
+// line by its packet ([teamPacketLine]), the person's wrap-up request to a
+// manager as the instruction it is (and the wrap-up's clock started,
+// team_wrapup.go), and everything else by [teamLine]. The caller holds
+// a.team.mu.
+func (a *Agent) teamEntryLineLocked(profile string, role teamRole, entry teams.Entry) string {
+ switch {
+ case entry.Kind == teams.KindPacket:
+ return teamPacketLine(profile, role, entry)
+ case entry.Kind == teams.KindStart && entry.Team != "":
+ line := teamBriefLine(role, entry)
+ if line == "" {
+ return ""
+ }
+ a.team.claims = append(a.team.claims, subTeamClaim{team: entry.Team, parent: role.id, key: role.key})
+ return subTeamBriefLine(a.team.file, entry, line)
+ case role.manager && teams.IsWrapUp(entry):
+ a.teamWrapUpBeginLocked(role, entry.At)
+ return wrapUpLine(role, entry)
+ }
+ return teamLine(role, entry)
+}
+
// teamLine is one entry as this conversation is told it, or "" for an entry
// that is not addressed to it.
//
@@ -503,6 +639,14 @@ func (a *Agent) firstTeamCursor(profile string, role teamRole) string {
// (which the interface performs) or an event (which the digest carries). The
// one event everyone is told is codeaf's, that a handle changed.
func teamLine(role teamRole, entry teams.Entry) string {
+ // A CONFLICT'S RULING reaches the party it names whoever wrote it and
+ // whatever this conversation is in the team (team_nest.go).
+ if teams.IsRuling(entry) {
+ if rulingFor(role, entry) {
+ return rulingLine(entry)
+ }
+ return ""
+ }
if entry.Kind == teams.KindStart {
return teamBriefLine(role, entry)
}
@@ -545,6 +689,13 @@ func teamLine(role teamRole, entry teams.Entry) string {
if entry.Kind == teams.KindDirective {
word = "◆ directive from manager"
}
+ // A LINK'S WORD IS AN FYI. A member shared into this team reports to
+ // another manager, and this team's manager may send it notes and
+ // nothing more (the verb refuses the rest); a directive that reached
+ // it anyway, from an older build, is information.
+ if role.shared {
+ word = fmt.Sprintf("◆ fyi from the manager of %q (you report to %q, whose word directs you)", role.name, role.reportsTo)
+ }
return word + teamAimed(entry.To, false) + number + ": " + text
}
return teamSpeaker(entry.From) + teamAimed(entry.To, false) + number + ": " + text
@@ -787,8 +938,11 @@ func (a *Agent) teamDigest(profile string) string {
if !ok {
continue
}
+ team = managedView(file, team)
log, _ := teams.ReadTraffic(profile, role.id, "", teamStateLook)
- parts = append(parts, teams.Digest(team, memberStates(team, keys, now, log, &a.team.journals), recentOf(log), teamDigestBudget))
+ states := memberStates(team, keys, now, log, &a.team.journals)
+ markShared(file, team, states)
+ parts = append(parts, teams.Digest(team, states, recentOf(log), teamDigestBudget))
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
@@ -1091,10 +1245,14 @@ const teamRoleWithdrawn = "You are no longer the manager or a member of any team
// teamManagerLaws is how a manager works, stated once in the role. The verbs'
// own descriptions carry how each is called.
-const teamManagerLaws = "Three laws:\n" +
+const teamManagerLaws = "Seven laws:\n" +
"1. Hand real work to members with team_send, or team_start for a new member, rather than doing it yourself, and keep track with team_status and team_read. Asked what is happening, answer from the team.\n" +
"2. The person outranks you: what they say in a member's own conversation stands over your directive, and a conflict goes to them.\n" +
- "3. You cannot answer a member's permission prompt; tell the person it is waiting."
+ "3. You cannot answer a member's permission prompt; tell the person it is waiting.\n" +
+ "4. You direct only the members who report to you, one level down: a team under yours is its manager's to run, so you direct that manager, never its members. A member team_status marks `reports to` another team is shared: read it and send it notes, never a directive or a stop.\n" +
+ "5. Members' questions come to you as decision packets: answer with team_decide, or send one up with team_escalate when it is not yours to decide. Your own questions go up the same way.\n" +
+ "6. A team's daily cap is the person's: you cannot raise it, and at the cap nothing new starts until they answer.\n" +
+ "7. A conflict between conversations is declared with team_raise and goes to the lowest manager above every party; when it waits on you, your team_decide reaches every party as a directive."
// teamRosterMax is how many members the role names before it sends the model
// to team_status for the rest, and teamRosterWord how much of each member's
@@ -1129,12 +1287,59 @@ func teamManagerRole(role teamRole, file *teams.File) string {
fmt.Fprintf(&b, "You are the manager of the team %q. The person talks to you and you run the team for them: you decide who does what, hand the work out, and tell the person where it stands.\n", role.name)
b.WriteString(teamRoster(role, file))
b.WriteString("\n")
+ if scope := teamManagerScope(role, file); scope != "" {
+ b.WriteString(scope)
+ b.WriteString("\n")
+ }
b.WriteString(teamManagerDelivery(role))
b.WriteString("\n")
b.WriteString(teamManagerLaws)
return b.String()
}
+// teamManagerScope is where the manager's team sits in the tree, "" for a
+// team alone at the top with nothing under it: whom it reports to (a
+// sub-team's manager reports one level up; a top-level team's to the global
+// manager when there is one), what runs under it, and, for the global manager,
+// what its members are.
+func teamManagerScope(role teamRole, file *teams.File) string {
+ if file == nil {
+ return ""
+ }
+ var parts []string
+ if role.root {
+ parts = append(parts, fmt.Sprintf("You are the global manager: %q holds every team, and your members are the managers of the top-level teams, never their members. "+
+ "Direct those managers and they pass it on. Their questions and conflicts between teams come to you before the person. team_start with kind team starts a new top-level team.", role.name))
+ } else if home, ok := file.Home(role.key); ok && home.Team != role.id {
+ boss := "its manager"
+ if t, ok := file.Team(home.Team); ok {
+ if m, ok := t.Member(t.Manager); ok && m.Handle != "" {
+ boss = "its manager, @" + m.Handle
+ }
+ what := "a team under"
+ if t.Root {
+ what = "a top-level team, under the global manager of"
+ }
+ parts = append(parts, fmt.Sprintf("Your team is %s %q: you report to %s. Post to it with team_post; your questions go to it before the person.", what, t.Name, boss))
+ }
+ }
+ var under []string
+ for _, child := range file.Children(role.id) {
+ if child.Closed() || role.root {
+ continue
+ }
+ who := "no manager yet"
+ if m, ok := child.Member(child.Manager); ok && m.Handle != "" {
+ who = "run by @" + m.Handle
+ }
+ under = append(under, fmt.Sprintf("%q (%s)", child.Name, who))
+ }
+ if len(under) > 0 {
+ parts = append(parts, "Teams under yours: "+strings.Join(under, ", ")+". Direct their managers, never their members.")
+ }
+ return strings.Join(parts, " ")
+}
+
// teamManagerDelivery is what happens to what the manager sends and what comes
// back, said as it is on this team: with the auto-wake on, a directive starts
// an idle member and replies start the manager (team_wakewatch.go); with it
@@ -1156,6 +1361,7 @@ func teamRoster(role teamRole, file *teams.File) string {
if !ok {
return "team_status lists its members."
}
+ team = managedView(file, team)
var named []string
for _, member := range team.Members {
if member.Key == role.key {
@@ -1186,9 +1392,29 @@ func teamMemberRole(role teamRole) string {
if role.handle != "" {
you = "@" + role.handle
}
- return fmt.Sprintf("You are %s in the team %q, which has a manager. "+
+ if role.root {
+ return fmt.Sprintf("You are %s in %q because you manage a top-level team: you report to its manager, the global manager. Its directives reach you marked \"◆ from manager\", "+
+ "your questions go to it before the person, and you report to it with team_post to the manager.", you, role.name)
+ }
+ if role.boss != "" && role.boss != role.id && !role.shared {
+ said := fmt.Sprintf("You are %s in the team %q, which has no manager of its own, so you answer to the manager of %q: team_post to the manager reaches it, and to the room or a teammate stays in %q.",
+ you, role.name, role.bossName, role.name)
+ if role.questionsUp {
+ said += " Your clarifying questions (ask) go to that manager first, and its answer comes back marked \"◆ answered\"; permission prompts still go to the person."
+ }
+ return said
+ }
+ if role.shared {
+ return fmt.Sprintf("You are %s in the team %q too, but you report to the manager of %q: that manager's word directs you, and this team's manager may only send you notes (fyi). "+
+ "Post to this team with team_post.", you, role.name, role.reportsTo)
+ }
+ said := fmt.Sprintf("You are %s in the team %q, which has a manager. "+
"Lines from the manager arrive marked \"◆ from manager\" and from teammates \"from @handle\"; none of them is the person, whose own words outrank the manager's. "+
- "Report progress, findings and blockers with team_post, to the manager, a teammate or the room.", you, role.name)
+ "Report progress, findings and blockers with team_post, to the manager, a teammate or the room. A conflict you cannot settle with another member or team goes up with team_raise.", you, role.name)
+ if role.questionsUp {
+ said += " Your clarifying questions (ask) go to your manager first, and its answer comes back marked \"◆ answered\"; permission prompts still go to the person."
+ }
+ return said
}
// setTeamRole leaves the role where the next landing of the session's notes
diff --git a/internal/session/team_cap.go b/internal/session/team_cap.go
new file mode 100644
index 0000000000..efd144211e
--- /dev/null
+++ b/internal/session/team_cap.go
@@ -0,0 +1,209 @@
+package session
+
+// ── A TEAM'S DAILY CAP IS HELD, NEVER CUT MID-TURN ──────────────────────────
+//
+// A team may have a daily cap (teams' teamsettings.go, `cap_usd_day`, which
+// inherits), and a cap is a POOL: its owner's spend counts every team under
+// it, so an inherited cap is the ancestor's one pool and never a second
+// allowance ([teams.Effective]'s CapFrom). This file is what enforces it, on
+// the session's side, and what it enforces is deliberately small:
+//
+// - AT OR OVER THE CAP, WHAT THE TEAM WOULD START IS HELD. A wake (a
+// directive to a member, replies to a manager, an answer to a question), a
+// new member's first turn on its brief, and `team_start` are refused while
+// the pool is at its cap. A turn already running is never cut: it finishes
+// and nothing new starts behind it. The lines that were held are not
+// lost; they are delivered at the conversation's next turn.
+// - THE PERSON'S OWN WORDS ARE NOT HELD. A person typing into a member's
+// conversation spends their own money in front of them, which is not what
+// a cap on unattended work is for.
+// - ONE CAP PACKET PER POOL AND CEILING goes to the person ([teams.Person]):
+// `harbor reached its $5 cap today`, with `Raise to $10` and `Stop for
+// today`, and a recommendation. Whoever meets the cap first raises it;
+// everyone after finds it waiting and raises nothing, including a second
+// process: [teams.Raise] keeps one packet per pool, day and ceiling under
+// the decisions file's lock. A raise lifts the
+// ceiling for the rest of that local day to the figure the option said
+// ([teams.CapFacts].RaiseTo); a stop, or an answer in the person's own
+// words, holds the pool until the day turns or the cap is changed.
+// - A MANAGER NEVER RAISES A CAP. The packet waits on the person, and
+// `team_decide` refuses a cap packet in words.
+//
+// IT IS NEVER A HOT-PATH LEDGER SCAN. The check runs only where the team would
+// start something (a wake, a start), never per model request, and the spend
+// it reads is kept against [teams.TeamSpendStamp] (a stat of the teams file
+// and one of the ledger): a pool whose stamp has not moved is answered from
+// memory, and one that moved is read by teams' own incremental fold.
+
+import (
+ "fmt"
+ "math"
+ "sync"
+
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// teamSpendOf and teamSpendStamp are the pool's spend and its stamp; vars so a
+// test can count how often the spend is really read.
+var (
+ teamSpendOf = teams.TeamSpend
+ teamSpendStamp = teams.TeamSpendStamp
+ teamToday = teams.Today
+)
+
+// capRaising serializes this process's cap raises, so two conversations in
+// one pool do not both call [teams.Raise]. A second process is covered by the
+// decisions file lock, not by this mutex.
+var capRaising sync.Mutex
+
+// spendMemo is one pool's spend as last read, against its stamp.
+type spendMemo struct {
+ stamp string
+ usd float64
+}
+
+// teamPoolSpend is the pool owner's spend today, read again only when its
+// stamp moved.
+func (a *Agent) teamPoolSpend(profile, owner, day string) float64 {
+ stamp := teamSpendStamp(profile, owner, day)
+ a.team.mu.Lock()
+ if held, ok := a.team.spends[owner]; ok && held.stamp == stamp {
+ a.team.mu.Unlock()
+ return held.usd
+ }
+ a.team.mu.Unlock()
+ spend, err := teamSpendOf(profile, owner, day)
+ if err != nil {
+ return 0
+ }
+ a.team.mu.Lock()
+ if a.team.spends == nil {
+ a.team.spends = map[string]spendMemo{}
+ }
+ a.team.spends[owner] = spendMemo{stamp: stamp, usd: spend.USD}
+ a.team.mu.Unlock()
+ return spend.USD
+}
+
+// capPool is the pool team id draws on: its owner and the cap, 0 for none.
+// The owner is the team whose override the cap is, or, for the profile's
+// default, the top of the chain (the root when there is one).
+func capPool(file *teams.File, id string, d teams.Defaults) (teams.Team, float64) {
+ if file == nil {
+ return teams.Team{}, 0
+ }
+ e := file.Effective(id, d)
+ if e.CapUSDDay <= 0 {
+ return teams.Team{}, 0
+ }
+ owner := e.CapFrom.Team
+ if e.CapFrom.Kind == teams.OriginSettings {
+ owner = id
+ for _, up := range file.Ancestors(id) {
+ if !up.Closed() {
+ owner = up.ID
+ }
+ }
+ }
+ team, ok := file.Team(owner)
+ if !ok {
+ return teams.Team{}, 0
+ }
+ return team, e.CapUSDDay
+}
+
+// teamCapHold is why new team work may not start in any of roles' pools right
+// now, "" when it may. Meeting a cap for the first time raises its packet.
+func (a *Agent) teamCapHold(profile string, roles []teamRole) string {
+ if profile == "" || len(roles) == 0 {
+ return ""
+ }
+ a.team.mu.Lock()
+ file, d := a.team.file, a.team.defaults
+ a.team.mu.Unlock()
+ seen := map[string]bool{}
+ for _, role := range roles {
+ if !role.managed {
+ continue
+ }
+ owner, cap := capPool(file, role.id, d)
+ if cap <= 0 || seen[owner.ID] {
+ continue
+ }
+ seen[owner.ID] = true
+ if reason := a.poolHold(profile, owner, cap); reason != "" {
+ return reason
+ }
+ }
+ return ""
+}
+
+// poolHold is why owner's pool is held, "" when it is not.
+func (a *Agent) poolHold(profile string, owner teams.Team, cap float64) string {
+ day := teamToday()
+ spent := a.teamPoolSpend(profile, owner.ID, day)
+ latest, found := latestCapPacket(profile, owner.ID, day)
+ ceiling := cap
+ if found && latest.State == teams.PacketDecided && latest.Decision == teams.OptionRaiseCap && latest.Cap.RaiseTo > ceiling {
+ ceiling = latest.Cap.RaiseTo
+ }
+ if spent < ceiling {
+ 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))
+ 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))
+ }
+ return held
+ }
+ capRaising.Lock()
+ defer capRaising.Unlock()
+ if again, ok := latestCapPacket(profile, owner.ID, day); ok && (again.Waiting() || again.Cap.CapUSD >= ceiling) {
+ return held
+ }
+ _, _ = teams.Raise(profile, capPacket(owner, day, ceiling, spent))
+ return held
+}
+
+// latestCapPacket is the last cap packet raised for owner's pool on day.
+func latestCapPacket(profile, owner, day string) (teams.Packet, bool) {
+ list, err := teams.Packets(profile, owner)
+ if err != nil {
+ return teams.Packet{}, false
+ }
+ for i := len(list) - 1; i >= 0; i-- {
+ p := list[i]
+ if p.Kind == teams.PacketCap && p.Cap != nil && p.Cap.Team == owner && p.Cap.Day == day {
+ return p, true
+ }
+ }
+ return teams.Packet{}, false
+}
+
+// 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
+ 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)),
+ Options: []teams.Option{
+ {ID: teams.OptionRaiseCap, Label: "Raise to " + teamMoney(raiseTo),
+ Consequence: fmt.Sprintf("%s and its sub-teams go on until %s today", owner.Name, teamMoney(raiseTo))},
+ {ID: teams.OptionStopToday, Label: "Stop for today",
+ Consequence: "members finish their current turn and start no new one until tomorrow"},
+ },
+ 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},
+ }
+}
+
+// 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)
+}
diff --git a/internal/session/team_delegation_test.go b/internal/session/team_delegation_test.go
new file mode 100644
index 0000000000..999e15d438
--- /dev/null
+++ b/internal/session/team_delegation_test.go
@@ -0,0 +1,586 @@
+package session
+
+// DELEGATION, AS TESTS: questions go up as packets and answers come back
+// down, links read and fyi but do not direct, a cap holds new work and asks
+// the person once, and a wrap-up ends in a closing report that closes the
+// team only when the person accepts it. Every fixture is a real teams.json,
+// a real Traffic log and real packet files, through internal/teams.
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/filelock"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// clarifying is an `ask` a member writes when it needs an answer to go on.
+const clarifying = `{"head":"JSON or form data?","kind":"clarification","reason":"the handler and the form disagree","stakes":"reversible",` +
+ `"options":[{"key":"1","label":"JSON","consequence":"the handler changes"},{"key":"2","label":"form data","consequence":"the form changes"}],` +
+ `"pick":{"key":"1","reason":"the other endpoints take JSON"}}`
+
+func callTool(t *testing.T, run func(context.Context, json.RawMessage) (string, bool, error), args string) (string, bool) {
+ t.Helper()
+ text, failed, err := run(context.Background(), json.RawMessage(args))
+ if err != nil {
+ t.Fatalf("the tool returned an error: %v", err)
+ }
+ return text, failed
+}
+
+func onlyPacket(t *testing.T, profile, scope string) teams.Packet {
+ t.Helper()
+ waiting, _, err := teams.OpenPackets(profile, scope)
+ if err != nil || len(waiting) != 1 {
+ t.Fatalf("packets waiting on %q: %+v %v", scope, waiting, err)
+ }
+ return waiting[0]
+}
+
+// A MEMBER'S CLARIFYING QUESTION GOES TO ITS MANAGER AS A PACKET, the manager
+// is handed it whole and answers it, and the member is handed the answer
+// marked as answered. Nothing is put in front of the person.
+func TestTeamQuestionGoesUpAsAPacketAndTheAnswerComesBack(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ web := teamAgent(t, fixture, fixture.web, nil, nil)
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ web.teamBoundary()
+
+ said, failed := callTool(t, web.executeAsk, clarifying)
+ if failed || !strings.Contains(said, "went to your manager (@boss") || !strings.Contains(said, "not to the person") {
+ t.Fatalf("the member was told %q", said)
+ }
+ p := onlyPacket(t, fixture.profile, fixture.teamID)
+ if p.Kind != teams.PacketQuestion || p.RaisedBy != "web" || p.Question != "JSON or form data?" || len(p.Options) != 2 ||
+ p.Recommendation == nil || p.Recommendation.Option != "1" || len(p.Parties) != 1 || p.Parties[0].Context != "the handler and the form disagree" {
+ t.Fatalf("the packet is not self-contained: %+v", p)
+ }
+ if mine, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(mine) != 0 {
+ t.Fatal("a member's question reached the person")
+ }
+
+ news := manager.teamBoundary()
+ for _, want := range []string{"◆ question " + p.ID + " from @web, waiting on you: JSON or form data?", "[1] JSON: the handler changes", "recommended: 1", "team_decide"} {
+ if !strings.Contains(news, want) {
+ t.Errorf("the manager's delivery lacks %q:\n%s", want, news)
+ }
+ }
+ answer, failed := callTool(t, manager.teamDecideTool, `{"packet":"`+p.ID+`","answer":"json","reason":"matches the rest"}`)
+ if failed || !strings.Contains(answer, "Decided "+p.ID) {
+ t.Fatalf("team_decide said %q", answer)
+ }
+ got := web.teamBoundary()
+ if !strings.Contains(got, "◆ answered: JSON (by ◆ @boss, because matches the rest). Your question was: JSON or form data?") {
+ t.Fatalf("the member was not handed the answer:\n%s", got)
+ }
+ log, _ := teams.ReadTraffic(fixture.profile, fixture.teamID, "", 0)
+ if last := log[len(log)-1]; last.Text != "answered @web: JSON" {
+ t.Fatalf("the rail's line: %+v", last)
+ }
+ // The member's answer and the manager's packet both wake, on a team that
+ // wakes.
+ role := teamRole{id: fixture.teamID, handle: "web", managed: true, key: convKeyOf(t, fixture.web)}
+ if !teamPacketWakes(fixture.profile, role, last(log)) {
+ t.Error("the answer does not wake the member it answers")
+ }
+}
+
+func last(log []teams.Entry) teams.Entry { return log[len(log)-1] }
+
+// PERMISSION PROMPTS AND QUESTIONS WITH questions_up OFF ARE THE PERSON'S.
+func TestTeamQuestionsUpOffAndPermissionsStayThePersons(t *testing.T) {
+ if askGoesUp(AskPermission, false) || askGoesUp(AskPermission, true) || askGoesUp(AskLanding, true) {
+ t.Fatal("a permission or a landing goes up")
+ }
+ fixture := newTeamFixture(t, true)
+ web := teamAgent(t, fixture, fixture.web, nil, nil)
+ if _, up := web.askUp(Question{Ask: AskPermission, Head: "run bash?"}); up {
+ t.Fatal("a permission kind went up")
+ }
+ off := false
+ if err := teams.Update(fixture.profile, func(f *teams.File) error {
+ return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.QuestionsUp = &off })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, up := web.askUp(Question{Ask: AskClarification, Head: "tabs?"}); up {
+ t.Fatal("a question went up with questions_up off")
+ }
+}
+
+// A MANAGER'S OWN QUESTION IS A PACKET TO THE PERSON when it has no manager
+// above it, and the person's answer is handed to it marked as answered.
+func TestTeamAManagersOwnQuestionGoesToThePersonsInbox(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ said, _ := callTool(t, manager.executeAsk, clarifying)
+ if !strings.Contains(said, "the person's inbox") {
+ t.Fatalf("the manager was told %q", said)
+ }
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Origin != fixture.teamID || p.RaisedBy != teams.FromManager {
+ t.Fatalf("the manager's packet: %+v", p)
+ }
+ if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, "2", ""); err != nil {
+ t.Fatal(err)
+ }
+ if news := manager.teamBoundary(); !strings.Contains(news, "◆ answered: form data (by the person)") {
+ t.Fatalf("the manager was not handed the person's answer:\n%s", news)
+ }
+}
+
+// linkedFixture is harbor (boss; web, parser) and dock (dockboss; web): web
+// joined harbor first, so harbor is its home and dock's manager is a link.
+func linkedFixture(t *testing.T) (teamFixture, string, string) {
+ t.Helper()
+ fixture := newTeamFixture(t, true)
+ dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.manager)), "dockboss")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ dockboss := filepath.Join(dir, placeTranscript)
+ if err := os.WriteFile(dockboss, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ dock := teams.NewID()
+ err := teams.Update(fixture.profile, func(f *teams.File) error {
+ f.Teams = append(f.Teams, teams.Team{ID: dock, Name: "dock"})
+ for _, m := range []teams.Member{
+ {Key: convKeyOf(t, dockboss), File: dockboss, Word: "dock manager", Handle: "dockboss"},
+ {Key: convKeyOf(t, fixture.web), File: fixture.web, Word: "web frontend", Handle: "web"},
+ } {
+ if err := f.AddMember(dock, m); err != nil {
+ return err
+ }
+ }
+ return f.SetManager(dock, convKeyOf(t, dockboss))
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fixture, dock, dockboss
+}
+
+// A LINK READS AND SENDS NOTES, AND IS REFUSED A DIRECTIVE OR A STOP in a
+// sentence that says whose the member is; its status marks the member shared.
+func TestTeamALinkMayNoteButNotDirectOrStop(t *testing.T) {
+ fixture, dock, dockboss := linkedFixture(t)
+ link := teamAgent(t, fixture, dockboss, nil, nil)
+ link.teamBoundary()
+ said, failed := callTool(t, link.teamSendTool, `{"to":"web","text":"do the header","kind":"directive"}`)
+ if !failed || !strings.Contains(said, `@web reports to the manager of "harbor", not to you`) {
+ t.Fatalf("a link's directive: %q", said)
+ }
+ said, failed = callTool(t, link.teamStopTool, `{"handle":"web"}`)
+ if !failed || !strings.Contains(said, "not a stop") {
+ t.Fatalf("a link's stop: %q", said)
+ }
+ said, failed = callTool(t, link.teamSendTool, `{"to":"everyone","text":"do the header","kind":"directive"}`)
+ if !failed || !strings.Contains(said, "reports to another team's manager") {
+ t.Fatalf("a link's directive to everyone: %q", said)
+ }
+ if said, failed = callTool(t, link.teamSendTool, `{"to":"web","text":"fyi the api moved"}`); failed {
+ t.Fatalf("a link's note was refused: %q", said)
+ }
+ status, _ := callTool(t, link.teamStatusTool, `{}`)
+ if !strings.Contains(status, "reports to harbor") {
+ t.Fatalf("status does not mark the shared member:\n%s", status)
+ }
+ log, _ := teams.ReadTraffic(fixture.profile, dock, "", 0)
+ for _, entry := range log {
+ if entry.Kind == teams.KindDirective || entry.Kind == teams.KindStop {
+ t.Fatalf("a refused verb wrote %+v", entry)
+ }
+ }
+ // And the member reads a link's line as an fyi, and is not woken by one.
+ web := teamAgent(t, fixture, fixture.web, nil, nil)
+ roles := web.teamRoles()
+ var shared teamRole
+ for _, role := range roles {
+ if role.id == dock {
+ shared = role
+ }
+ }
+ if !shared.shared || shared.reportsTo != "harbor" {
+ t.Fatalf("web's role in dock: %+v", shared)
+ }
+ directive := teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "web", Text: "do it"}
+ if teamWakes(shared, directive) || !strings.Contains(teamLine(shared, directive), "fyi from the manager of \"dock\"") {
+ t.Fatal("a link's directive reads or wakes as the home manager's")
+ }
+ // Its home manager still directs it.
+ boss := teamAgent(t, fixture, fixture.manager, nil, nil)
+ boss.teamBoundary()
+ if said, failed := callTool(t, boss.teamSendTool, `{"to":"web","text":"do the header","kind":"directive"}`); failed {
+ t.Fatalf("the home manager's directive was refused: %q", said)
+ }
+}
+
+// capSpend stands in for the ledger: a fixed spend under a stamp the test
+// moves, counting every real read.
+type capSpend struct {
+ usd float64
+ stamp string
+ reads int
+}
+
+func stubCapSpend(t *testing.T, spend *capSpend) {
+ t.Helper()
+ oldOf, oldStamp, oldDay := teamSpendOf, teamSpendStamp, teamToday
+ teamSpendOf = func(profile, team, day string) (teams.Spend, error) {
+ spend.reads++
+ return teams.Spend{Team: team, Day: day, USD: spend.usd}, nil
+ }
+ teamSpendStamp = func(profile, team, day string) string { return spend.stamp + "|" + team }
+ teamToday = func() string { return "2026-09-24" }
+ t.Cleanup(func() { teamSpendOf, teamSpendStamp, teamToday = oldOf, oldStamp, oldDay })
+}
+
+// A POOL AT ITS CAP HOLDS NEW WORK AND ASKS THE PERSON ONCE. The spend is read
+// once per stamp, never per check; team_start is refused; a raise lifts the
+// ceiling for the day; meeting the raised ceiling asks again, once; a stop
+// holds; and a manager cannot decide a cap.
+func TestTeamACapHoldsNewWorkAndAsksThePersonOnce(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ five := 5.0
+ if err := teams.Update(fixture.profile, func(f *teams.File) error {
+ return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.CapUSDDay = &five })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ spend := &capSpend{usd: 6, stamp: "s1"}
+ stubCapSpend(t, spend)
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ roles := manager.teamRoles()
+
+ held := manager.teamCapHold(fixture.profile, roles)
+ if !strings.Contains(held, "harbor reached its $5 cap today") {
+ t.Fatalf("held for %q", held)
+ }
+ for i := 0; i < 5; i++ {
+ manager.teamCapHold(fixture.profile, roles)
+ }
+ if spend.reads != 1 {
+ t.Fatalf("the spend was read %d times under one stamp", spend.reads)
+ }
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Kind != teams.PacketCap || p.Cap == nil || p.Cap.RaiseTo != 10 || p.Options[0].Label != "Raise to $10" ||
+ p.Options[1].ID != teams.OptionStopToday || p.Recommendation == nil {
+ t.Fatalf("the cap packet: %+v", p)
+ }
+ said, failed := callTool(t, manager.teamStartTool, `{"handle":"docs","brief":"write the README"}`)
+ if !failed || !strings.Contains(said, "No new member starts") {
+ t.Fatalf("team_start at the cap: %q", said)
+ }
+ if said, failed := callTool(t, manager.teamDecideTool, `{"packet":"`+p.ID+`","answer":"raise"}`); !failed || !strings.Contains(said, "waits on the person") {
+ t.Fatalf("a manager decided a cap: %q", said)
+ }
+ if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, teams.OptionRaiseCap, ""); err != nil {
+ t.Fatal(err)
+ }
+ if held := manager.teamCapHold(fixture.profile, roles); held != "" {
+ t.Fatalf("a raised cap still holds: %q", held)
+ }
+ spend.usd, spend.stamp = 11, "s2"
+ if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$10 cap") {
+ t.Fatalf("the raised ceiling did not hold: %q", held)
+ }
+ second := onlyPacket(t, fixture.profile, teams.Person)
+ if second.ID == p.ID || second.Cap.CapUSD != 10 || second.Cap.RaiseTo != 20 {
+ t.Fatalf("the second cap packet: %+v", second)
+ }
+ if _, err := teams.Decide(fixture.profile, second.ID, teams.Person, teams.OptionStopToday, ""); err != nil {
+ t.Fatal(err)
+ }
+ if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "chose to stop it for today") {
+ t.Fatalf("a stop did not hold: %q", held)
+ }
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 0 {
+ t.Fatalf("a stop raised another packet: %+v", waiting)
+ }
+}
+
+// NO CAP, NO READ: a team with no cap never reads the spend at all.
+func TestTeamNoCapReadsNoSpend(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ spend := &capSpend{usd: 100, stamp: "s1"}
+ stubCapSpend(t, spend)
+ web := teamAgent(t, fixture, fixture.web, nil, nil)
+ web.teamBoundary()
+ if held := web.teamCapHold(fixture.profile, web.teamRoles()); held != "" || spend.reads != 0 {
+ t.Fatalf("no cap: held %q after %d reads", held, spend.reads)
+ }
+}
+
+// WRAP UP FIRST ENDS IN A CLOSING REPORT, and the team closes when the person
+// accepts it.
+func TestTeamWrapUpEndsInAClosingReportThatClosesOnAccept(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ stubCapSpend(t, &capSpend{usd: 1.25, stamp: "s1"})
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ appendTraffic(t, fixture, teams.WrapUpRequest(""))
+ news := manager.teamBoundary()
+ if !strings.Contains(news, `Wrap up "harbor" now`) || !strings.Contains(news, "team_close_report") {
+ t.Fatalf("the manager was not told to wrap up:\n%s", news)
+ }
+ manager.team.mu.Lock()
+ going := len(manager.team.wraps)
+ manager.team.mu.Unlock()
+ if going != 1 {
+ t.Fatal("the wrap-up's clock did not start")
+ }
+ f, _ := teams.Load(fixture.profile)
+ if team, _ := f.Team(fixture.teamID); team.Wrap == nil || team.Wrap.Bound != wrapUpFor || team.Wrap.Started.IsZero() {
+ t.Fatalf("the wrap-up was not written down: %+v", team.Wrap)
+ }
+ said, failed := callTool(t, manager.teamCloseReportTool, `{"done":"the form","left":"the tests","files":["web/form.go"]}`)
+ if failed || !strings.Contains(said, "closing report went to the person") {
+ t.Fatalf("team_close_report said %q", said)
+ }
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Kind != teams.PacketClosing || p.Report == nil || p.Report.Done != "the form" || p.Report.SpendUSD != 1.25 || p.Report.Incomplete {
+ t.Fatalf("the closing report: %+v", p)
+ }
+ if said, failed := callTool(t, manager.teamCloseReportTool, `{"done":"again"}`); !failed || !strings.Contains(said, "already waiting") {
+ t.Fatalf("a second report: %q", said)
+ }
+ if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, teams.OptionClose, ""); err != nil {
+ t.Fatal(err)
+ }
+ if news := manager.teamBoundary(); !strings.Contains(news, "accepted the closing report") {
+ t.Fatalf("the manager was not told:\n%s", news)
+ }
+ f, _ = teams.Load(fixture.profile)
+ if team, _ := f.Team(fixture.teamID); !team.Closed() || team.Report != p.ID {
+ t.Fatalf("the team did not close on its report: %+v", team)
+ }
+}
+
+// A WRAP-UP PAST ITS BOUND IS REPORTED INCOMPLETE by codeaf.
+func TestTeamAWrapUpPastItsBoundIsReportedIncomplete(t *testing.T) {
+ old := wrapUpFor
+ wrapUpFor = 10 * time.Millisecond
+ t.Cleanup(func() { wrapUpFor = old })
+ fixture := newTeamFixture(t, true)
+ stubCapSpend(t, &capSpend{usd: 0.5, stamp: "s1"})
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ appendTraffic(t, fixture, teams.WrapUpRequest("Wrap up first"))
+ manager.teamBoundary()
+ manager.teamWrapUpDue(fixture.profile, time.Now())
+ time.Sleep(20 * time.Millisecond)
+ manager.teamWrapUpDue(fixture.profile, time.Now())
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Kind != teams.PacketClosing || p.Report == nil || !p.Report.Incomplete || p.Options[0].ID != teams.OptionCloseNow ||
+ !strings.Contains(p.Question, "wrap-up incomplete") {
+ t.Fatalf("the incomplete report: %+v", p)
+ }
+ manager.teamWrapUpDue(fixture.profile, time.Now())
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 1 {
+ t.Fatal("the incomplete report was raised twice")
+ }
+}
+
+// A RESTART KEEPS THE TIME THAT IS LEFT. The clock on the team is what the
+// next process arms, and it does not start the bound again.
+func TestTeamWrapUpResumesWithTheTimeLeft(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ stubCapSpend(t, &capSpend{usd: 0.5, stamp: "s1"})
+ started := time.Now().Add(-time.Minute).UTC()
+ bound := 15 * time.Minute
+ if err := teams.Update(fixture.profile, func(f *teams.File) error {
+ return f.SetWrap(fixture.teamID, started, bound)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 0 {
+ t.Fatal("a wrap-up with time left raised a report on start")
+ }
+ manager.team.mu.Lock()
+ w := manager.team.wraps[fixture.teamID]
+ manager.team.mu.Unlock()
+ if w == nil || !w.started.Equal(started) || w.bound != bound {
+ t.Fatalf("the clock did not resume: %+v", w)
+ }
+ manager.teamWrapUpDue(fixture.profile, started.Add(bound))
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Kind != teams.PacketClosing || p.Report == nil || !p.Report.Incomplete {
+ t.Fatalf("the resumed clock did not report at its bound: %+v", p)
+ }
+ if err := manager.Close(); err != nil {
+ t.Fatal(err)
+ }
+ _ = teamAgent(t, fixture, fixture.manager, nil, nil)
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 1 {
+ t.Fatal("a second start raised the report again")
+ }
+}
+
+// holdDecisions is this test holding the team's decisions lock, the way a
+// writer that has not finished does. release lets the next raise through.
+func holdDecisions(t *testing.T, profile, teamID string) func() {
+ t.Helper()
+ path := strings.TrimSuffix(teams.DecisionsPath(profile, teamID), ".jsonl") + ".lock"
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := filelock.Lock(file, true, true); err != nil {
+ file.Close()
+ t.Fatal(err)
+ }
+ return func() {
+ _ = filelock.Unlock(file)
+ file.Close()
+ }
+}
+
+// A BUSY DECISIONS FILE DOES NOT FORGET THE WRAP-UP. The clock comes out of
+// memory before the report is raised, so a raise that cannot take the lock
+// used to leave it out: this process never tried again, and only a restart
+// sent the report. The next look tries again, and the report goes out once.
+func TestTeamWrapUpBusyDecisionsIsTriedAgain(t *testing.T) {
+ every := teamWatchEvery
+ teamWatchEvery = time.Hour
+ t.Cleanup(func() { teamWatchEvery = every })
+ fixture := newTeamFixture(t, true)
+ stubCapSpend(t, &capSpend{usd: 0.5, stamp: "s1"})
+ manager := teamAgent(t, fixture, fixture.manager, nil, nil)
+ manager.teamBoundary()
+ appendTraffic(t, fixture, teams.WrapUpRequest("Wrap up first"))
+ manager.teamBoundary()
+ release := holdDecisions(t, fixture.profile, fixture.teamID)
+ past := time.Now().Add(wrapUpFor)
+ manager.teamWrapUpDue(fixture.profile, past)
+ manager.team.mu.Lock()
+ _, due := manager.team.wraps[fixture.teamID]
+ manager.team.mu.Unlock()
+ if !due {
+ t.Fatal("a raise that could not take the decisions lock forgot the wrap-up")
+ }
+ f, err := teams.Load(fixture.profile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if team, _ := f.Team(fixture.teamID); team.Wrap == nil {
+ t.Fatal("a failed raise cleared the clock on disk")
+ }
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 0 {
+ t.Fatalf("a failed raise still left a report: %+v", waiting)
+ }
+ release()
+ manager.teamWrapUpDue(fixture.profile, past)
+ if waiting, _, err := teams.OpenPackets(fixture.profile, teams.Person); err != nil || len(waiting) != 1 {
+ t.Fatalf("the next look did not raise the one report: %+v %v", waiting, err)
+ }
+ f, err = teams.Load(fixture.profile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if team, _ := f.Team(fixture.teamID); team.Wrap != nil {
+ t.Fatalf("the clock was left on the team: %+v", team.Wrap)
+ }
+ manager.team.mu.Lock()
+ _, still := manager.team.wraps[fixture.teamID]
+ manager.team.mu.Unlock()
+ if still {
+ t.Fatal("a sent report left the clock in memory")
+ }
+ manager.teamWrapUpDue(fixture.profile, past.Add(time.Minute))
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 1 {
+ t.Fatal("the report was raised twice")
+ }
+}
+
+// A WRAP-UP ALREADY PAST ITS BOUND CLOSES ON THE START, and only once.
+func TestTeamWrapUpPastItsBoundClosesOnceOnStart(t *testing.T) {
+ fixture := newTeamFixture(t, true)
+ stubCapSpend(t, &capSpend{usd: 0.5, stamp: "s1"})
+ started := time.Now().Add(-20 * time.Minute).UTC()
+ if err := teams.Update(fixture.profile, func(f *teams.File) error {
+ return f.SetWrap(fixture.teamID, started, 15*time.Minute)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ first := teamAgent(t, fixture, fixture.manager, nil, nil)
+ p := onlyPacket(t, fixture.profile, teams.Person)
+ if p.Kind != teams.PacketClosing || p.Report == nil || !p.Report.Incomplete ||
+ !strings.Contains(p.Question, "wrap-up incomplete") {
+ t.Fatalf("a past wrap-up did not close on start: %+v", p)
+ }
+ f, _ := teams.Load(fixture.profile)
+ if team, _ := f.Team(fixture.teamID); team.Wrap != nil {
+ t.Fatalf("the finished clock was left on the team: %+v", team.Wrap)
+ }
+ if err := first.Close(); err != nil {
+ t.Fatal(err)
+ }
+ _ = teamAgent(t, fixture, fixture.manager, nil, nil)
+ if waiting, _, _ := teams.OpenPackets(fixture.profile, teams.Person); len(waiting) != 1 {
+ t.Fatal("a second start closed the team again")
+ }
+}
+
+// THE PROFILE'S teams.wake IS THE DEFAULT A TEAM WITH NO OVERRIDE TAKES.
+func TestTeamWakeFollowsTheProfileDefault(t *testing.T) {
+ fixture := newWakingTeamFixture(t)
+ web := teamAgent(t, fixture, fixture.web, nil, nil)
+ if roles := web.teamRoles(); len(roles) != 1 || !roles[0].wakes {
+ t.Fatalf("a team with no override does not wake by default: %+v", roles)
+ }
+ if err := os.WriteFile(filepath.Join(fixture.profile, "config.json"), []byte(`{"teams.wake": false}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // The config's stamp moves; the teams file's does not.
+ later := time.Now().Add(2 * time.Second)
+ _ = os.Chtimes(filepath.Join(fixture.profile, "config.json"), later, later)
+ if roles := web.teamRoles(); len(roles) != 1 || roles[0].wakes {
+ t.Fatalf("teams.wake off did not reach the role: %+v", roles)
+ }
+}
+
+// AT THE CAP A DIRECTIVE WAKES NOBODY: the member is held, the Traffic says
+// so, and the person is asked once.
+func TestTeamACapHoldsAWake(t *testing.T) {
+ fastTeamWake(t)
+ fixture := newWakingTeamFixture(t)
+ five := 5.0
+ if err := teams.Update(fixture.profile, func(f *teams.File) error {
+ return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.CapUSDDay = &five })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ stubCapSpend(t, &capSpend{usd: 6, stamp: "s1"})
+ webAnswers := oneAnswer(1)
+ teamAgent(t, fixture, fixture.web, webAnswers, nil)
+ appendTraffic(t, fixture, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "web", Text: "Fix the header."})
+ deadline := time.Now().Add(teamWakeSettle + 40*teamWatchEvery)
+ held := false
+ for time.Now().Before(deadline) && !held {
+ log, _ := teams.ReadTraffic(fixture.profile, fixture.teamID, "", 0)
+ for _, entry := range log {
+ held = held || strings.HasPrefix(entry.Text, "held @web: harbor reached its $5 cap today")
+ }
+ time.Sleep(teamWatchEvery)
+ }
+ if !held {
+ t.Fatal("the Traffic never said the wake was held")
+ }
+ if webAnswers.requests() != 0 {
+ t.Fatal("a member at the cap was woken")
+ }
+ onlyPacket(t, fixture.profile, teams.Person)
+}
diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go
new file mode 100644
index 0000000000..372cfae9e9
--- /dev/null
+++ b/internal/session/team_nest.go
@@ -0,0 +1,698 @@
+package session
+
+// ── NESTED TEAMS: SUB-TEAMS, CONFLICTS, ONE LEVEL, AND THE GLOBAL MANAGER ────
+//
+// The rulings (c-5, c-6, c-7; docs/design/conversations-and-teams/DESIGN.md,
+// section 8): work is handed down a tree of teams and decisions travel up it.
+// This file is the session's half of the tree:
+//
+// - A SUB-TEAM IS STARTED LIKE A MEMBER. `team_start` of kind team makes the
+// child team in the store under the manager's team (refused past the
+// effective depth limit or under a closed team, with the reason), writes
+// its share of the pool on it ([teams.File.SubTeamCap]), moves in the
+// members it names, and writes ONE start to the parent's Traffic, the start
+// the interface already carries out: it opens the new conversation behind
+// the one in front and adds it to the parent team under its handle. The
+// start names the child ([teams.Entry.Team]), and the new conversation,
+// reading its brief at its first boundary, makes itself the child's manager
+// ([Agent.claimSubTeams]). So its manager is a member of the parent and
+// reports up by the ordinary home rule, and the interface needs nothing new.
+// - A CONFLICT IS DECLARED, NEVER DETECTED. `team_raise` names the other
+// parties by handle (across teams as `team/@handle`), finds the lowest
+// common managed ancestor of all of them ([teams.File.LCA]), and raises a
+// conflict packet there, or to the person when there is none. That manager
+// is woken by the packet line and handed the packet whole; it decides with
+// `team_decide` or sends it up with `team_escalate`, never sideways; and the
+// store turns the decision into a directive to every party in its own team's
+// log ([teams.IsRuling]), which wakes it.
+// - ORDERS GO ONE LEVEL DOWN. A manager's directive and stop reach its own
+// team's members, a sub-team's manager among them, and never a sub-team's
+// members: a handle that names one is refused with the sentence that points
+// to that sub-team's manager ([subTeamPointer]).
+// - THE GLOBAL MANAGER is the manager of the root team (teams' root.go). The
+// store seats every top-level manager as a member of the root, so its
+// verbs, its delivery and its members' homes are the ordinary ones; its view
+// of its team is only those managers ([managedView]), never their chats.
+// - A SUB-TEAM WITH NO MANAGER OF ITS OWN answers to the nearest manager above
+// it for questions and for `team_post` to the manager ([bossOf]); orders do
+// not reach it, because its members are not that manager's members.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/exec/bare"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// bossOf is the managed team a member of team with key answers to: team
+// itself when it has a manager, and otherwise the nearest open ancestor whose
+// manager is not key. "" is none.
+func bossOf(file *teams.File, team teams.Team, key string) (string, string) {
+ if team.Manager != "" {
+ return team.ID, team.Name
+ }
+ for _, up := range file.Ancestors(team.ID) {
+ if up.Closed() || up.Manager == "" || up.Manager == key {
+ continue
+ }
+ return up.ID, up.Name
+ }
+ return "", ""
+}
+
+// managedView is team as its manager runs it. For the root team it is its
+// manager and the top-level managers ([teams.File.TopManagers]) and nobody
+// else, so the global manager's digest, roster and verbs see only the managers
+// it directs; every other team is itself.
+func managedView(file *teams.File, team teams.Team) teams.Team {
+ if file == nil || !team.Root {
+ return team
+ }
+ view := team.Clone()
+ view.Members = nil
+ if m, ok := team.Member(team.Manager); ok {
+ view.Members = append(view.Members, m)
+ }
+ view.Members = append(view.Members, file.TopManagers()...)
+ return view
+}
+
+// subTeamPointer is the refusal for a handle that is not one of team's own
+// members but names a member of a team under it, "" when it names nobody
+// there. Orders go one level down: it names the sub-team's manager to send to
+// instead, or says that sub-team has none.
+func subTeamPointer(file *teams.File, team teams.Team, handle, what string) string {
+ if file == nil {
+ return ""
+ }
+ handle = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(handle)), "@")
+ for _, below := range file.Descendants(team.ID) {
+ if below.Closed() {
+ continue
+ }
+ member, ok := below.ByHandle(handle)
+ if !ok {
+ continue
+ }
+ // The team one level under team on the way down to below is the one
+ // whose manager takes team's orders.
+ child := below
+ for _, up := range file.Ancestors(below.ID) {
+ if up.ID == team.ID {
+ break
+ }
+ child = up
+ }
+ if member.Key == below.Manager && below.ID == child.ID {
+ return fmt.Sprintf("@%s manages %q, a team under yours, but is not a member of %q, so it is not yours to send %s. "+
+ "Nothing was sent. Ask the person to add it to %q; until then send it nothing but raise what matters with team_raise.", handle, below.Name, team.Name, what, team.Name)
+ }
+ for _, level := range append([]teams.Team{below}, file.Ancestors(below.ID)...) {
+ if level.ID == team.ID {
+ break
+ }
+ if level.Manager == "" || level.Manager == member.Key {
+ continue
+ }
+ boss := "its manager"
+ if m, ok := team.Member(level.Manager); ok && m.Handle != "" {
+ boss = "@" + m.Handle
+ } else if m, ok := level.Member(level.Manager); ok && m.Handle != "" {
+ boss = "@" + m.Handle
+ }
+ return fmt.Sprintf("@%s is in %q, a team under yours, and orders go one level down: it takes them from the manager of %q, not from you. "+
+ "Nothing was sent. Send %s to %s, which passes on what it should.", handle, below.Name, level.Name, what, boss)
+ }
+ return fmt.Sprintf("@%s is in %q, a team under yours with no manager of its own. It asks you its questions and posts to you, but orders go only to your own members. "+
+ "Nothing was sent. Ask the person to give %q a manager, or to move @%s into %q.", handle, below.Name, below.Name, handle, team.Name)
+ }
+ return ""
+}
+
+// ── team_start of kind team ─────────────────────────────────────────────────
+
+// errNest is a sub-team start refused inside the store's write, in the words
+// the model is handed.
+type errNest string
+
+func (e errNest) Error() string { return string(e) }
+
+// teamStartSubTeam makes the child team under team, moves in the members
+// named, and writes the start that opens its manager. It answers what to hand
+// the model and whether that is a refusal.
+func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, name string, moving []string) (string, bool) {
+ profile := a.config.teamProfile()
+ name = strings.Join(strings.Fields(name), " ")
+ if name == "" {
+ return invalidArgumentsPrefix + "a team needs its name", true
+ }
+ if reason := a.teamCapHold(profile, []teamRole{role}); reason != "" {
+ return "No sub-team starts: " + reason + ".", true
+ }
+ d := a.teamDefaults(profile)
+ a.team.mu.Lock()
+ keys := append([]string(nil), a.teamKeysLocked()...)
+ a.team.mu.Unlock()
+ var (
+ childID string
+ capUSD float64
+ moved []string
+ pool string
+ movedIn []teams.MoveNotice
+ )
+ err := teams.Update(profile, func(f *teams.File) error {
+ snap := &teams.File{Teams: make([]teams.Team, len(f.Teams))}
+ for i, t := range f.Teams {
+ snap.Teams[i] = t.Clone()
+ }
+ parent, ok := f.Team(team.ID)
+ if !ok || !teamHoldsKey(keys, parent.Manager) {
+ return errNest("This conversation is not the manager of " + strconv.Quote(team.Name) + " now. Nothing was done.")
+ }
+ if parent.Closed() {
+ return errNest(fmt.Sprintf("%q is closed, and a closed team starts nothing. Nothing was done.", parent.Name))
+ }
+ if !f.CanNest(parent.ID, d) {
+ e := f.Effective(parent.ID, d)
+ from := e.DepthFrom.Words()
+ if from == "" {
+ from = "its own setting"
+ }
+ return errNest(fmt.Sprintf("A team under %q would be level %d, past its depth limit of %d (%s). Nothing was done: hand the work to a member instead, or ask the person to raise the limit.",
+ parent.Name, f.Depth(parent.ID)+1, e.DepthLimit, from))
+ }
+ if _, taken := parent.ByHandle(handle); taken {
+ return errNest(fmt.Sprintf("@%s is already a member of %q. Pick another handle for the new team's manager.", handle, parent.Name))
+ }
+ for _, other := range f.Teams {
+ if !other.Closed() && strings.EqualFold(other.Name, name) {
+ return errNest(fmt.Sprintf("There is already a team called %q. Pick another name.", other.Name))
+ }
+ }
+ var members []teams.Member
+ for _, h := range moving {
+ m, ok := teamMemberByHandle(parent, h)
+ if !ok || m.Key == parent.Manager {
+ return errNest(fmt.Sprintf("No member of %q has the handle %q to move in. Its members are: %s. Nothing was done.", parent.Name, h, teamHandles(parent, parent.Manager)))
+ }
+ if where := reportsElsewhere(f, parent, m); where != "" {
+ return errNest(fmt.Sprintf("@%s reports to the manager of %q, not to you, so it is not yours to move. Nothing was done.", m.Handle, where))
+ }
+ members = append(members, m)
+ }
+ child := teams.Team{ID: teams.NewID(), Name: name, Parent: parent.ID, Made: time.Now()}
+ f.Teams = append(f.Teams, child)
+ if share := f.SubTeamCap(parent.ID, d); share > 0 {
+ if err := f.SetSettings(child.ID, func(s *teams.Settings) { s.CapUSDDay = &share }); err != nil {
+ return err
+ }
+ capUSD = share
+ } else if owner, cap := capPool(f, parent.ID, d); cap > 0 {
+ pool = owner.Name
+ }
+ for _, m := range members {
+ m.Home, m.Started = false, false
+ if err := f.AddMember(child.ID, m); err != nil {
+ return err
+ }
+ if err := f.RemoveMember(parent.ID, m.Key); err != nil {
+ return err
+ }
+ moved = append(moved, "@"+m.Handle)
+ }
+ childID = child.ID
+ movedIn = teams.MemberMoveNotices(snap, f)
+ return nil
+ })
+ var refused errNest
+ if errors.As(err, &refused) {
+ return string(refused), true
+ }
+ if err != nil {
+ return "The team could not be made: " + err.Error(), true
+ }
+ // The members that left this team and joined the new one are told once,
+ // here, where the membership was written. A refusal returned above and
+ // wrote nothing.
+ _ = teams.WriteMoveNotices(profile, movedIn)
+ start := teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: handle, Text: brief, Team: childID}
+ if err := teams.AppendTraffic(profile, team.ID, start); err != nil {
+ return "The team " + strconv.Quote(name) + " was made, but its manager's start could not be written to the traffic: " + err.Error(), true
+ }
+ made := fmt.Sprintf("made by the manager of %q; its manager @%s starts on the brief: %s", team.Name, handle, cutRunesTeam(firstLineTeam(brief), 200))
+ if len(moved) > 0 {
+ made += ". Moved in from " + strconv.Quote(team.Name) + ": " + strings.Join(moved, ", ")
+ }
+ a.teamSay(profile, childID, teams.Entry{Kind: teams.KindNote, From: teams.FromSystem, To: teams.ToRoom, Text: made})
+ money := "It has no cap of its own and none above it."
+ switch {
+ case capUSD > 0:
+ money = fmt.Sprintf("Its cap is $%.2f a day, its share of your team's pool.", capUSD)
+ case pool != "":
+ money = fmt.Sprintf("It has no cap of its own and spends from %q's pool.", pool)
+ }
+ said := fmt.Sprintf("Made the team %q under %q and asked for its manager @%s. The conversations view opens @%s in %q's folder as a member of %q; it is handed your brief marked as yours, "+
+ "makes itself the manager of %q, and reports to you. %s", name, team.Name, handle, handle, team.Name, team.Name, name, money)
+ if len(moved) > 0 {
+ said += " Moved in: " + strings.Join(moved, ", ") + "; they are its manager's to direct now, not yours."
+ }
+ return said, false
+}
+
+// subTeamClaim is a start this conversation was opened by that names the
+// team it is to manage.
+type subTeamClaim struct {
+ team, parent, key string
+}
+
+// subTeamBriefLine is a sub-team start's brief as its manager is handed it:
+// the brief, under the words that say what it is to run.
+func subTeamBriefLine(file *teams.File, entry teams.Entry, brief string) string {
+ name, parent := "a new team", "your manager's team"
+ if file != nil {
+ if t, ok := file.Team(entry.Team); ok {
+ name = strconv.Quote(t.Name)
+ if p, ok := file.Team(t.Parent); ok {
+ parent = strconv.Quote(p.Name)
+ }
+ }
+ }
+ return fmt.Sprintf("◆ you were started to manage the team %s, under %s: you run it and report to the manager who started you.\n", name, parent) + brief
+}
+
+// claimSubTeams makes this conversation the manager of each team a start it
+// was opened by names, once its brief has been read. It is a member of the
+// parent (the interface added it there under its handle), and it is added to
+// the child with the same record. A team that has another manager by now, or
+// is gone or closed, is left alone, and the parent's Traffic says why.
+func (a *Agent) claimSubTeams(profile string, claims []subTeamClaim) {
+ for _, claim := range claims {
+ var why string
+ err := teams.Update(profile, func(f *teams.File) error {
+ child, ok := f.Team(claim.team)
+ if !ok || child.Closed() {
+ why = "the team is gone or closed"
+ return nil
+ }
+ if child.Manager != "" && child.Manager != claim.key {
+ why = "it has another manager already"
+ return nil
+ }
+ parent, _ := f.Team(claim.parent)
+ m, ok := parent.Member(claim.key)
+ if !ok {
+ m = teams.Member{Key: claim.key}
+ }
+ m.Home, m.Started = false, false
+ if err := f.AddMember(child.ID, m); err != nil {
+ return err
+ }
+ return f.SetManager(child.ID, claim.key)
+ })
+ if err != nil {
+ why = err.Error()
+ }
+ if why != "" {
+ a.teamSay(profile, claim.parent, teams.Entry{Kind: teams.KindEvent, From: teams.FromSystem, To: teams.ToManager, Member: claim.key,
+ State: teams.StateFailed, Text: "could not make the new conversation a team's manager: " + why})
+ }
+ }
+}
+
+// ── team_raise ──────────────────────────────────────────────────────────────
+
+const teamRaiseToolName = "team_raise"
+
+const teamRaiseDescription = "Raise a conflict you cannot settle with the other side yourself: two or more conversations (you and the parties you name) need incompatible things. " +
+ "It goes as one decision packet to the lowest manager above all of you who is not one of you, or to the person when there is none, and wakes that manager. " +
+ "Write it so the decider needs no transcript: the question, your side, and options each with what happens. The ruling comes back to every party as a directive."
+
+const teamRaiseSchema = `{"type":"object","properties":{` +
+ `"question":{"type":"string","description":"What must be decided, in one line."},` +
+ `"parties":{"type":"array","items":{"type":"string"},"description":"The other side(s): a handle like @api, or team/@handle for a member of another team. You are a party already."},` +
+ `"context":{"type":"string","description":"Your side, in your words."},` +
+ `"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"consequence":{"type":"string","description":"What happens if it is chosen."}},"required":["label","consequence"],"additionalProperties":false},"description":"At least two."},` +
+ `"recommend":{"type":"string","description":"The option you would pick, by number or label. Optional."},` +
+ `"reason":{"type":"string","description":"Why, when you recommend one."},` +
+ teamArgSchema + `},"required":["question","parties","options"],"additionalProperties":false}`
+
+// raiseTools is the verb every conversation in a managed team has, member or
+// manager.
+func (a *Agent) raiseTools() []bare.Tool {
+ return []bare.Tool{
+ {Name: teamRaiseToolName, Description: teamRaiseDescription, Schema: json.RawMessage(teamRaiseSchema), Execute: a.teamRaiseTool},
+ }
+}
+
+// partyAt is one party found by the words that named it.
+type partyAt struct {
+ member teams.Member
+ team teams.Team
+}
+
+// resolveParty is the conversation words name: `team/@handle` (a team by name
+// or id), or a bare handle looked for first in the teams this conversation is
+// in and then in every open team. Several conversations answering one bare
+// handle is a refusal that lists them.
+func resolveParty(file *teams.File, roles []teamRole, words string) (partyAt, string) {
+ words = strings.TrimSpace(words)
+ teamWord, handle := "", words
+ if i := strings.LastIndex(words, "/"); i >= 0 {
+ teamWord, handle = strings.TrimSpace(words[:i]), words[i+1:]
+ }
+ handle = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(handle)), "@")
+ if handle == "" {
+ return partyAt{}, fmt.Sprintf("%q names no handle.", words)
+ }
+ var found []partyAt
+ look := func(t teams.Team) {
+ if t.Closed() {
+ return
+ }
+ if m, ok := t.ByHandle(handle); ok {
+ for _, have := range found {
+ if have.member.Key == m.Key {
+ return
+ }
+ }
+ found = append(found, partyAt{member: m, team: t})
+ }
+ }
+ if teamWord != "" {
+ for _, t := range file.Teams {
+ if t.ID == teamWord || strings.EqualFold(t.Name, teamWord) {
+ look(t)
+ }
+ }
+ if len(found) == 0 {
+ return partyAt{}, fmt.Sprintf("No open team called %q has a member @%s.", teamWord, handle)
+ }
+ } else {
+ for _, role := range roles {
+ if t, ok := file.Team(role.id); ok {
+ look(t)
+ }
+ }
+ if len(found) == 0 {
+ for _, t := range file.Teams {
+ look(t)
+ }
+ }
+ }
+ switch len(found) {
+ case 0:
+ return partyAt{}, fmt.Sprintf("No team has a member @%s. Name it as team/@handle.", handle)
+ case 1:
+ return found[0], ""
+ }
+ var named []string
+ for _, p := range found {
+ named = append(named, p.team.Name+"/@"+handle)
+ }
+ return partyAt{}, fmt.Sprintf("@%s is more than one conversation: %s. Say which as team/@handle.", handle, strings.Join(named, ", "))
+}
+
+// atOrUnder reports whether team id is top itself or a team under it.
+func atOrUnder(file *teams.File, id, top string) bool {
+ if id == top {
+ return true
+ }
+ for _, up := range file.Ancestors(id) {
+ if up.ID == top {
+ return true
+ }
+ }
+ return false
+}
+
+// membershipUnder is key's open membership at or under top: one where it is
+// not the manager first, the deepest first among those. "" is none.
+func membershipUnder(file *teams.File, key, top string) string {
+ best, bestManaged, bestDepth := "", true, -1
+ for _, t := range file.Teams {
+ if t.Closed() || !t.Holds(key) || !atOrUnder(file, t.ID, top) {
+ continue
+ }
+ managed := t.Manager == key
+ depth := len(file.Ancestors(t.ID))
+ if best == "" || (bestManaged && !managed) || (managed == bestManaged && depth > bestDepth) {
+ best, bestManaged, bestDepth = t.ID, managed, depth
+ }
+ }
+ return best
+}
+
+func (a *Agent) teamRaiseTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
+ var parsed struct {
+ Question string `json:"question"`
+ Parties []string `json:"parties"`
+ Context string `json:"context"`
+ Options []struct {
+ Label string `json:"label"`
+ Consequence string `json:"consequence"`
+ } `json:"options"`
+ Recommend string `json:"recommend"`
+ Reason string `json:"reason"`
+ Team string `json:"team"`
+ }
+ if err := decodeToolArguments(args, &parsed); err != nil {
+ return invalidArgumentsPrefix + err.Error(), true, nil
+ }
+ question := strings.TrimSpace(parsed.Question)
+ if question == "" {
+ return invalidArgumentsPrefix + "question is empty", true, nil
+ }
+ if len(parsed.Parties) == 0 {
+ return invalidArgumentsPrefix + "name the other party or parties", true, nil
+ }
+ if len(parsed.Options) < 2 {
+ return invalidArgumentsPrefix + "a conflict needs at least two options, each with what happens", true, nil
+ }
+ var options []teams.Option
+ for i, o := range parsed.Options {
+ label, consequence := strings.TrimSpace(o.Label), strings.TrimSpace(o.Consequence)
+ if label == "" || consequence == "" {
+ return invalidArgumentsPrefix + "every option needs a label and what happens if it is chosen", true, nil
+ }
+ options = append(options, teams.Option{ID: strconv.Itoa(i + 1), Label: label, Consequence: consequence})
+ }
+ var recommendation *teams.Recommendation
+ if pick := strings.TrimSpace(parsed.Recommend); pick != "" {
+ for _, o := range options {
+ if o.ID == pick || strings.EqualFold(o.Label, pick) {
+ reason := strings.TrimSpace(parsed.Reason)
+ if reason == "" {
+ reason = "the raiser's own pick"
+ }
+ recommendation = &teams.Recommendation{Option: o.ID, Reason: reason}
+ }
+ }
+ if recommendation == nil {
+ return invalidArgumentsPrefix + "recommend names none of the options", true, nil
+ }
+ }
+ profile := a.config.teamProfile()
+ if profile == "" {
+ return "This conversation is not in a team.", true, nil
+ }
+ file, keys, roles, d := a.teamSnapshot(profile)
+ if file == nil {
+ return "The teams file could not be read.", true, nil
+ }
+ var mine []teamRole
+ for _, role := range roles {
+ if role.managed {
+ mine = append(mine, role)
+ }
+ }
+ if want := strings.TrimSpace(parsed.Team); want != "" {
+ var chosen []teamRole
+ for _, role := range mine {
+ if role.id == want || strings.EqualFold(role.name, want) {
+ chosen = append(chosen, role)
+ }
+ }
+ mine = chosen
+ }
+ if len(mine) == 0 {
+ return "This conversation is in no team with a manager now, so there is nobody to raise a conflict to. Ask the person.", true, nil
+ }
+ // The raiser's own membership: a member's before a manager's, because a
+ // manager's conflict with another team is its team's, raised from where it
+ // is a member.
+ role := mine[0]
+ for _, r := range mine {
+ if !r.manager {
+ role = r
+ break
+ }
+ }
+ if role.handle == "" {
+ return "You have no handle in " + strconv.Quote(role.name) + " yet, so the decider could not tell who raised it. It is given once this conversation has a title.", true, nil
+ }
+ parties := []teams.Party{{Key: role.key, Handle: role.handle, Team: role.id, Context: strings.TrimSpace(parsed.Context)}}
+ partyKeys := []string{role.key}
+ for _, words := range parsed.Parties {
+ at, refusal := resolveParty(file, roles, words)
+ if refusal != "" {
+ return refusal + " Nothing was raised.", true, nil
+ }
+ if teamHoldsKey(keys, at.member.Key) {
+ return "You named yourself; you are a party already. Name the other side. Nothing was raised.", true, nil
+ }
+ if teamHoldsKey(partyKeys, at.member.Key) {
+ continue
+ }
+ partyKeys = append(partyKeys, at.member.Key)
+ parties = append(parties, teams.Party{Key: at.member.Key, Handle: at.member.Handle, Team: at.team.ID})
+ }
+ decider, deciderName := teams.Person, "the person"
+ origin := role.id
+ var lca teams.Team
+ if at, ok := file.LCA(partyKeys...); ok {
+ lca = at
+ decider = at.ID
+ deciderName = fmt.Sprintf("the manager of %q", at.Name)
+ if m, ok := at.Member(at.Manager); ok && m.Handle != "" {
+ deciderName += " (@" + m.Handle + ")"
+ }
+ // Every party's line of the packet runs through a membership under the
+ // decider, so the origin is below it and each ruling lands in a team
+ // the party reads.
+ if !atOrUnder(file, origin, at.ID) {
+ origin = membershipUnder(file, role.key, at.ID)
+ }
+ parties[0].Team = origin
+ for i := 1; i < len(parties); i++ {
+ if !atOrUnder(file, parties[i].Team, at.ID) {
+ if under := membershipUnder(file, parties[i].Key, at.ID); under != "" {
+ parties[i].Team = under
+ }
+ }
+ }
+ }
+ if origin == "" {
+ origin = role.id
+ }
+ raised, err := teams.Raise(profile, teams.Packet{
+ Team: decider, Origin: origin, Kind: teams.PacketConflict, RaisedBy: role.handle,
+ Parties: parties, Question: question, Options: options, Recommendation: recommendation,
+ })
+ if err != nil {
+ return "The conflict could not be raised: " + teamErrorWords(err), true, nil
+ }
+ if lca.ID != "" {
+ if m, ok := lca.Member(lca.Manager); ok {
+ a.teamRouse(profile, lca, file.Effective(lca.ID, d).Wake, []teams.Member{m}, "")
+ }
+ }
+ var others []string
+ for _, p := range parties[1:] {
+ who := "@" + p.Handle
+ if t, ok := file.Team(p.Team); ok && p.Team != role.id {
+ who += " (" + t.Name + ")"
+ }
+ others = append(others, who)
+ }
+ return fmt.Sprintf("Raised conflict %s with %s for %s to decide. The ruling comes to every party, you included, as a directive and starts your turn if you are idle; "+
+ "carry on with what does not depend on it, or end your turn and wait.", raised.ID, strings.Join(others, ", "), deciderName), false, nil
+}
+
+// ── delivery ────────────────────────────────────────────────────────────────
+
+// rulingFor reports whether a ruling entry ([teams.IsRuling]) is addressed to
+// this conversation's membership: by its key, or by its handle when it
+// carries none.
+func rulingFor(role teamRole, entry teams.Entry) bool {
+ if !teams.IsRuling(entry) {
+ return false
+ }
+ if entry.Member != "" {
+ return entry.Member == role.key
+ }
+ return role.handle != "" && entry.To == role.handle
+}
+
+// rulingLine is a ruling as its party is told it. It is binding whoever wrote
+// it: a conflict's decider outranks the parties' own managers on the question
+// it decided, and only the person outranks the decider.
+func rulingLine(entry teams.Entry) string {
+ return "◆ " + indentAfterFirst(cutRunesTeam(strings.TrimSpace(entry.Text), teamEntryText)) +
+ " Follow it unless the person said otherwise in this conversation."
+}
+
+// partyTold is a conflict newly raised, as a party that did not raise it is
+// told it (without a wake): who raised it and who decides it. "" for anyone
+// else.
+func partyTold(role teamRole, entry teams.Entry, p teams.Packet) string {
+ if p.Kind != teams.PacketConflict || entry.State != teams.PacketOpen || p.State != teams.PacketOpen {
+ return ""
+ }
+ for i, party := range p.Parties {
+ if i == 0 || party.Key != role.key || party.Team != role.id {
+ continue
+ }
+ return fmt.Sprintf("◆ @%s raised a conflict naming you (%s), for %s to decide: %s. Its ruling comes to you as a directive.",
+ strings.TrimPrefix(p.RaisedBy, "@"), p.ID, packetDecider(p), oneLineTeam(p.Question))
+ }
+ return ""
+}
+
+// readableBelow is the member of a team under team that words names
+// (team/@handle, or a handle only one conversation under team has), for a
+// read. false is nobody there.
+func readableBelow(profile string, team teams.Team, words string) (teams.Member, bool) {
+ file, err := teams.Load(profile)
+ if err != nil {
+ return teams.Member{}, false
+ }
+ var below []teamRole
+ for _, t := range file.Descendants(team.ID) {
+ if !t.Closed() {
+ below = append(below, teamRole{id: t.ID, name: t.Name})
+ }
+ }
+ if len(below) == 0 {
+ return teams.Member{}, false
+ }
+ // Only the teams under team are searched: a bare handle looks there, and
+ // a named team must be one of them.
+ scoped := &teams.File{Version: file.Version}
+ for _, role := range below {
+ if t, ok := file.Team(role.id); ok {
+ scoped.Teams = append(scoped.Teams, t)
+ }
+ }
+ at, refusal := resolveParty(scoped, below, words)
+ if refusal != "" {
+ return teams.Member{}, false
+ }
+ return at.member, true
+}
+
+// teamPostUp is a post to the manager from a member of a sub-team with no
+// manager of its own: written to the log of the team whose manager it answers
+// to, marked with the sub-team it came from, and that manager roused.
+func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (string, bool, error) {
+ profile := a.config.teamProfile()
+ entry.Member = role.key
+ entry.Text = fmt.Sprintf("(from %q, a team under yours with no manager of its own) %s", team.Name, entry.Text)
+ if err := teams.AppendTraffic(profile, role.boss, entry); err != nil {
+ return "The post could not be written to the traffic of " + strconv.Quote(role.bossName) + ": " + err.Error(), true, nil
+ }
+ file, _, _, _ := a.teamSnapshot(profile)
+ if file != nil {
+ if boss, ok := file.Team(role.boss); ok {
+ if manager, ok := boss.Member(boss.Manager); ok {
+ a.teamRouse(profile, boss, file.Effective(boss.ID, a.teamDefaults(profile)).Wake, []teams.Member{manager}, "")
+ }
+ }
+ }
+ return fmt.Sprintf("Posted to the manager of %q, which %q answers to while it has no manager of its own. It arrives at the start of its next step.", role.bossName, team.Name), false, nil
+}
diff --git a/internal/session/team_nest_test.go b/internal/session/team_nest_test.go
new file mode 100644
index 0000000000..7a4646a08e
--- /dev/null
+++ b/internal/session/team_nest_test.go
@@ -0,0 +1,471 @@
+package session
+
+// NESTED TEAMS, AS TESTS: a manager starts a sub-team whose manager makes
+// itself one on its brief and reports up; orders go one level down and a
+// handle below is pointed at its manager; a conflict goes to the lowest
+// common manager, is ruled there, and the ruling reaches every party; the
+// global manager runs the top-level managers and only them; and a sub-team
+// with no manager of its own answers to the one above. Every fixture is a real
+// teams.json and real Traffic, through internal/teams.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// teamTree is a profile and a set of named transcripts, each in a session folder
+// of its own, for trees the flat fixture cannot draw.
+type teamTree struct {
+ fixture teamFixture
+ paths map[string]string
+}
+
+func newTeamTree(t *testing.T, names ...string) teamTree {
+ t.Helper()
+ root := t.TempDir()
+ n := teamTree{fixture: teamFixture{profile: filepath.Join(root, "profile")}, paths: map[string]string{}}
+ for _, name := range names {
+ dir := filepath.Join(root, "sessions", name)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(dir, placeTranscript)
+ if err := os.WriteFile(path, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ n.paths[name] = path
+ }
+ return n
+}
+
+// member is the teams record for the transcript called name, with handle.
+func (n teamTree) member(t *testing.T, name, handle string) teams.Member {
+ t.Helper()
+ path := n.paths[name]
+ return teams.Member{Key: convKeyOf(t, path), File: path, Word: name + " work", Handle: handle}
+}
+
+func (n teamTree) key(t *testing.T, name string) string { return convKeyOf(t, n.paths[name]) }
+
+func (n teamTree) agent(t *testing.T, name string) *Agent {
+ t.Helper()
+ return teamAgent(t, n.fixture, n.paths[name], nil, nil)
+}
+
+// team adds a team with a manager (by transcript name, "" for none) and
+// members, under parent ("" for the top), with the auto-wake off so every
+// delivery here is read at the boundaries the test runs.
+func (n teamTree) team(t *testing.T, name, parent, manager string, members ...teams.Member) string {
+ t.Helper()
+ id := teams.NewID()
+ must := func(err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ must(teams.Update(n.fixture.profile, func(file *teams.File) error {
+ off := false
+ team := teams.Team{ID: id, Name: name, Parent: parent}
+ team.Settings.Wake = &off
+ file.Teams = append(file.Teams, team)
+ for _, m := range members {
+ if err := file.AddMember(id, m); err != nil {
+ return err
+ }
+ }
+ if manager != "" {
+ return file.SetManager(id, n.key(t, manager))
+ }
+ return nil
+ }))
+ return id
+}
+
+func (n teamTree) file(t *testing.T) *teams.File {
+ t.Helper()
+ f, err := teams.Load(n.fixture.profile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+// ── 1. a sub-team ───────────────────────────────────────────────────────────
+
+// team_start OF KIND TEAM makes the child under the manager's team with its
+// share of the pool, moves in the members named, writes the start the
+// interface carries out, and the conversation it opens makes itself the
+// child's manager on its brief and reports up.
+func TestTeamStartOfKindTeamMakesASubTeamWhoseManagerReportsUp(t *testing.T) {
+ n := newTeamTree(t, "boss", "web", "parser", "api")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "web", "web"), n.member(t, "parser", "parser"))
+ capUSD := 10.0
+ if err := teams.Update(n.fixture.profile, func(f *teams.File) error {
+ return f.SetSettings(harbor, func(s *teams.Settings) { s.CapUSDDay = &capUSD })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ boss := n.agent(t, "boss")
+ boss.teamBoundary()
+ said, failed := callTool(t, boss.teamStartTool, `{"handle":"api","brief":"Build the signup API.\nDone is a green handler test.","kind":"team","name":"backend","members":["parser"]}`)
+ if failed || !strings.Contains(said, `Made the team "backend" under "harbor"`) || !strings.Contains(said, "$5.00 a day") || !strings.Contains(said, "Moved in: @parser") {
+ t.Fatalf("team_start of kind team said %q", said)
+ }
+ f := n.file(t)
+ var backend teams.Team
+ for _, team := range f.Teams {
+ if team.Name == "backend" {
+ backend = team
+ }
+ }
+ if backend.Parent != harbor || !backend.Holds(n.key(t, "parser")) || backend.Settings.CapUSDDay == nil || *backend.Settings.CapUSDDay != 5 {
+ t.Fatalf("the sub-team: %+v", backend)
+ }
+ if h, _ := f.Team(harbor); h.Holds(n.key(t, "parser")) {
+ t.Fatal("the moved member is still in harbor")
+ }
+ log, _ := teams.ReadTraffic(n.fixture.profile, harbor, "", 0)
+ start := last(log)
+ if start.Kind != teams.KindStart || start.To != "api" || start.Team != backend.ID {
+ t.Fatalf("harbor's start line: %+v", start)
+ }
+ here, _ := teams.ReadTraffic(n.fixture.profile, backend.ID, "", 0)
+ if len(here) != 2 || here[0].Text != "@parser joined from harbor" || !strings.Contains(here[1].Text, "its manager @api starts on the brief") {
+ t.Fatalf("backend's own lines: %+v", here)
+ }
+
+ // The interface carries out the start: the new conversation joins harbor
+ // under its handle.
+ if err := teams.Update(n.fixture.profile, func(f *teams.File) error {
+ m := n.member(t, "api", "api")
+ m.Started = true
+ return f.AddMember(harbor, m)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ api := n.agent(t, "api")
+ news := api.teamBoundary()
+ for _, want := range []string{`◆ you were started to manage the team "backend", under "harbor"`, "◆ brief from manager #2: Build the signup API."} {
+ if !strings.Contains(news, want) {
+ t.Errorf("the new manager's first delivery lacks %q:\n%s", want, news)
+ }
+ }
+ f = n.file(t)
+ backend, _ = f.Team(backend.ID)
+ if backend.Manager != n.key(t, "api") {
+ t.Fatalf("the new conversation did not become backend's manager: %+v", backend)
+ }
+ if home, _ := f.Home(n.key(t, "api")); home.Team != harbor {
+ t.Fatalf("backend's manager reports to %+v, want harbor", home)
+ }
+ if home, _ := f.Home(n.key(t, "parser")); home.Team != backend.ID {
+ t.Fatalf("the moved member reports to %+v, want backend", home)
+ }
+ api.mu.Lock()
+ role := api.teamRoleText
+ api.mu.Unlock()
+ for _, want := range []string{`You are the manager of the team "backend".`, `Your team is a team under "harbor": you report to its manager, @boss.`, "@parser"} {
+ if !strings.Contains(role, want) {
+ t.Errorf("the sub-team manager's role lacks %q:\n%s", want, role)
+ }
+ }
+ if !holds(api, teamStatusToolName) || !holds(api, teamPostToolName) || !holds(api, teamRaiseToolName) {
+ t.Error("the sub-team manager lacks its verbs")
+ }
+ // The parent's manager is told what runs under it.
+ boss.teamBoundary()
+ boss.mu.Lock()
+ role = boss.teamRoleText
+ boss.mu.Unlock()
+ if !strings.Contains(role, `Teams under yours: "backend" (run by @api). Direct their managers, never their members.`) {
+ t.Errorf("harbor's manager is not told what runs under it:\n%s", role)
+ }
+}
+
+// PAST THE DEPTH LIMIT, a sub-team is refused with the reason, and nothing is
+// made.
+func TestASubTeamPastTheDepthLimitIsRefusedWithTheReason(t *testing.T) {
+ n := newTeamTree(t, "boss", "web")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "web", "web"))
+ one := 1
+ if err := teams.Update(n.fixture.profile, func(f *teams.File) error {
+ return f.SetSettings(harbor, func(s *teams.Settings) { s.DepthLimit = &one })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ boss := n.agent(t, "boss")
+ boss.teamBoundary()
+ said, failed := callTool(t, boss.teamStartTool, `{"handle":"api","brief":"b","kind":"team","name":"backend"}`)
+ if !failed || !strings.Contains(said, `A team under "harbor" would be level 2, past its depth limit of 1 (its own setting)`) {
+ t.Fatalf("a start past the limit said %q (failed %v)", said, failed)
+ }
+ if len(n.file(t).Teams) != 1 {
+ t.Fatal("a refused start made a team")
+ }
+ if log, _ := teams.ReadTraffic(n.fixture.profile, harbor, "", 0); len(log) != 0 {
+ t.Fatalf("a refused start wrote traffic: %+v", log)
+ }
+}
+
+// ── 3. one level ────────────────────────────────────────────────────────────
+
+// ORDERS GO ONE LEVEL DOWN: the sub-team's manager is the parent manager's to
+// direct, and its members are not, and the refusal names whom to send to.
+func TestOrdersGoOneLevelDownAndPointToTheSubTeamsManager(t *testing.T) {
+ n := newTeamTree(t, "boss", "api", "parser", "loner")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "api", "api"))
+ n.team(t, "backend", harbor, "api", n.member(t, "api", "lead"), n.member(t, "parser", "parser"))
+ n.team(t, "dock", harbor, "", n.member(t, "loner", "loner"))
+ boss, api, parser := n.agent(t, "boss"), n.agent(t, "api"), n.agent(t, "parser")
+ for _, a := range []*Agent{boss, api, parser} {
+ a.teamBoundary()
+ }
+
+ said, failed := callTool(t, boss.teamSendTool, `{"to":"@parser","text":"use JSON","kind":"directive"}`)
+ if !failed || !strings.Contains(said, "orders go one level down") || !strings.Contains(said, "Send a message to @api") {
+ t.Fatalf("a directive to a grandchild said %q", said)
+ }
+ said, failed = callTool(t, boss.teamStopTool, `{"handle":"parser"}`)
+ if !failed || !strings.Contains(said, "Send a stop to @api") {
+ t.Fatalf("a stop of a grandchild said %q", said)
+ }
+ said, failed = callTool(t, boss.teamSendTool, `{"to":"loner","text":"x","kind":"directive"}`)
+ if !failed || !strings.Contains(said, "no manager of its own") {
+ t.Fatalf("a directive into an unmanaged sub-team said %q", said)
+ }
+ // The sub-team's manager itself is a member, and takes the directive.
+ if said, failed = callTool(t, boss.teamSendTool, `{"to":"api","text":"ship it","kind":"directive"}`); failed {
+ t.Fatalf("a directive to the sub-team's manager was refused: %q", said)
+ }
+ if news := api.teamBoundary(); !strings.Contains(news, "◆ directive from manager #1: ship it") {
+ t.Fatalf("the sub-team's manager did not get its manager's directive:\n%s", news)
+ }
+ // Everyone is the manager's own members only.
+ callTool(t, boss.teamSendTool, `{"to":"everyone","text":"standup","kind":"note"}`)
+ if news := parser.teamBoundary(); strings.Contains(news, "standup") {
+ t.Fatalf("a grandchild was handed its grandparent's note to everyone:\n%s", news)
+ }
+ // A read reaches down the tree.
+ if said, failed = callTool(t, boss.teamReadTool, `{"handle":"backend/@parser"}`); failed {
+ t.Fatalf("a read of a member below was refused: %q", said)
+ }
+}
+
+// ── 2. conflicts ────────────────────────────────────────────────────────────
+
+// siblingSubTeams is harbor (boss) over front (lead; web) and back (chief;
+// api), each sub-team's manager a member of harbor.
+func siblingSubTeams(t *testing.T) (teamTree, string, string, string) {
+ t.Helper()
+ n := newTeamTree(t, "boss", "lead", "chief", "web", "api")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "lead", "lead"), n.member(t, "chief", "chief"))
+ front := n.team(t, "front", harbor, "lead", n.member(t, "lead", "lead"), n.member(t, "web", "web"))
+ back := n.team(t, "back", harbor, "chief", n.member(t, "chief", "chief"), n.member(t, "api", "api"))
+ return n, harbor, front, back
+}
+
+// A CONFLICT BETWEEN MEMBERS OF SIBLING SUB-TEAMS lands at their common
+// parent's manager, which is handed it whole and rules; both parties receive
+// the ruling as a directive, and it wakes them.
+func TestAConflictBetweenSiblingSubTeamsIsRuledByTheirCommonManager(t *testing.T) {
+ n, harbor, front, back := siblingSubTeams(t)
+ web, api, boss, lead := n.agent(t, "web"), n.agent(t, "api"), n.agent(t, "boss"), n.agent(t, "lead")
+ for _, a := range []*Agent{web, api, boss, lead} {
+ a.teamBoundary()
+ }
+ if !holds(web, teamRaiseToolName) {
+ t.Fatal("a member was not offered team_raise")
+ }
+ said, failed := callTool(t, web.teamRaiseTool, `{"question":"which shape does the signup form send?","parties":["back/@api"],"context":"the form posts JSON",`+
+ `"options":[{"label":"JSON","consequence":"@api changes the handler"},{"label":"form data","consequence":"@web rewrites the submit"}],"recommend":"JSON","reason":"matches the rest"}`)
+ if failed || !strings.Contains(said, `with @api (back) for the manager of "harbor" (@boss) to decide`) {
+ t.Fatalf("team_raise said %q", said)
+ }
+ p := onlyPacket(t, n.fixture.profile, harbor)
+ if p.Kind != teams.PacketConflict || p.Origin != front || len(p.Parties) != 2 || p.Parties[1].Team != back || p.Recommendation == nil {
+ t.Fatalf("the packet: %+v", p)
+ }
+ if mine, _, _ := teams.OpenPackets(n.fixture.profile, teams.Person); len(mine) != 0 {
+ t.Fatal("a conflict with a common manager reached the person")
+ }
+ // Not the sub-teams' managers: each is below the other party.
+ if news := lead.teamBoundary(); strings.Contains(news, p.ID) {
+ t.Fatalf("a party's own manager was handed the conflict:\n%s", news)
+ }
+ news := boss.teamBoundary()
+ for _, want := range []string{"◆ conflict " + p.ID + " from @web, waiting on you", "parties: @web, @api", "@web says: the form posts JSON", "[1] JSON: @api changes the handler"} {
+ if !strings.Contains(news, want) {
+ t.Errorf("the deciding manager's delivery lacks %q:\n%s", want, news)
+ }
+ }
+ if told := api.teamBoundary(); !strings.Contains(told, "◆ @web raised a conflict naming you ("+p.ID+")") {
+ t.Errorf("the other party was not told:\n%s", told)
+ }
+ // A party's manager may not decide it: it does not wait on lead's team.
+ if said, failed := callTool(t, lead.teamDecideTool, `{"packet":"`+p.ID+`","answer":"2"}`); !failed {
+ t.Fatalf("a party's own manager decided it: %q", said)
+ }
+ if said, failed := callTool(t, boss.teamDecideTool, `{"packet":"`+p.ID+`","answer":"1","reason":"the other endpoints take JSON"}`); failed {
+ t.Fatalf("the common manager could not decide: %q", said)
+ }
+ for name, a := range map[string]*Agent{"web": web, "api": api} {
+ got := a.teamBoundary()
+ for _, want := range []string{"◆ ruling on the conflict " + p.ID, `by ◆ @boss (manager of "harbor"): JSON: @api changes the handler`, "the other endpoints take JSON"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("@%s's ruling lacks %q:\n%s", name, want, got)
+ }
+ }
+ if strings.Contains(got, "◆ answered") {
+ t.Errorf("@%s was handed the ruling twice:\n%s", name, got)
+ }
+ }
+ // Each ruling is in its party's team log, and wakes that party.
+ for team, handle := range map[string]string{front: "web", back: "api"} {
+ log, _ := teams.ReadTraffic(n.fixture.profile, team, "", 0)
+ ruling := last(log)
+ role := teamRole{id: team, handle: handle, managed: true, key: n.key(t, handle)}
+ if !teams.IsRuling(ruling) || !teamWakes(role, ruling) {
+ t.Errorf("%s's ruling does not wake @%s: %+v", team, handle, ruling)
+ }
+ }
+}
+
+// NO COMMON MANAGER IS THE PERSON: two top-level teams with no root.
+func TestAConflictWithNoCommonManagerGoesToThePerson(t *testing.T) {
+ n := newTeamTree(t, "boss", "yard", "web", "api")
+ n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "web", "web"))
+ n.team(t, "dock", "", "yard", n.member(t, "yard", "yard"), n.member(t, "api", "api"))
+ web := n.agent(t, "web")
+ web.teamBoundary()
+ said, failed := callTool(t, web.teamRaiseTool, `{"question":"who owns the schema?","parties":["@api"],"options":[{"label":"web","consequence":"web owns it"},{"label":"api","consequence":"api owns it"}]}`)
+ if failed || !strings.Contains(said, "for the person to decide") {
+ t.Fatalf("team_raise said %q", said)
+ }
+ onlyPacket(t, n.fixture.profile, teams.Person)
+}
+
+// ── 4. the global manager ───────────────────────────────────────────────────
+
+// THE GLOBAL MANAGER runs the top-level managers and only them: its digest and
+// status list them, never their members; a directive reaches a top-level
+// manager as its manager's; a member below is pointed at its manager; a
+// top-level manager's own question goes to it before the person; and it
+// starts top-level teams.
+func TestTheGlobalManagerRunsTheTopLevelManagersOnly(t *testing.T) {
+ n := newTeamTree(t, "gm", "boss", "yard", "web", "api", "hq")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "web", "web"))
+ n.team(t, "dock", "", "yard", n.member(t, "yard", "yard"), n.member(t, "api", "api"))
+ root := ""
+ if err := teams.Update(n.fixture.profile, func(f *teams.File) error {
+ root = f.MakeRoot(f.Teams[0].Made)
+ off := false
+ if err := f.SetSettings(root, func(s *teams.Settings) { s.Wake = &off }); err != nil {
+ return err
+ }
+ if err := f.AddMember(root, n.member(t, "gm", "all")); err != nil {
+ return err
+ }
+ return f.SetManager(root, n.key(t, "gm"))
+ }); err != nil {
+ t.Fatal(err)
+ }
+ gm, boss := n.agent(t, "gm"), n.agent(t, "boss")
+ gm.teamBoundary()
+ boss.teamBoundary()
+ gm.mu.Lock()
+ role := gm.teamRoleText
+ gm.mu.Unlock()
+ for _, want := range []string{"You are the global manager", "@boss", "@yard"} {
+ if !strings.Contains(role, want) {
+ t.Errorf("the global manager's role lacks %q:\n%s", want, role)
+ }
+ }
+ if strings.Contains(role, "@web") || strings.Contains(role, "@api") {
+ t.Errorf("the global manager was handed the teams' members:\n%s", role)
+ }
+ status, _ := callTool(t, gm.teamStatusTool, `{}`)
+ if !strings.Contains(status, "@boss") || strings.Contains(status, "@web") {
+ t.Errorf("the global manager's status:\n%s", status)
+ }
+ if said, failed := callTool(t, gm.teamSendTool, `{"to":"web","text":"x","kind":"directive"}`); !failed || !strings.Contains(said, "Send a message to @boss") {
+ t.Fatalf("a directive past the top-level managers said %q", said)
+ }
+ if said, failed := callTool(t, gm.teamSendTool, `{"to":"boss","text":"ship harbor first","kind":"directive"}`); failed {
+ t.Fatalf("a directive to a top-level manager was refused: %q", said)
+ }
+ news := boss.teamBoundary()
+ if !strings.Contains(news, "◆ directive from manager #1: ship harbor first") {
+ t.Fatalf("the top-level manager did not get the global manager's directive:\n%s", news)
+ }
+ boss.mu.Lock()
+ role = boss.teamRoleText
+ boss.mu.Unlock()
+ if !strings.Contains(role, `Your team is a top-level team, under the global manager of "All teams": you report to its manager, @all.`) {
+ t.Errorf("the top-level manager is not told whom it reports to:\n%s", role)
+ }
+ // Its own question goes to the global manager, not the person.
+ said, _ := callTool(t, boss.executeAsk, clarifying)
+ if strings.Contains(said, "the person's inbox") {
+ t.Fatalf("a top-level manager's question skipped the global manager: %q", said)
+ }
+ if p := onlyPacket(t, n.fixture.profile, root); p.RaisedBy == "" {
+ t.Fatalf("the packet: %+v", p)
+ }
+ // It starts a top-level team.
+ said, failed := callTool(t, gm.teamStartTool, `{"handle":"ops","brief":"Run the ops.","kind":"team","name":"ops"}`)
+ if failed {
+ t.Fatalf("the global manager could not start a top-level team: %q", said)
+ }
+ f := n.file(t)
+ for _, team := range f.Teams {
+ if team.Name == "ops" && (team.Parent != root || f.Depth(team.ID) != 1) {
+ t.Fatalf("the new team is not top-level: %+v", team)
+ }
+ }
+ _ = harbor
+}
+
+// ── 5. a sub-team with no manager ───────────────────────────────────────────
+
+// A MEMBER OF A SUB-TEAM WITH NO MANAGER answers to the nearest manager above
+// it: it has the member's verb, its post to the manager reaches that manager,
+// and its question goes there as a packet from its own team.
+func TestAnUnmanagedSubTeamsMemberAnswersToTheManagerAbove(t *testing.T) {
+ n := newTeamTree(t, "boss", "web", "loner")
+ harbor := n.team(t, "harbor", "", "boss", n.member(t, "boss", "boss"), n.member(t, "web", "web"))
+ dock := n.team(t, "dock", harbor, "", n.member(t, "loner", "loner"))
+ loner, boss := n.agent(t, "loner"), n.agent(t, "boss")
+ loner.teamBoundary()
+ boss.teamBoundary()
+ if !holds(loner, teamPostToolName) {
+ t.Fatal("a member of an unmanaged sub-team has no team_post")
+ }
+ loner.mu.Lock()
+ role := loner.teamRoleText
+ loner.mu.Unlock()
+ if !strings.Contains(role, `You are @loner in the team "dock", which has no manager of its own, so you answer to the manager of "harbor"`) {
+ t.Errorf("its role:\n%s", role)
+ }
+ said, failed := callTool(t, loner.teamPostTool, `{"to":"manager","text":"the dock is done"}`)
+ if failed || !strings.Contains(said, `Posted to the manager of "harbor"`) {
+ t.Fatalf("team_post to the manager said %q", said)
+ }
+ log, _ := teams.ReadTraffic(n.fixture.profile, harbor, "", 0)
+ if post := last(log); post.From != "loner" || post.To != teams.ToManager || !strings.Contains(post.Text, `(from "dock"`) {
+ t.Fatalf("harbor's log: %+v", post)
+ }
+ if news := boss.teamBoundary(); !strings.Contains(news, "from @loner") || !strings.Contains(news, "the dock is done") {
+ t.Fatalf("harbor's manager was not handed the post:\n%s", news)
+ }
+ said, _ = callTool(t, loner.executeAsk, clarifying)
+ if !strings.Contains(said, "went to your manager (@boss") {
+ t.Fatalf("its question said %q", said)
+ }
+ if p := onlyPacket(t, n.fixture.profile, harbor); p.Origin != dock || p.RaisedBy != "loner" {
+ t.Fatalf("the question's packet: %+v", p)
+ }
+}
diff --git a/internal/session/team_questions.go b/internal/session/team_questions.go
new file mode 100644
index 0000000000..abd1f89040
--- /dev/null
+++ b/internal/session/team_questions.go
@@ -0,0 +1,553 @@
+package session
+
+// ── QUESTIONS GO UP AS DECISION PACKETS ─────────────────────────────────────
+//
+// The ruling (c-5, c-7): the person talks to the top manager and steps away,
+// so a member's clarifying question goes to the manager it reports to first
+// (its HOME, teams' home.go), and only what no manager may or can decide
+// reaches the person. Whatever travels up travels as one self-contained
+// DECISION PACKET ([teams.Packet]): the question, the asker's context, the
+// options each with what happens if it is chosen, and the asker's own pick
+// with its reason, so whoever decides it never has to read a transcript.
+//
+// THE ROAD, end to end:
+//
+// - A member calls `ask` with a clarifying question (a clarification, a
+// choice or a confirmation) while `questions_up` is on for the team it
+// reports to ([teams.Effective], inherited). Nothing is put on the
+// person's screen: [Agent.askUp] raises a question packet addressed to
+// the home manager's team and hands the model back a sentence saying
+// so. The call does not park: a manager may take minutes, and a tool call
+// parked across two conversations is a turn nobody can stop cleanly.
+// - The raise appends a [teams.KindPacket] line to the team's Traffic, and
+// the manager's traffic watch wakes it on that line
+// ([Agent.teamPacketWakes]); its delivery hands it the packet whole
+// ([teamPacketLine]).
+// - The manager answers with `team_decide`, or sends it up with
+// `team_escalate` to its own manager or to the person. The decision is one
+// more packet line, which wakes the member and is delivered to it marked
+// `◆ answered: …`, and the Traffic rail reads `answered @web: …`.
+//
+// PERMISSION PROMPTS NEVER TAKE THIS ROAD. They are the approval gate's, not
+// `ask`'s, and a manager may not answer one (section 5 of the design); only the
+// kinds in [askGoesUp] do, and a permission kind written into `ask` stays the
+// person's.
+//
+// A MANAGER'S OWN QUESTION is a packet too: to its own home manager when it has
+// one and questions go up there, and otherwise to the person (addressed
+// [teams.Person], `you`), where the teams page's inbox shows it. The person's
+// decision reaches the manager by the same delivery and wakes it.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/Agent-Field/codeaf/internal/exec/bare"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+const (
+ teamDecideToolName = "team_decide"
+ teamEscalateToolName = "team_escalate"
+)
+
+const teamDecideDescription = "Decide a decision packet waiting on you: a member's question, or anything your members raised to you. " +
+ "answer is one of the packet's option ids, or your own words when no option fits. The asker is handed your answer marked as yours and continues. " +
+ "You cannot decide a cap or a closing report; those are the person's."
+
+const teamDecideSchema = `{"type":"object","properties":{"packet":{"type":"string","description":"The packet id, like p1a2b3c4d5e6f."},` +
+ `"answer":{"type":"string","description":"An option id, or your own answer."},` +
+ `"reason":{"type":"string","description":"One line: why."},` +
+ teamArgSchema + `},"required":["packet","answer"],"additionalProperties":false}`
+
+const teamEscalateDescription = "Send a decision packet waiting on you up the tree when it is not yours to decide: to your own manager, or to the person. " +
+ "to is up (your manager, or the person when you have none) or you (the person). Never sideways."
+
+const teamEscalateSchema = `{"type":"object","properties":{"packet":{"type":"string","description":"The packet id."},` +
+ `"to":{"type":"string","enum":["up","you"],"description":"Default up."},` +
+ `"reason":{"type":"string","description":"Why it is not yours to decide."},` +
+ teamArgSchema + `},"required":["packet","reason"],"additionalProperties":false}`
+
+// askGoesUp reports whether an `ask` of kind goes up as a packet: the
+// clarifying kinds for a member, and a judgement as well for a manager, whose
+// judgement calls are the person's to make (c-5). A permission, a landing, an
+// assumption and a ratify never go up: the first is the person's safety gate,
+// and the others are about this conversation's own work in front of the person.
+func askGoesUp(kind AskKind, manager bool) bool {
+ switch kind {
+ case AskClarification, AskChoice, AskConfirmation:
+ return true
+ case AskJudgement:
+ return manager
+ }
+ return false
+}
+
+// teamSnapshot is the teams file, this conversation's keys and roles, and the
+// profile's defaults, from one hold of the seat's lock: a stat each of the
+// teams file and config.json, and a read only when one moved.
+func (a *Agent) teamSnapshot(profile string) (*teams.File, []string, []teamRole, teams.Defaults) {
+ a.team.mu.Lock()
+ defer a.team.mu.Unlock()
+ roles := a.teamRolesLocked(profile)
+ return a.team.file, append([]string(nil), a.teamKeysLocked()...), roles, a.team.defaults
+}
+
+// homeOf is the manager keys report to.
+func homeOf(file *teams.File, keys []string) (teams.Report, bool) {
+ if file == nil {
+ return teams.Report{}, false
+ }
+ for _, key := range keys {
+ if report, ok := file.Home(key); ok {
+ return report, true
+ }
+ }
+ return teams.Report{}, false
+}
+
+// askUp is `ask`'s road up: the answer to hand the model and true when q went
+// up as a packet, and false when it is the person's as it always was. A packet
+// that cannot be written falls back to the person, because a question that
+// reaches nobody is worse than one that reaches the person.
+func (a *Agent) askUp(q Question) (string, bool) {
+ profile := a.config.teamProfile()
+ if profile == "" || q.Policy.Kind == PolicyDecide {
+ return "", false
+ }
+ file, keys, roles, d := a.teamSnapshot(profile)
+ if file == nil || len(roles) == 0 {
+ return "", false
+ }
+ var manages *teamRole
+ var member *teamRole
+ for i := range roles {
+ switch {
+ case roles[i].manager && manages == nil:
+ manages = &roles[i]
+ case !roles[i].manager && roles[i].managed && !roles[i].shared && member == nil:
+ member = &roles[i]
+ }
+ }
+ home, hasHome := homeOf(file, keys)
+ upThere := hasHome && file.Effective(home.Team, d).QuestionsUp
+ packet := askPacket(q)
+ switch {
+ case member != nil && askGoesUp(q.Ask, false) && upThere && home.Team == member.boss:
+ if member.handle == "" {
+ return "", false
+ }
+ packet.Team, packet.Origin, packet.RaisedBy = home.Team, member.id, member.handle
+ packet.Parties = []teams.Party{{Key: member.key, Handle: member.handle, Team: member.id, Context: strings.TrimSpace(q.Reason)}}
+ raised, err := teams.Raise(profile, packet)
+ if err != nil {
+ return "", false
+ }
+ who := "your manager"
+ if team, ok := file.Team(home.Team); ok {
+ if m, ok := team.Member(team.Manager); ok {
+ if m.Handle != "" {
+ who = fmt.Sprintf("your manager (@%s, manager of %q)", m.Handle, team.Name)
+ }
+ // A MANAGER NOBODY HOLDS IS OPENED to answer it, as a reply
+ // to it would open it (team_wakewatch.go).
+ a.teamRouse(profile, team, member.wakes, []teams.Member{m}, "")
+ }
+ }
+ return fmt.Sprintf("Your question went to %s as packet %s, not to the person: in this team questions go to the manager first. "+
+ "Its answer comes back to you as a message marked \"◆ answered\" and starts your turn if you are idle. "+
+ "Carry on with what does not depend on it, or end your turn and wait.", who, raised.ID), true
+ case manages != nil && askGoesUp(q.Ask, true):
+ if q.Ask == AskJudgement {
+ packet.Kind = teams.PacketJudgement
+ }
+ packet.RaisedBy = teams.FromManager
+ packet.Parties = []teams.Party{{Key: manages.key, Handle: manages.handle, Team: manages.id, Context: strings.TrimSpace(q.Reason)}}
+ // UP TO ITS OWN MANAGER when it has one and questions go up there;
+ // otherwise the person. Its own team is the origin either way, which
+ // is under the manager it reports to (a manager's home is one level
+ // up, home.go).
+ packet.Origin, packet.Team = manages.id, teams.Person
+ to := "the person's inbox (a decision card on the teams page)"
+ if hasHome && upThere && packet.Kind == teams.PacketQuestion {
+ packet.Team = home.Team
+ if team, ok := file.Team(home.Team); ok {
+ to = fmt.Sprintf("your own manager, the manager of %q", team.Name)
+ }
+ }
+ if len(packet.Options) == 0 && packet.Kind != teams.PacketQuestion {
+ // A judgement needs its options; one written with none is asked of
+ // the person as it always was.
+ return "", false
+ }
+ raised, err := teams.Raise(profile, packet)
+ if err != nil {
+ return "", false
+ }
+ return fmt.Sprintf("Your question went to %s as packet %s. "+
+ "The answer comes back to you as a message marked \"◆ answered\" and starts your turn if you are idle; do not wait or poll for it.", to, raised.ID), true
+ }
+ return "", false
+}
+
+// askPacket is q as a question packet: its head, its answers each with what
+// happens, and the asker's pick with its reason. The caller fills in who
+// decides, where it is from and who raised it.
+func askPacket(q Question) teams.Packet {
+ p := teams.Packet{Kind: teams.PacketQuestion, Question: strings.TrimSpace(q.Head)}
+ byKey := map[string]string{}
+ for _, option := range q.Options {
+ label := strings.TrimSpace(option.Label)
+ if label == "" {
+ continue
+ }
+ consequence := strings.TrimSpace(option.Consequence)
+ if consequence == "" {
+ consequence = strings.TrimSpace(option.Body)
+ }
+ if consequence == "" {
+ consequence = "the asker goes on with " + label
+ }
+ id := strconv.Itoa(len(p.Options) + 1)
+ byKey[option.Key] = id
+ p.Options = append(p.Options, teams.Option{ID: id, Label: label, Consequence: consequence})
+ }
+ if q.Pick != nil {
+ if id, ok := byKey[q.Pick.Key]; ok {
+ reason := strings.TrimSpace(q.Pick.Reason)
+ if reason == "" {
+ reason = "the asker's own pick"
+ }
+ p.Recommendation = &teams.Recommendation{Option: id, Reason: reason}
+ }
+ }
+ return p
+}
+
+// ── the manager's two verbs ─────────────────────────────────────────────────
+
+// packetTools are the manager's verbs for packets waiting on it.
+func (a *Agent) packetTools() []bare.Tool {
+ return []bare.Tool{
+ {Name: teamDecideToolName, Description: teamDecideDescription, Schema: json.RawMessage(teamDecideSchema), Execute: a.teamDecideTool},
+ {Name: teamEscalateToolName, Description: teamEscalateDescription, Schema: json.RawMessage(teamEscalateSchema), Execute: a.teamEscalateTool},
+ }
+}
+
+// managedPacket is packet id when it waits on a team this conversation
+// manages, with that team, and the refusal to hand the model otherwise.
+func (a *Agent) managedPacket(id, want string) (teams.Packet, teams.Team, string) {
+ id = strings.TrimSpace(id)
+ team, _, refusal := a.teamTarget(want, true)
+ if refusal != "" {
+ // One manager in several teams names none: the packet says which.
+ if want != "" || !strings.Contains(refusal, "more than one") {
+ return teams.Packet{}, teams.Team{}, refusal
+ }
+ }
+ profile := a.config.teamProfile()
+ p, err := teams.PacketByID(profile, id)
+ if err != nil {
+ return teams.Packet{}, teams.Team{}, fmt.Sprintf("There is no packet %q.", id)
+ }
+ if !p.Waiting() {
+ return teams.Packet{}, teams.Team{}, fmt.Sprintf("Packet %s was already decided (%s). Nothing was done.", p.ID, p.Decision)
+ }
+ if p.Team == teams.Person {
+ return teams.Packet{}, teams.Team{}, fmt.Sprintf("Packet %s waits on the person, not on you: a %s is theirs to decide.", p.ID, p.Kind)
+ }
+ file, keys, _, _ := a.teamSnapshot(profile)
+ if file == nil {
+ return teams.Packet{}, teams.Team{}, "The teams file could not be read."
+ }
+ at, ok := file.Team(p.Team)
+ if !ok || !teamHoldsKey(keys, at.Manager) {
+ return teams.Packet{}, teams.Team{}, fmt.Sprintf("Packet %s waits on another team's manager, not on you. Nothing was done.", p.ID)
+ }
+ if team.ID != "" && team.ID != at.ID {
+ return teams.Packet{}, teams.Team{}, fmt.Sprintf("Packet %s waits on %q, not on %q.", p.ID, at.Name, team.Name)
+ }
+ return p, at, ""
+}
+
+func (a *Agent) teamDecideTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
+ var parsed struct {
+ Packet string `json:"packet"`
+ Answer string `json:"answer"`
+ Reason string `json:"reason"`
+ Team string `json:"team"`
+ }
+ if err := decodeToolArguments(args, &parsed); err != nil {
+ return invalidArgumentsPrefix + err.Error(), true, nil
+ }
+ answer := strings.TrimSpace(parsed.Answer)
+ if answer == "" {
+ return invalidArgumentsPrefix + "answer is empty", true, nil
+ }
+ p, team, refusal := a.managedPacket(parsed.Packet, parsed.Team)
+ if refusal != "" {
+ return refusal, true, nil
+ }
+ switch p.Kind {
+ case teams.PacketCap, teams.PacketClosing:
+ return fmt.Sprintf("A %s is the person's to decide, never a manager's. Nothing was done.", p.Kind), true, nil
+ }
+ // AN OPTION MAY BE NAMED BY ITS LABEL: a model that writes "JSON" for
+ // option 1 means option 1.
+ for _, option := range p.Options {
+ if strings.EqualFold(option.Label, answer) {
+ answer = option.ID
+ }
+ }
+ decided, err := teams.Decide(a.config.teamProfile(), p.ID, teams.FromManager, answer, strings.TrimSpace(parsed.Reason))
+ if err != nil {
+ return "The decision could not be recorded: " + teamErrorWords(err), true, nil
+ }
+ return fmt.Sprintf("Decided %s in %q: %s. %s is handed your answer marked \"◆ answered\" and continues; the traffic shows it.",
+ decided.ID, team.Name, packetWord(decided), raiserWord(decided.RaisedBy)), false, nil
+}
+
+func (a *Agent) teamEscalateTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
+ var parsed struct {
+ Packet string `json:"packet"`
+ To string `json:"to"`
+ Reason string `json:"reason"`
+ Team string `json:"team"`
+ }
+ if err := decodeToolArguments(args, &parsed); err != nil {
+ return invalidArgumentsPrefix + err.Error(), true, nil
+ }
+ reason := strings.TrimSpace(parsed.Reason)
+ if reason == "" {
+ return invalidArgumentsPrefix + "reason is empty", true, nil
+ }
+ p, team, refusal := a.managedPacket(parsed.Packet, parsed.Team)
+ if refusal != "" {
+ return refusal, true, nil
+ }
+ profile := a.config.teamProfile()
+ to, where := teams.Person, "the person"
+ if strings.TrimSpace(parsed.To) != teams.Person {
+ file, _, _, _ := a.teamSnapshot(profile)
+ if home, ok := homeOf(file, []string{team.Manager}); ok {
+ to = home.Team
+ if at, ok := file.Team(home.Team); ok {
+ where = fmt.Sprintf("the manager of %q", at.Name)
+ }
+ }
+ }
+ if _, err := teams.Escalate(profile, p.ID, teams.FromManager, to, reason); err != nil {
+ return "The packet could not be sent up: " + teamErrorWords(err), true, nil
+ }
+ return fmt.Sprintf("Sent %s up to %s. Its asker is told it went up and is handed the answer when it is decided.", p.ID, where), false, nil
+}
+
+// teamErrorWords is a store error as a sentence for the model.
+func teamErrorWords(err error) string {
+ switch {
+ case errors.Is(err, teams.ErrDecided):
+ return "it was already decided."
+ case errors.Is(err, teams.ErrNotDecider):
+ return "it does not wait on you."
+ case errors.Is(err, teams.ErrSideways):
+ return "a packet goes up the tree or to the person, never sideways or down."
+ case errors.Is(err, teams.ErrNoPacket):
+ return "there is no such packet."
+ }
+ return err.Error()
+}
+
+// raiserWord names a packet's raiser in a sentence.
+func raiserWord(by string) string {
+ switch by {
+ case teams.FromManager:
+ return "The manager"
+ case teams.FromSystem:
+ return "codeaf"
+ case teams.Person:
+ return "The person"
+ }
+ return "@" + strings.TrimPrefix(by, "@")
+}
+
+// packetWord is a decided packet's decision in words: the option's label, or
+// the decider's own words.
+func packetWord(p teams.Packet) string {
+ if option, ok := p.Option(p.Decision); ok {
+ return option.Label
+ }
+ return p.Decision
+}
+
+// ── delivery and the wake ───────────────────────────────────────────────────
+
+// teamPacketText bounds one packet as delivered.
+const teamPacketText = 3000
+
+// teamPacketLine is a [teams.KindPacket] line as this conversation is told it,
+// "" for one that is not its business. Its packet is read by id (the packet
+// files' fold, stat-first, teams' decision.go), which only a packet line
+// costs.
+//
+// - A MANAGER is handed a packet newly waiting on its team whole, and told
+// when a packet it raised (its own question) was decided or sent up, and
+// when the person decided its team's closing report, which closes the
+// team on a Close ([closingDecidedLine]).
+// - A MEMBER is told when its own question was answered (`◆ answered: …`)
+// or sent up.
+func teamPacketLine(profile string, role teamRole, entry teams.Entry) string {
+ p, ok := packetOf(profile, entry)
+ if !ok {
+ return ""
+ }
+ if closingDecided(role, entry, p) {
+ return closingDecidedLine(profile, p)
+ }
+ return packetLineFor(role, entry, p)
+}
+
+// packetOf is the packet a packet line is about, as it stands.
+func packetOf(profile string, entry teams.Entry) (teams.Packet, bool) {
+ if entry.Kind != teams.KindPacket || entry.Packet == "" {
+ return teams.Packet{}, false
+ }
+ p, err := teams.PacketByID(profile, entry.Packet)
+ return p, err == nil
+}
+
+// closingDecided reports whether entry is the decision on role's own team's
+// closing report, for its manager.
+func closingDecided(role teamRole, entry teams.Entry, p teams.Packet) bool {
+ return role.manager && p.Kind == teams.PacketClosing && p.Origin == role.id && entry.State == teams.PacketDecided
+}
+
+// packetLineFor is [teamPacketLine] for every packet but a closing report's
+// decision, off the packet already read.
+func packetLineFor(role teamRole, entry teams.Entry, p teams.Packet) string {
+ mine := p.Origin == role.id && (role.manager && p.RaisedBy == teams.FromManager ||
+ !role.manager && role.handle != "" && p.RaisedBy == role.handle)
+ if p.Kind == teams.PacketClosing || p.Kind == teams.PacketCap {
+ // The person's cards: a manager is told of its closing report's
+ // decision above, and of nothing else about these two here.
+ return ""
+ }
+ if p.Kind == teams.PacketConflict {
+ // A CONFLICT'S DECISION REACHES THE PARTIES AS A RULING, a directive
+ // the store writes to each (team_nest.go), so its packet line hands
+ // them nothing more; a party that did not raise it is told it was
+ // raised.
+ if entry.State == teams.PacketDecided {
+ return ""
+ }
+ if told := partyTold(role, entry, p); told != "" && !(role.manager && p.Team == role.id) {
+ return told
+ }
+ }
+ switch entry.State {
+ case teams.PacketOpen, teams.PacketEscalated:
+ if role.manager && p.Waiting() && p.Team == role.id && entry.State == p.State {
+ return cutRunesTeam(packetBrief(p), teamPacketText)
+ }
+ if mine && entry.State == teams.PacketEscalated {
+ return fmt.Sprintf("◆ your question %s was sent up, to %s: %s. Its answer comes to you when it is decided.", p.ID, packetDecider(p), oneLineTeam(p.Reason))
+ }
+ case teams.PacketDecided:
+ if !mine {
+ return ""
+ }
+ return answeredLine(p)
+ }
+ return ""
+}
+
+// answeredLine is a decided packet as its raiser is handed it.
+func answeredLine(p teams.Packet) string {
+ by := "by the manager"
+ if p.DecidedBy == teams.Person {
+ by = "by the person"
+ } else if p.DecidedBy != "" && p.DecidedBy != teams.FromManager {
+ by = "by ◆ @" + p.DecidedBy
+ }
+ line := "◆ answered: " + indentAfterFirst(cutRunesTeam(packetWord(p), teamEntryText)) + " (" + by
+ if reason := strings.TrimSpace(p.Reason); reason != "" {
+ line += ", because " + oneLineTeam(reason)
+ }
+ return line + "). Your question was: " + oneLineTeam(p.Question)
+}
+
+// packetDecider names who a packet waits on.
+func packetDecider(p teams.Packet) string {
+ if p.Team == teams.Person {
+ return "the person"
+ }
+ return "a manager above"
+}
+
+// packetBrief is a packet whole, as the manager it waits on is handed it.
+func packetBrief(p teams.Packet) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "◆ %s %s from %s, waiting on you: %s", p.Kind, p.ID, raiserName(p.RaisedBy), oneLineTeam(p.Question))
+ if p.Kind == teams.PacketConflict {
+ var sides []string
+ for _, party := range p.Parties {
+ sides = append(sides, "@"+party.Handle)
+ }
+ fmt.Fprintf(&b, "\n parties: %s. Your ruling reaches each of them as a directive; you may team_read a party first (team/@handle for one in a team under yours).", strings.Join(sides, ", "))
+ }
+ for _, party := range p.Parties {
+ if context := strings.TrimSpace(party.Context); context != "" {
+ who := "@" + party.Handle
+ if party.Handle == "" {
+ who = "the asker"
+ }
+ fmt.Fprintf(&b, "\n %s says: %s", who, oneLineTeam(context))
+ }
+ }
+ for _, option := range p.Options {
+ fmt.Fprintf(&b, "\n [%s] %s: %s", option.ID, option.Label, oneLineTeam(option.Consequence))
+ }
+ if r := p.Recommendation; r != nil {
+ fmt.Fprintf(&b, "\n recommended: %s, because %s", r.Option, oneLineTeam(r.Reason))
+ }
+ for _, hop := range p.Trail {
+ fmt.Fprintf(&b, "\n sent up by %s: %s", hop.By, oneLineTeam(hop.Reason))
+ }
+ b.WriteString("\n Decide it with team_decide (an option id, or your own words), or send it up with team_escalate if it is not yours to decide.")
+ return b.String()
+}
+
+// raiserName names a raiser in a packet's first line.
+func raiserName(by string) string {
+ switch by {
+ case teams.FromManager:
+ return "a manager"
+ case teams.FromSystem:
+ return "codeaf"
+ case teams.Person:
+ return "the person"
+ }
+ return "@" + strings.TrimPrefix(by, "@")
+}
+
+// teamPacketWakes reports whether a packet line starts this conversation's
+// turn when it is idle: for a manager, a packet newly waiting on its team, its
+// own packet decided or sent up, or its team's closing report decided; for a
+// member, its own question decided. It closes nothing: that is the delivery's.
+func teamPacketWakes(profile string, role teamRole, entry teams.Entry) bool {
+ p, ok := packetOf(profile, entry)
+ if !ok {
+ return false
+ }
+ if closingDecided(role, entry, p) {
+ return true
+ }
+ if partyTold(role, entry, p) != "" && !(role.manager && p.Team == role.id) {
+ // Told, not woken: the ruling is what it acts on.
+ return false
+ }
+ return packetLineFor(role, entry, p) != "" && (role.manager || entry.State == teams.PacketDecided)
+}
diff --git a/internal/session/team_role_test.go b/internal/session/team_role_test.go
index c1e9e66649..b31c30d092 100644
--- a/internal/session/team_role_test.go
+++ b/internal/session/team_role_test.go
@@ -62,7 +62,7 @@ func TestTheManagersFirstRequestSaysWhatItIs(t *testing.T) {
for _, want := range []string{
`You are the manager of the team "harbor".`,
"@web (web frontend)", "@parser (the parser)",
- "Three laws:", "team_send", "team_start", "team_status", "team_read",
+ "Seven laws:", "team_send", "team_start", "team_status", "team_read", "team_decide", "team_escalate",
"The person outranks you", "permission prompt",
} {
if !strings.Contains(role, want) {
diff --git a/internal/session/team_test.go b/internal/session/team_test.go
index b9557a44e1..6b13953450 100644
--- a/internal/session/team_test.go
+++ b/internal/session/team_test.go
@@ -83,7 +83,12 @@ func makeTeamFixture(t *testing.T, managed, wakes bool) teamFixture {
}
fixture.teamID = teams.NewID()
err := teams.Update(fixture.profile, func(file *teams.File) error {
- file.Teams = append(file.Teams, teams.Team{ID: fixture.teamID, Name: "harbor", WakeOff: !wakes})
+ team := teams.Team{ID: fixture.teamID, Name: "harbor"}
+ if !wakes {
+ off := false
+ team.Settings.Wake = &off
+ }
+ file.Teams = append(file.Teams, team)
for _, member := range []teams.Member{
{Key: convKeyOf(t, fixture.manager), File: fixture.manager, Word: "Harbor manager", Handle: "boss"},
{Key: convKeyOf(t, fixture.web), File: fixture.web, Word: "web frontend", Handle: "web"},
diff --git a/internal/session/team_wake.go b/internal/session/team_wake.go
index f534510bc3..60044dcffa 100644
--- a/internal/session/team_wake.go
+++ b/internal/session/team_wake.go
@@ -136,7 +136,10 @@ func (a *Agent) awaitTeamStart(profile string, base int) {
return
}
if news := a.teamBoundary(); news != "" {
- a.enqueueNote(userMessage{message: textMessage("user", news), wake: true})
+ // AT THE CAP THE BRIEF WAITS: it is queued without the wake, and
+ // the member reads it at its first turn (team_cap.go).
+ held := a.teamCapHold(profile, a.teamRoles()) != ""
+ a.enqueueNote(userMessage{message: textMessage("user", news), wake: !held})
}
return
}
diff --git a/internal/session/team_wakewatch.go b/internal/session/team_wakewatch.go
index dd04f00348..00ac96046a 100644
--- a/internal/session/team_wakewatch.go
+++ b/internal/session/team_wakewatch.go
@@ -118,6 +118,8 @@ type teamWatch struct {
rounds int
heard int64
held bool
+ // capSaid is the last cap hold the Traffic was told of.
+ capSaid string
}
// personTurns counts turns the person started, for the loop breaker. It is
@@ -139,6 +141,10 @@ func (a *Agent) watchTeamTraffic(profile string) {
if profile == "" {
return
}
+ // A wrap-up that was in progress when this process last stopped is armed
+ // before the loop, so one already past its bound closes on the start
+ // rather than waiting out a tick (team_wrapup.go).
+ a.teamWrapUpResume(profile, time.Now())
guard.Go("team traffic wake", func() { a.teamWatchLoop(profile) })
}
@@ -160,6 +166,9 @@ func (a *Agent) teamWatchTick(profile string, now time.Time) bool {
if closed {
return false
}
+ // THE WRAP-UP'S CLOCK is looked at running or idle (team_wrapup.go), and
+ // costs nothing while no wrap-up is in progress.
+ a.teamWrapUpDue(profile, now)
if running {
// A RUNNING TURN READS ITS OWN TRAFFIC at every step boundary. A batch a
// manager was gathering goes with it: its posts land at the next
@@ -182,7 +191,7 @@ func (a *Agent) teamWatchTick(profile string, now time.Time) bool {
arrived := a.teamWatchReadLocked(profile, role)
var waking []teams.Entry
for _, entry := range arrived {
- if teamWakes(role, entry) {
+ if teamWakes(role, entry) || teamPacketWakes(profile, role, entry) {
waking = append(waking, entry)
}
}
@@ -262,6 +271,14 @@ func (a *Agent) teamWatchReadLocked(profile string, role teamRole) []teams.Entry
// member; a member's post to the manager, or a member's finished, failed or
// asking event, for the manager.
func teamWakes(role teamRole, entry teams.Entry) bool {
+ if role.manager && teams.IsWrapUp(entry) {
+ return true
+ }
+ // A CONFLICT'S RULING wakes the party it names, member or manager, whoever
+ // wrote it (team_nest.go).
+ if teams.IsRuling(entry) {
+ return rulingFor(role, entry)
+ }
if role.manager {
switch entry.From {
case "", teams.FromManager, teams.FromYou, teams.FromSystem:
@@ -278,7 +295,7 @@ func teamWakes(role teamRole, entry teams.Entry) bool {
}
return false
}
- if entry.Kind != teams.KindDirective || entry.From != teams.FromManager || strings.TrimSpace(entry.Text) == "" {
+ if entry.Kind != teams.KindDirective || entry.From != teams.FromManager || strings.TrimSpace(entry.Text) == "" || role.shared {
return false
}
return entry.Addressed(role.handle)
@@ -292,13 +309,17 @@ func (a *Agent) teamWakeMember(profile string, roles []teamRole, now time.Time)
a.teamWakeLimitSay(profile, roles, reason, now)
return
}
+ if reason := a.teamCapHold(profile, roles); reason != "" {
+ a.teamCapSay(profile, roles, reason)
+ return
+ }
news := a.teamBoundary()
if news == "" {
// A boundary that ran in the moment between the look and here has
// handed the directive over already; there is nothing left to wake on.
return
}
- text := "Your manager's directive started this turn; the person did not speak.\n\n" + news
+ text := "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\n" + news
woke, reason := a.teamWakeWith(text)
if woke {
a.teamWakeCount(now)
@@ -342,6 +363,14 @@ func (a *Agent) teamWakeManager(profile string, roles []teamRole, batch map[stri
a.teamWakeLimitSay(profile, said, reason, now)
return
}
+ var batchRoles []teamRole
+ for id := range batch {
+ batchRoles = append(batchRoles, byID[id])
+ }
+ if reason := a.teamCapHold(profile, batchRoles); reason != "" {
+ a.teamCapSay(profile, batchRoles, reason)
+ return
+ }
news := a.teamBoundary()
var groups []string
for id, entries := range batch {
@@ -441,6 +470,29 @@ func (a *Agent) teamWakeCount(now time.Time) {
a.team.watch.woken = append(a.team.watch.woken, now)
}
+// teamCapSay tells the Traffic a wake was held at the cap, once per
+// conversation and reason: every held wake says the same thing, and the cap
+// packet is what asks the person.
+func (a *Agent) teamCapSay(profile string, roles []teamRole, reason string) {
+ a.team.mu.Lock()
+ say := a.team.watch.capSaid != reason
+ a.team.watch.capSaid = reason
+ a.team.mu.Unlock()
+ if !say {
+ return
+ }
+ for _, role := range roles {
+ who, to := "◆", teams.ToManager
+ if !role.manager {
+ who, to = "@"+role.handle, role.handle
+ }
+ a.teamSay(profile, role.id, teams.Entry{
+ Kind: teams.KindEvent, From: teams.FromSystem, To: to, Member: role.key,
+ State: teams.StateIdle, Text: "held " + who + ": " + reason,
+ })
+ }
+}
+
// teamWakeLimitSay tells the Traffic the limit was reached, once an hour.
func (a *Agent) teamWakeLimitSay(profile string, roles []teamRole, reason string, now time.Time) {
a.team.mu.Lock()
@@ -620,8 +672,8 @@ func SetTeamResume(open func(file, workspace string) error) {
// says in the Traffic when one could not be. A conversation that is open
// somewhere watches for itself and is left alone. answers is the entry that
// asked for the wake, which a failure to wake answers, "" for none.
-func (a *Agent) teamRouse(profile string, team teams.Team, targets []teams.Member, answers string) {
- if profile == "" || !team.Wakes() || len(targets) == 0 {
+func (a *Agent) teamRouse(profile string, team teams.Team, wakes bool, targets []teams.Member, answers string) {
+ if profile == "" || !wakes || len(targets) == 0 {
return
}
a.team.mu.Lock()
diff --git a/internal/session/team_wrapup.go b/internal/session/team_wrapup.go
new file mode 100644
index 0000000000..4ef73adeeb
--- /dev/null
+++ b/internal/session/team_wrapup.go
@@ -0,0 +1,372 @@
+package session
+
+// ── WRAP UP FIRST: THE MANAGER'S CLOSING REPORT ─────────────────────────────
+//
+// The ruling (c-9): a person closing a team with work running picks `Wrap up
+// first` by default. The interface appends the one request teams' wrapup.go
+// defines ([teams.WrapUpRequest]: a directive from `you` to `manager` whose
+// State is [teams.StateWrapUp]) to the team's Traffic, and nothing else: the
+// two sides meet only there.
+//
+// THE MANAGER'S SIDE, here:
+//
+// - Its delivery hands it the request as an instruction ([wrapUpLine]):
+// tell every member to finish and commit (team_send, directive), answer
+// what it can of what they ask, start nothing new, then write the closing
+// report with `team_close_report` (done, left, where the files are). The
+// request wakes an idle manager like a member's reply does.
+// - The report is a [teams.PacketClosing] packet to the person with the
+// team's spend today, options `Close` and `Keep going`, and a
+// recommendation. The team closes only when the person picks `Close`
+// ([teams.AcceptClosing], which the interface calls on the click and this
+// side calls again on reading the decision; it is idempotent).
+// - IT IS BOUNDED, by [wrapUpFor] and by [wrapUpSpendUSD] of the team's
+// spend since the request. Past either, with no report, codeaf raises the
+// packet itself, marked `wrap-up incomplete`, with `Close now` and `Keep
+// going` ([Agent.teamWrapUpDue]). The clock is looked at by the traffic
+// watch every tick, running or idle, and costs nothing while no wrap-up is
+// in progress.
+//
+// THE CLOCK IS ON THE TEAM, in teams.json ([teams.Wrap]): the start and the
+// bound it was given. A manager that restarts in the middle of one reads it
+// back ([Agent.teamWrapUpResume]) and keeps the time that is left. One
+// already past its bound raises the incomplete report on that start, once,
+// the same way a live clock would have. The person's card still offers
+// `Close now` either way (the interface's own road).
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/exec/bare"
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// The wrap-up's two bounds. Fifteen minutes is long enough for members to
+// finish the piece in hand and commit, and short enough that a person who
+// asked to close is not left wondering; two dollars is a few turns of each
+// member, which is what finishing costs, and not a second day's work. Vars so
+// a test can run a wrap-up out in milliseconds; nothing in the product writes
+// them.
+var (
+ wrapUpFor = 15 * time.Minute
+ wrapUpSpendUSD float64 = 2
+)
+
+const teamCloseReportToolName = "team_close_report"
+
+const teamCloseReportDescription = "Bring the person your team's closing report when you have wrapped up: what was done, what is left, and where the files are. " +
+ "It goes to the person as a decision with Close and Keep going; the team closes only if they pick Close. Use it when the person asked you to wrap up."
+
+const teamCloseReportSchema = `{"type":"object","properties":{"done":{"type":"string","description":"What the team finished."},` +
+ `"left":{"type":"string","description":"What is left, and who had it."},` +
+ `"files":{"type":"array","items":{"type":"string"},"description":"Where the work is: paths, branches, commits."},` +
+ teamArgSchema + `},"required":["done"],"additionalProperties":false}`
+
+// wrapUp is one managed team's wrap-up in progress.
+type wrapUp struct {
+ team string
+ name string
+ started time.Time
+ // bound is how long this wrap-up was given. It is the value of [wrapUpFor]
+ // when the clock started, kept on the team so a restart does not start
+ // the limit again. Zero is a clock from before it was stored, and reads
+ // as [wrapUpFor].
+ bound time.Duration
+ // noted says the start and the bound have been written to the team.
+ noted bool
+ // spentAt is the team's spend today when the wrap-up's clock first
+ // looked, which the spend bound is measured from; measured says it has.
+ spentAt float64
+ measured bool
+}
+
+// wrapUpTools is the manager's report verb.
+func (a *Agent) wrapUpTools() []bare.Tool {
+ return []bare.Tool{
+ {Name: teamCloseReportToolName, Description: teamCloseReportDescription, Schema: json.RawMessage(teamCloseReportSchema), Execute: a.teamCloseReportTool},
+ }
+}
+
+// teamWrapUpBeginLocked records the wrap-up of role's team, once: a second
+// request while one is in progress keeps the first clock. The caller holds
+// a.team.mu, so it reads nothing: the spend the bound is measured from is
+// taken at the clock's first look ([Agent.teamWrapUpDue]).
+func (a *Agent) teamWrapUpBeginLocked(role teamRole, at time.Time) {
+ if a.team.wraps == nil {
+ a.team.wraps = map[string]*wrapUp{}
+ }
+ if _, going := a.team.wraps[role.id]; going {
+ return
+ }
+ if at.IsZero() {
+ at = time.Now()
+ }
+ a.team.wraps[role.id] = &wrapUp{team: role.id, name: role.name, started: at, bound: wrapUpFor}
+}
+
+// teamWrapUpNote writes every wrap-up this process started and has not yet
+// recorded. The caller does not hold a.team.mu: the write takes the file's
+// own lock, and holding the seat across it would stall every reader. A second
+// start keeps the first clock ([teams.File.SetWrap]).
+func (a *Agent) teamWrapUpNote(profile string) {
+ if profile == "" {
+ return
+ }
+ a.team.mu.Lock()
+ var pending []wrapUp
+ for _, w := range a.team.wraps {
+ if !w.noted {
+ pending = append(pending, *w)
+ }
+ }
+ a.team.mu.Unlock()
+ for _, w := range pending {
+ bound := w.bound
+ if bound <= 0 {
+ bound = wrapUpFor
+ }
+ if _, _, err := teams.Change(profile, func(f *teams.File) error {
+ return f.SetWrap(w.team, w.started, bound)
+ }); err != nil {
+ continue
+ }
+ a.team.mu.Lock()
+ if held, ok := a.team.wraps[w.team]; ok {
+ held.noted = true
+ }
+ a.team.mu.Unlock()
+ }
+}
+
+// teamWrapUpResume arms the clock of every team this conversation manages
+// that has a wrap-up on disk, then looks at it. A wrap-up still inside its
+// bound keeps the start it was given, so the time left is what was left. One
+// already past it raises the incomplete report here, on the start, and not
+// again: the record is cleared when the report goes out. It is called when
+// the session opens ([Agent.watchTeamTraffic]).
+func (a *Agent) teamWrapUpResume(profile string, now time.Time) {
+ if profile == "" {
+ return
+ }
+ a.team.mu.Lock()
+ roles := a.teamRolesLocked(profile)
+ file := a.team.file
+ if a.team.wraps == nil {
+ a.team.wraps = map[string]*wrapUp{}
+ }
+ for _, role := range roles {
+ if !role.manager || file == nil {
+ continue
+ }
+ t, ok := file.Team(role.id)
+ if !ok || t.Wrap == nil || t.Closed() {
+ continue
+ }
+ if _, going := a.team.wraps[role.id]; going {
+ continue
+ }
+ a.team.wraps[role.id] = &wrapUp{
+ team: role.id, name: role.name, started: t.Wrap.Started, bound: t.Wrap.Bound, noted: true,
+ }
+ }
+ a.team.mu.Unlock()
+ a.teamWrapUpDue(profile, now)
+}
+
+// clearTeamWrap forgets a wrap-up the clock has finished with. A missing team
+// is not an error worth the report's road.
+func clearTeamWrap(profile, id string) {
+ _, _, _ = teams.Change(profile, func(f *teams.File) error {
+ return f.ClearWrap(id)
+ })
+}
+
+// wrapBound is how long w was given.
+func wrapBound(w wrapUp) time.Duration {
+ if w.bound > 0 {
+ return w.bound
+ }
+ return wrapUpFor
+}
+
+// wrapUpLine is the request as the manager is handed it: the person's words,
+// and what to do.
+func wrapUpLine(role teamRole, entry teams.Entry) string {
+ return fmt.Sprintf("◆ from the person: %s\n Wrap up %q now: team_send every member a directive to finish the piece in hand, commit its work and report; "+
+ "answer what you can of what they ask; start nothing new. Then call team_close_report with what was done, what is left and where the files are. "+
+ "You have %s and %s of team spend for this; past either, codeaf sends the person the report as incomplete.",
+ oneLineTeam(entry.Text), role.name, wrapUpFor.Round(time.Minute), teamMoney(wrapUpSpendUSD))
+}
+
+// teamWrapUpDue raises the incomplete report for every wrap-up past a bound.
+// It is the watch's, every tick, and reads nothing while none is in progress.
+func (a *Agent) teamWrapUpDue(profile string, now time.Time) {
+ a.team.mu.Lock()
+ if len(a.team.wraps) == 0 {
+ a.team.mu.Unlock()
+ return
+ }
+ going := make([]wrapUp, 0, len(a.team.wraps))
+ for _, w := range a.team.wraps {
+ going = append(going, *w)
+ }
+ a.team.mu.Unlock()
+ for _, w := range going {
+ why := ""
+ spent := a.teamPoolSpend(profile, w.team, teamToday())
+ if !w.measured {
+ a.team.mu.Lock()
+ if held, ok := a.team.wraps[w.team]; ok {
+ held.spentAt, held.measured = spent, true
+ }
+ a.team.mu.Unlock()
+ w.spentAt = spent
+ }
+ switch {
+ 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 spent-w.spentAt >= wrapUpSpendUSD:
+ why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamMoney(spent-w.spentAt))
+ default:
+ continue
+ }
+ a.team.mu.Lock()
+ held := a.team.wraps[w.team]
+ delete(a.team.wraps, w.team)
+ a.team.mu.Unlock()
+ if openClosing(profile, w.team) {
+ // The report is already waiting, so the clock is over. Clearing
+ // the record is what stops the next start from raising it again.
+ clearTeamWrap(profile, w.team)
+ continue
+ }
+ if _, err := teams.Raise(profile, closingPacket(w.name, w.team, teams.ClosingReport{
+ Done: "not reported: " + why, Left: "unknown: see the team's traffic and each member's conversation",
+ SpendUSD: roundCents(spent), Incomplete: true,
+ })); err != nil {
+ // THE RAISE DID NOT LAND. The clock was taken out before the
+ // write so a second look during it cannot raise a second report.
+ // A busy decisions file (ErrBusy after its wait) writes nothing,
+ // and leaving the clock out would mean this process never tries
+ // again: only a restart, which reads the disk, would send it.
+ // Put the same clock back and let the next ordinary look retry.
+ // A clock begun since the delete is newer and stays.
+ a.team.mu.Lock()
+ if held != nil {
+ if a.team.wraps == nil {
+ a.team.wraps = map[string]*wrapUp{}
+ }
+ if _, ok := a.team.wraps[w.team]; !ok {
+ a.team.wraps[w.team] = held
+ }
+ }
+ a.team.mu.Unlock()
+ continue
+ }
+ clearTeamWrap(profile, w.team)
+ }
+}
+
+// openClosing reports whether team already has a closing report waiting.
+func openClosing(profile, team string) bool {
+ waiting, _, err := teams.OpenPackets(profile, teams.Person)
+ if err != nil {
+ return false
+ }
+ for _, p := range waiting {
+ if p.Kind == teams.PacketClosing && p.Origin == team {
+ return true
+ }
+ }
+ return false
+}
+
+// closingPacket is a closing report as the person is asked it. An incomplete
+// one offers `Close now` in place of `Close`, and recommends keeping going,
+// because nobody has said the work is safe to leave.
+func closingPacket(name, team string, report teams.ClosingReport) teams.Packet {
+ p := teams.Packet{
+ Team: teams.Person, Origin: team, Kind: teams.PacketClosing, RaisedBy: teams.FromManager,
+ Question: fmt.Sprintf("close %s?", name), Report: &report,
+ }
+ if report.Incomplete {
+ p.RaisedBy = teams.FromSystem
+ p.Question = fmt.Sprintf("close %s? (wrap-up incomplete)", name)
+ p.Options = []teams.Option{
+ {ID: teams.OptionCloseNow, Label: "Close now", Consequence: "every member's turn is stopped and " + name + " closes as it stands"},
+ {ID: teams.OptionKeepGoing, Label: "Keep going", Consequence: name + " stays open and its manager carries on"},
+ }
+ p.Recommendation = &teams.Recommendation{Option: teams.OptionKeepGoing, Reason: "the manager has not said the work is safe to leave"}
+ return p
+ }
+ p.Options = []teams.Option{
+ {ID: teams.OptionClose, Label: "Close", Consequence: name + " closes; its members, traffic and this report are kept under Closed"},
+ {ID: teams.OptionKeepGoing, Label: "Keep going", Consequence: name + " stays open and its manager carries on"},
+ }
+ p.Recommendation = &teams.Recommendation{Option: teams.OptionClose, Reason: "the manager reports the work wrapped up"}
+ return p
+}
+
+func roundCents(usd float64) float64 { return float64(int64(usd*100+0.5)) / 100 }
+
+func (a *Agent) teamCloseReportTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
+ var parsed struct {
+ Done string `json:"done"`
+ Left string `json:"left"`
+ Files []string `json:"files"`
+ Team string `json:"team"`
+ }
+ if err := decodeToolArguments(args, &parsed); err != nil {
+ return invalidArgumentsPrefix + err.Error(), true, nil
+ }
+ done := strings.TrimSpace(parsed.Done)
+ if done == "" {
+ return invalidArgumentsPrefix + "done is empty", true, nil
+ }
+ team, _, refusal := a.teamTarget(parsed.Team, true)
+ if refusal != "" {
+ return refusal, true, nil
+ }
+ profile := a.config.teamProfile()
+ if openClosing(profile, team.ID) {
+ return fmt.Sprintf("A closing report for %q is already waiting on the person. Nothing was sent.", team.Name), true, nil
+ }
+ var files []string
+ for _, file := range parsed.Files {
+ if file = strings.TrimSpace(file); file != "" {
+ files = append(files, file)
+ }
+ }
+ spent := a.teamPoolSpend(profile, team.ID, teamToday())
+ raised, err := teams.Raise(profile, closingPacket(team.Name, team.ID, teams.ClosingReport{
+ Done: done, Left: strings.TrimSpace(parsed.Left), Files: files, SpendUSD: roundCents(spent),
+ }))
+ if err != nil {
+ return "The report could not be sent: " + err.Error(), true, nil
+ }
+ a.team.mu.Lock()
+ delete(a.team.wraps, team.ID)
+ a.team.mu.Unlock()
+ clearTeamWrap(profile, team.ID)
+ return fmt.Sprintf("Your closing report went to the person as packet %s. %q closes only if they pick Close; if they pick Keep going you carry on, and you are told either way.", raised.ID, team.Name), false, nil
+}
+
+// closingDecidedLine is a decided closing report as its manager is told it,
+// having closed the team when the person accepted it.
+func closingDecidedLine(profile string, p teams.Packet) string {
+ closed, _ := teams.AcceptClosing(profile, p)
+ switch p.Decision {
+ case teams.OptionClose, teams.OptionCloseNow:
+ if closed {
+ return "◆ the person accepted the closing report: the team is closed. Tell them it is done, and start nothing more in it."
+ }
+ return "◆ the person accepted the closing report; the team is closed."
+ case teams.OptionKeepGoing:
+ return "◆ the person read the closing report and chose to keep going: the team stays open. Carry on with what is left."
+ }
+ return "◆ the person answered the closing report: " + oneLineTeam(p.Decision)
+}
diff --git a/internal/session/teamevent.go b/internal/session/teamevent.go
index eedf405b70..fbe8314e5d 100644
--- a/internal/session/teamevent.go
+++ b/internal/session/teamevent.go
@@ -168,7 +168,7 @@ func (a *Agent) rouseManager(profile string, role teamRole) {
return
}
if manager, ok := team.Member(team.Manager); ok {
- a.teamRouse(profile, team, []teams.Member{manager}, "")
+ a.teamRouse(profile, team, role.wakes, []teams.Member{manager}, "")
}
}
diff --git a/internal/session/teamspend_test.go b/internal/session/teamspend_test.go
new file mode 100644
index 0000000000..b1a2cfc560
--- /dev/null
+++ b/internal/session/teamspend_test.go
@@ -0,0 +1,34 @@
+package session
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// TestTeamSpendReadsTheSessionsLedger pins the one join internal/teams makes
+// against this package without importing it (it cannot; this package imports
+// it): a team's spend is read from the file [UsageLedgerPath] names, by the
+// JSON names [UsageLine] writes. Two spellings of one path would be two
+// ledgers, and a renamed field would read every team's day as $0.
+func TestTeamSpendReadsTheSessionsLedger(t *testing.T) {
+ if teams.UsageLedgerPath() != UsageLedgerPath() {
+ t.Fatalf("teams reads %s, the ledger is %s", teams.UsageLedgerPath(), UsageLedgerPath())
+ }
+ raw, err := json.Marshal(UsageLine{At: time.Now(), Day: "2026-09-24", Calls: 1, USD: 1.5,
+ Session: "0123456789abcdef", Root: "fedcba9876543210"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"at", "day", "calls", "usd", "session", "root"} {
+ if _, ok := fields[name]; !ok {
+ t.Errorf("a usage line has no %q field, which internal/teams' spend.go reads", name)
+ }
+ }
+}
diff --git a/internal/session/tools_ask.go b/internal/session/tools_ask.go
index 3ba6929669..4eaee4b8ef 100644
--- a/internal/session/tools_ask.go
+++ b/internal/session/tools_ask.go
@@ -198,6 +198,13 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo
if err := q.Check(a.Decisions()); err != nil {
return askRefusedLead + err.Error(), false, nil
}
+ // A TEAM MEMBER'S CLARIFYING QUESTION GOES TO ITS MANAGER FIRST, as a
+ // decision packet, when its team says questions go up (team_questions.go).
+ // A conversation in no team pays a stat of the teams file here, and only
+ // when it asks.
+ if said, up := a.askUp(q); up {
+ return said, false, nil
+ }
if !a.config.Interactive && q.Policy.Kind == PolicyAsk {
if q.Pick == nil {
return "your call: " + strings.TrimSpace(q.Head) + " (nobody to ask)", false, nil
diff --git a/internal/session/tools_standing.go b/internal/session/tools_standing.go
index f7466060cc..fc91dfe966 100644
--- a/internal/session/tools_standing.go
+++ b/internal/session/tools_standing.go
@@ -495,8 +495,10 @@ func (a *Agent) standPropose(ctx context.Context, parsed standArguments) (string
switch {
case answer.Once:
// Nothing is created and nothing is scheduled. The person wanted the
- // action, not the arrangement, so the model does it here.
- return "do it once, now, as an ordinary turn — nothing stands. Nothing was set up.", false, nil
+ // action, not the arrangement. The result says the next step in so
+ // many words, because "nothing was set up" sent a model off to read
+ // this program's source looking for a reminder that was never missing.
+ return "Do it now as an ordinary step and report what happened. The person chose not to repeat it. Do not set it up again unless they ask. Do not investigate codeaf.", false, nil
case !answer.Approved:
if correction := strings.TrimSpace(answer.Change); correction != "" {
// AND THE CORRECTION MAY BE ABOUT ANY OF IT. The card's one change
@@ -1518,7 +1520,7 @@ func (a *Agent) standingAsk(id uint64, notice StandingNotice) Question {
Ask: AskChoice,
Form: FormCard,
Asker: Asker{Kind: AskerModel},
- Head: StandingAskLead + strings.TrimSpace(notice.Item.Words),
+ Head: StandingHead(notice.Item),
Reason: StandingAskReason,
Subject: SubjectRef{Kind: SubjectOrder, ID: id, Name: strings.TrimSpace(notice.Item.Words)},
Options: StandingOptions(notice.Item),
@@ -1533,18 +1535,14 @@ func (a *Agent) standingAsk(id uint64, notice StandingNotice) Question {
// at all.
Blocking: Blocking{Turn: true},
Scope: []AnswerScope{ScopeOnce, ScopeAlways},
+ // THE CORRECTION IS A SENTENCE, not a key that resolves. The button's
+ // own hint is what the box is for.
+ Input: InputShape{Kind: InputText, Prompt: StandingChangeHint(notice.Item)},
}
}
-// StandingAskLead opens the sentence a standing card asks with, and the
-// PERSON'S OWN WORDS close it ([standing.Item.Words]) — the anchor every
-// surface leads this item with. It is a constant so the card, the presence file
-// and the question object cannot become three accounts of one item.
-//
-// IT IS EXPORTED BECAUSE THE SURFACE BUILDS THE SAME QUESTION, for
-// [TaskProposalLead]'s reason exactly: a window has the notice before the
-// questions lane reaches it and raises the question from that, so two builders
-// that drifted would put two questions on screen about one proposal.
+// StandingAskLead is the old opening, kept so a reader of an older line can
+// find what a card used to say. New cards open with [StandingHead].
const StandingAskLead = "wants to keep an eye on: "
// StandingAskReason is why the card is up, in the one sentence that is true of
diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go
index 11d4e99e51..97609170db 100644
--- a/internal/session/tools_team.go
+++ b/internal/session/tools_team.go
@@ -65,7 +65,8 @@ const teamGroup = "team"
var teamToolNames = []string{
teamStatusToolName, teamReadToolName, teamSendToolName, teamStopToolName, teamStartToolName,
- teamPostToolName,
+ teamDecideToolName, teamEscalateToolName, teamCloseReportToolName,
+ teamPostToolName, teamRaiseToolName,
}
// The optional `team` argument every verb takes. It is needed only by a
@@ -104,10 +105,15 @@ const teamStopSchema = `{"type":"object","properties":{"handle":{"type":"string"
const teamStartDescription = "Start a new member conversation in this team with a handle and a brief. The person is asked first. " +
"The new conversation opens in the team's folder and its first message is your brief, marked as from the manager. " +
- "Write the brief as a complete assignment: the goal, what done looks like, and which files are its to touch."
+ "Write the brief as a complete assignment: the goal, what done looks like, and which files are its to touch. " +
+ "kind team starts a sub-team instead: a new team under yours (name) whose manager is the new conversation, with its share of your pool; members you name move into it. " +
+ "Its manager reports to you and takes your orders; its members are its manager's to direct, not yours."
-const teamStartSchema = `{"type":"object","properties":{"handle":{"type":"string","description":"2 to 12 lowercase letters or digits, unique in the team."},` +
+const teamStartSchema = `{"type":"object","properties":{"handle":{"type":"string","description":"2 to 12 lowercase letters or digits, unique in the team. For kind team, the new team's manager."},` +
`"brief":{"type":"string","description":"The whole assignment, as its first message."},` +
+ `"kind":{"type":"string","enum":["member","team"],"description":"Default member."},` +
+ `"name":{"type":"string","description":"kind team: the new team's name."},` +
+ `"members":{"type":"array","items":{"type":"string"},"description":"kind team: handles of your members to move into it."},` +
teamArgSchema + `},"required":["handle","brief"],"additionalProperties":false}`
const teamPostDescription = "Post a message in your team: to the room (every member and the manager), to one member by handle, or to the manager. " +
@@ -170,8 +176,9 @@ func (a *Agent) teamTarget(want string, manager bool) (teams.Team, teamRole, str
a.team.mu.Lock()
keys := append([]string(nil), a.teamKeysLocked()...)
a.team.mu.Unlock()
+ defaults := a.teamDefaults(profile)
var fits []teamRole
- for _, role := range rolesFor(file.Teams, keys) {
+ for _, role := range rolesFor(file, keys, defaults) {
if manager && role.manager || !manager && !role.manager && role.managed {
fits = append(fits, role)
}
@@ -199,6 +206,9 @@ func (a *Agent) teamTarget(want string, manager bool) (teams.Team, teamRole, str
return teams.Team{}, teamRole{}, "You are in more than one team: " + strings.Join(sortedTeamNames(fits), ", ") + ". Say which with team."
}
team, _ := file.Team(fits[0].id)
+ if manager {
+ team = managedView(file, team)
+ }
return team, fits[0], ""
}
@@ -239,7 +249,51 @@ func (a *Agent) teamStatusTool(ctx context.Context, args json.RawMessage) (strin
keys := append([]string(nil), a.teamKeysLocked()...)
a.team.mu.Unlock()
log, _ := teams.ReadTraffic(profile, team.ID, "", teamStateLook)
- return teams.Digest(team, memberStates(team, keys, time.Now(), log, &a.team.journals), recentOf(log), teamStatusBudget), false, nil
+ file, _, _, _ := a.teamSnapshot(profile)
+ states := memberStates(team, keys, time.Now(), log, &a.team.journals)
+ markShared(file, team, states)
+ return teams.Digest(team, states, recentOf(log), teamStatusBudget) + waitingOn(profile, team), false, nil
+}
+
+// markShared marks each member of team that reports to another team's
+// manager with that team's name ([teams.MemberState].ReportsTo), so the
+// digest draws it `reports to dock` and, while it runs, `busy for dock`.
+func markShared(file *teams.File, team teams.Team, states map[string]teams.MemberState) {
+ if file == nil {
+ return
+ }
+ for _, member := range team.Members {
+ if member.Key == team.Manager {
+ continue
+ }
+ home, ok := file.Home(member.Key)
+ if !ok || home.Team == team.ID {
+ continue
+ }
+ at, ok := file.Team(home.Team)
+ if !ok {
+ continue
+ }
+ state := states[member.Key]
+ state.ReportsTo = at.Name
+ states[member.Key] = state
+ }
+}
+
+// waitingOn is the packets waiting on team's manager, one line each, "" for
+// none: what a status answer adds after the digest so a manager that missed a
+// delivery still finds what it owes.
+func waitingOn(profile string, team teams.Team) string {
+ waiting, _, err := teams.OpenPackets(profile, team.ID)
+ if err != nil || len(waiting) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("\nWaiting on you (team_decide or team_escalate):\n")
+ for _, p := range waiting {
+ fmt.Fprintf(&b, "- %s %s from %s: %s\n", p.ID, p.Kind, raiserName(p.RaisedBy), cutRunesTeam(oneLineTeam(p.Question), 200))
+ }
+ return b.String()
}
// ── team_read ───────────────────────────────────────────────────────────────
@@ -259,7 +313,15 @@ func (a *Agent) teamReadTool(ctx context.Context, args json.RawMessage) (string,
}
member, ok := teamMemberByHandle(team, parsed.Handle)
if !ok {
- return fmt.Sprintf("No member of %q has the handle %q. Its members are: %s.", team.Name, parsed.Handle, teamHandles(team, "")), true, nil
+ // A READ REACHES DOWN THE TREE: it changes nothing, so a manager may
+ // read a conversation in a team under its own (the parties of a
+ // conflict it is deciding, most often), named as team/@handle.
+ if below, found := readableBelow(a.config.teamProfile(), team, parsed.Handle); found {
+ member, ok = below, true
+ }
+ }
+ if !ok {
+ return fmt.Sprintf("No member of %q has the handle %q. Its members are: %s. A member of a team under yours is named team/@handle.", team.Name, parsed.Handle, teamHandles(team, "")), true, nil
}
if member.Key == team.Manager {
return "That is this conversation. Its own transcript is already in front of you.", true, nil
@@ -369,14 +431,30 @@ func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string,
default:
return invalidArgumentsPrefix + "kind is note or directive", true, nil
}
- team, _, refusal := a.teamTarget(parsed.Team, true)
+ team, role, refusal := a.teamTarget(parsed.Team, true)
if refusal != "" {
return refusal, true, nil
}
- entry, refusal := teamSendAddress(team, parsed.To)
+ file, _, _, _ := a.teamSnapshot(a.config.teamProfile())
+ entry, refusal := teamSendAddress(file, team, parsed.To, kind)
if refusal != "" {
return refusal, true, nil
}
+ // A DIRECTIVE TO EVERYONE REACHES THE ONES WHO REPORT HERE. A shared
+ // member is left out and named, and the rest are sent it as one message to
+ // several, so no line in the log says `everyone` about a directive some
+ // were not sent, and the replies still thread under one number.
+ var shared []string
+ if entry.To == teams.ToEveryone && kind == teams.KindDirective {
+ var own []teams.Member
+ own, shared = splitByHome(file, team)
+ if len(shared) > 0 {
+ if len(own) == 0 {
+ return fmt.Sprintf("Every member of %q reports to another team's manager (%s), so none may be directed by you. Send them a note instead.", team.Name, strings.Join(shared, ", ")), true, nil
+ }
+ entry = teamEntryFor(own)
+ }
+ }
entry.Kind, entry.From, entry.Text = kind, teams.FromManager, text
id, err := teams.AppendTrafficID(a.config.teamProfile(), team.ID, entry)
if err != nil {
@@ -393,8 +471,12 @@ func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string,
// A DIRECTIVE WAKES, and a member nobody has open is opened so it can
// (team_wakewatch.go). The answer says what will happen and no more:
// whether the wake ran is the Traffic's to say, where the person reads it.
- a.teamRouse(a.config.teamProfile(), team, teamSendTargets(team, entry), id)
- if !team.Wakes() {
+ a.teamRouse(a.config.teamProfile(), team, role.wakes, teamSendTargets(team, entry), id)
+ if len(shared) > 0 {
+ return fmt.Sprintf("Sent a directive to %d member(s) of %q who report to you: %s. Not sent to %s: they report to another team's manager; send them a note.",
+ len(entry.Recipients()), team.Name, who, strings.Join(shared, ", ")), false, nil
+ }
+ if !role.wakes {
return fmt.Sprintf("Sent a directive to %s. This team's auto-wake is off, so a member that is idle reads it when its conversation next runs; a busy one at its next step.", who), false, nil
}
return fmt.Sprintf("Sent a directive to %s. A member that is idle starts a turn on it now, and a busy one reads it at its next step. "+
@@ -405,11 +487,12 @@ func (a *Agent) teamSendTool(ctx context.Context, args json.RawMessage) (string,
// teamSendAddress is where a manager's `to` sends a message: everyone, one
// member, or several named members as ONE entry (internal/teams' thread.go),
-// and the refusal to hand back when a handle names nobody.
-func teamSendAddress(team teams.Team, to string) (teams.Entry, string) {
+// and the refusal to hand back when a handle names nobody, names a member of
+// a team below (whose manager is the one to ask), or names a link a directive
+// may not reach.
+func teamSendAddress(file *teams.File, team teams.Team, to, kind string) (teams.Entry, string) {
words := strings.FieldsFunc(strings.ToLower(to), func(r rune) bool { return r == ' ' || r == ',' || r == ';' })
- var handles []string
- var entry teams.Entry
+ var members []teams.Member
for _, word := range words {
word = strings.TrimPrefix(word, "@")
if word == "" {
@@ -420,22 +503,76 @@ func teamSendAddress(team teams.Team, to string) (teams.Entry, string) {
}
member, ok := teamMemberByHandle(team, word)
if !ok || member.Key == team.Manager {
+ if pointer := subTeamPointer(file, team, word, "a message"); pointer != "" && !ok {
+ return teams.Entry{}, pointer
+ }
return teams.Entry{}, fmt.Sprintf("No member of %q has the handle %q. Send to one or more of %s, or to everyone.", team.Name, word, teamHandles(team, team.Manager))
}
- if !slices.Contains(handles, member.Handle) {
- handles = append(handles, member.Handle)
- entry.Member = member.Key
+ if where := reportsElsewhere(file, team, member); where != "" && kind == teams.KindDirective {
+ return teams.Entry{}, linkRefusal(member, where, "a directive")
+ }
+ if !slices.ContainsFunc(members, func(m teams.Member) bool { return m.Handle == member.Handle }) {
+ members = append(members, member)
}
}
- switch len(handles) {
- case 0:
+ if len(members) == 0 {
return teams.Entry{}, fmt.Sprintf("Say who the message is for: one or more of %s, or everyone.", teamHandles(team, team.Manager))
- case 1:
- entry.To = handles[0]
- default:
- entry.To, entry.Handles, entry.Member = teams.ToSeveral, handles, ""
}
- return entry, ""
+ return teamEntryFor(members), ""
+}
+
+// teamEntryFor addresses an entry to members: the one by handle, or several
+// as one entry that lists them.
+func teamEntryFor(members []teams.Member) teams.Entry {
+ if len(members) == 1 {
+ return teams.Entry{To: members[0].Handle, Member: members[0].Key}
+ }
+ handles := make([]string, 0, len(members))
+ for _, member := range members {
+ handles = append(handles, member.Handle)
+ }
+ return teams.Entry{To: teams.ToSeveral, Handles: handles}
+}
+
+// reportsElsewhere is the name of the team member reports to when that is
+// not team, "" when it reports here (or to nobody).
+func reportsElsewhere(file *teams.File, team teams.Team, member teams.Member) string {
+ if file == nil {
+ return ""
+ }
+ home, ok := file.Home(member.Key)
+ if !ok || home.Team == team.ID {
+ return ""
+ }
+ if at, ok := file.Team(home.Team); ok {
+ return at.Name
+ }
+ return ""
+}
+
+// splitByHome is team's members but its manager: those who report here, and
+// the handles of those who report elsewhere.
+func splitByHome(file *teams.File, team teams.Team) ([]teams.Member, []string) {
+ var own []teams.Member
+ var shared []string
+ for _, member := range team.Members {
+ if member.Key == team.Manager {
+ continue
+ }
+ if reportsElsewhere(file, team, member) != "" {
+ shared = append(shared, "@"+member.Handle)
+ continue
+ }
+ own = append(own, member)
+ }
+ return own, shared
+}
+
+// linkRefusal is the honest sentence a link's directive or stop is refused
+// with: whose the member is, and what the manager may still do.
+func linkRefusal(member teams.Member, where, what string) string {
+ return fmt.Sprintf("@%s reports to the manager of %q, not to you: here you are a link, who may read it (team_read) and send it a note, but not %s. "+
+ "Nothing was sent. Send it a note (kind note), or raise it with its manager.", member.Handle, where, what)
}
// teamSendTargets is who a manager's message is addressed to: the members it
@@ -465,10 +602,17 @@ func (a *Agent) teamStopTool(ctx context.Context, args json.RawMessage) (string,
if refusal != "" {
return refusal, true, nil
}
+ file, _, _, _ := a.teamSnapshot(a.config.teamProfile())
member, ok := teamMemberByHandle(team, parsed.Handle)
if !ok || member.Key == team.Manager {
+ if pointer := subTeamPointer(file, team, parsed.Handle, "a stop"); pointer != "" && !ok {
+ return pointer, true, nil
+ }
return fmt.Sprintf("No member of %q has the handle %q. Its members are: %s.", team.Name, parsed.Handle, teamHandles(team, team.Manager)), true, nil
}
+ if where := reportsElsewhere(file, team, member); where != "" {
+ return linkRefusal(member, where, "a stop"), true, nil
+ }
reason := strings.TrimSpace(parsed.Reason)
if reason == "" {
reason = "stopped by the manager"
@@ -484,13 +628,21 @@ func (a *Agent) teamStopTool(ctx context.Context, args json.RawMessage) (string,
func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string, bool, error) {
var parsed struct {
- Handle string `json:"handle"`
- Brief string `json:"brief"`
- Team string `json:"team"`
+ Handle string `json:"handle"`
+ Brief string `json:"brief"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Members []string `json:"members"`
+ Team string `json:"team"`
}
if err := decodeToolArguments(args, &parsed); err != nil {
return invalidArgumentsPrefix + err.Error(), true, nil
}
+ switch strings.TrimSpace(parsed.Kind) {
+ case "", "member", "team":
+ default:
+ return invalidArgumentsPrefix + "kind is member or team", true, nil
+ }
brief := strings.TrimSpace(parsed.Brief)
if brief == "" {
return invalidArgumentsPrefix + "brief is empty", true, nil
@@ -499,10 +651,20 @@ func (a *Agent) teamStartTool(ctx context.Context, args json.RawMessage) (string
if err := teams.ValidHandle(handle); err != nil {
return invalidArgumentsPrefix + err.Error(), true, nil
}
- team, _, refusal := a.teamTarget(parsed.Team, true)
+ team, role, refusal := a.teamTarget(parsed.Team, true)
if refusal != "" {
return refusal, true, nil
}
+ if strings.TrimSpace(parsed.Kind) == "team" {
+ said, failed := a.teamStartSubTeam(team, role, handle, brief, parsed.Name, parsed.Members)
+ return said, failed, nil
+ }
+ if strings.TrimSpace(parsed.Name) != "" || len(parsed.Members) > 0 {
+ return invalidArgumentsPrefix + "name and members are for kind team", true, nil
+ }
+ if reason := a.teamCapHold(a.config.teamProfile(), []teamRole{role}); reason != "" {
+ return "No new member starts: " + reason + ".", true, nil
+ }
if _, taken := team.ByHandle(handle); taken {
return fmt.Sprintf("@%s is already a member of %q. Pick another handle, or team_send it the work.", handle, team.Name), true, nil
}
@@ -527,10 +689,19 @@ func teamStartArgs(arguments string) (handle, brief string) {
var parsed struct {
Handle string `json:"handle"`
Brief string `json:"brief"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
}
if json.Unmarshal([]byte(arguments), &parsed) != nil {
return "", ""
}
+ // A SUB-TEAM'S CARD SAYS SO: what the person is agreeing to is a new team
+ // as well as a new conversation.
+ if strings.TrimSpace(parsed.Kind) == "team" {
+ if name := strings.Join(strings.Fields(parsed.Name), " "); name != "" {
+ parsed.Brief = "a new team " + strconv.Quote(name) + " under yours, managed by it. " + parsed.Brief
+ }
+ }
handle = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Handle)), "@")
if teams.ValidHandle(handle) != nil {
handle = ""
@@ -608,6 +779,17 @@ func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (string,
if thread == "" && entry.To == teams.ToManager {
thread = a.teamAnswering(team.ID)
}
+ if entry.To == teams.ToManager && role.boss != "" && role.boss != team.ID {
+ // A SUB-TEAM WITH NO MANAGER OF ITS OWN POSTS TO THE ONE ABOVE: the
+ // line goes to that team's log, where its manager reads it, saying
+ // which team it came from. What it answers is what that manager last
+ // said to it, in that log.
+ if strings.TrimSpace(parsed.Thread) == "" {
+ thread = a.teamAnswering(role.boss)
+ }
+ entry.Answers = thread
+ return a.teamPostUp(team, role, entry)
+ }
entry.Answers = thread
// THE POST'S OWN NUMBER IS IN ITS ANSWER, ` as #N`, so the surface can find
// this call in the member's transcript when somebody asks to be taken to
@@ -628,7 +810,7 @@ func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (string,
// A REPLY TO THE MANAGER WAKES IT, so a manager nobody has open is
// opened (team_wakewatch.go).
if manager, ok := team.Member(team.Manager); ok {
- a.teamRouse(a.config.teamProfile(), team, []teams.Member{manager}, "")
+ a.teamRouse(a.config.teamProfile(), team, role.wakes, []teams.Member{manager}, "")
}
}
answered := ""
diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go
index 2cb39ac064..bb1e2c11e8 100644
--- a/internal/session/wakecause.go
+++ b/internal/session/wakecause.go
@@ -146,7 +146,9 @@ func (a *Agent) oweLocked(ask owedAsk) {
// forgetOwedLocked clears the previous turn's owed asks and the results they
// arrived with. Called once, where a turn opens.
-func (a *Agent) forgetOwedLocked() { a.owedAsks, a.landingOutcomes, a.turnResults = nil, nil, nil }
+func (a *Agent) forgetOwedLocked() {
+ a.owedAsks, a.landingOutcomes, a.turnResults, a.personCardAnswers = nil, nil, nil, nil
+}
// turnAsk is the ask this turn's endings are read against.
//
diff --git a/internal/session/world.go b/internal/session/world.go
index 1928ff0e20..5c51274c85 100644
--- a/internal/session/world.go
+++ b/internal/session/world.go
@@ -434,6 +434,39 @@ func ReadWorld(root string) World {
// ReadHome is every project under this machine's state root.
func ReadHome() World { return ReadWorld(PlacesRoot()) }
+// ReadRows is the rows of the named conversations and of nothing else, keyed by
+// each transcript's cleaned path: one [readSessionRow] per name, read exactly as
+// the walk reads that folder.
+//
+// IT IS FOR A SURFACE THAT KNOWS WHICH CONVERSATIONS IT DRAWS. The teams page
+// draws its members, twenty-five on a big machine, and it used to walk every
+// session under the root on its opening and on every beat to find them: a stat,
+// a meta.json, a presence file and a lock taken and let go for each of hundreds
+// of folders, to keep a handful. A name that is not a session folder's journal,
+// or whose folder is not a conversation somebody has had, is simply absent.
+//
+// THE TASK ROLL-UP IS NOT READ. The index is the bucket's ([TaskIndexPath]'s
+// law) and nothing that asks for rows by name draws it, so [SessionRow.Tasks]
+// is the zero roll-up here, as are the project fields.
+func ReadRows(transcripts []string) map[string]SessionRow {
+ now := time.Now()
+ rows := make(map[string]SessionRow, len(transcripts))
+ for _, name := range transcripts {
+ transcript := filepath.Clean(strings.TrimSpace(name))
+ if transcript == "." || filepath.Base(transcript) != placeTranscript {
+ continue
+ }
+ if _, done := rows[transcript]; done {
+ continue
+ }
+ dir := filepath.Dir(transcript)
+ if row, ok := readSessionRow(dir, filepath.Base(dir), now); ok {
+ rows[transcript] = row
+ }
+ }
+ return rows
+}
+
// Adopt puts the conversation a window is sitting in into the world when the
// walk did not find it, and reports whether it had to.
//
diff --git a/internal/session/world_test.go b/internal/session/world_test.go
index d94086949a..78e2f2b148 100644
--- a/internal/session/world_test.go
+++ b/internal/session/world_test.go
@@ -1,6 +1,11 @@
package session
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
// A ROLLUP'S NEWEST MOMENT IS A LANDING MOMENT. Live rows contribute to the
// running and incomplete counts exactly as the conversation's presence says,
@@ -22,3 +27,58 @@ func TestARollupOfOnlyRunningRowsHasNoNewestMoment(t *testing.T) {
t.Fatalf("the rollup counts running/incomplete as %d/%d, want 1/1", got.Running, got.Incomplete)
}
}
+
+// ROWS BY NAME ARE THE WALK'S ROWS FOR THOSE NAMES AND NOTHING ELSE. Each named
+// conversation reads as [ReadWorld] reads it; a folder nobody spoke in, a name
+// that is not a session's journal and a folder that is not there are absent;
+// and the conversation beside them that nobody named is never read.
+func TestReadRowsReadsTheNamedConversationsAlone(t *testing.T) {
+ root := t.TempDir()
+ bucket := filepath.Join(root, "-work-alpha")
+ spoke := time.Now().Add(-time.Hour).Truncate(time.Second)
+ folder := func(id string, spoken bool) string {
+ dir := filepath.Join(bucket, id)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ transcript := filepath.Join(dir, placeTranscript)
+ if err := os.WriteFile(transcript, []byte("{}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ meta := Meta{ID: id, Title: "t " + id, Workspace: "/work/alpha", Created: spoke}
+ if spoken {
+ meta.LastUserAt = spoke
+ }
+ if err := SaveMeta(dir, meta); err != nil {
+ t.Fatal(err)
+ }
+ return transcript
+ }
+ named := folder("aaaa000000000001", true)
+ other := folder("aaaa000000000002", true)
+ empty := folder("aaaa000000000003", false)
+ gone := filepath.Join(bucket, "aaaa000000000004", placeTranscript)
+
+ rows := ReadRows([]string{named, " " + named + " ", empty, gone, filepath.Join(bucket, "notes.txt")})
+ if len(rows) != 1 {
+ t.Fatalf("read %d rows, want the one named conversation: %+v", len(rows), rows)
+ }
+ got, ok := rows[named]
+ if !ok {
+ t.Fatalf("the named conversation is not keyed by its transcript: %+v", rows)
+ }
+ var want SessionRow
+ for _, p := range ReadWorld(root).Projects {
+ for _, r := range p.Sessions {
+ if r.Transcript == named {
+ want = r
+ }
+ }
+ }
+ if got.ID != want.ID || got.Title != want.Title || !got.At.Equal(want.At) || got.Open != want.Open || got.Live != want.Live || got.Dir != want.Dir {
+ t.Fatalf("the row by name differs from the walk's:\n got %+v\nwant %+v", got, want)
+ }
+ if _, read := rows[other]; read {
+ t.Fatal("a conversation nobody named was read")
+ }
+}
diff --git a/internal/standing/standing.go b/internal/standing/standing.go
index 2617e60c0c..02292e71f1 100644
--- a/internal/standing/standing.go
+++ b/internal/standing/standing.go
@@ -77,6 +77,7 @@ import (
"strconv"
"strings"
"time"
+ "unicode/utf8"
)
// Schema is the document version every [Item] carries. Bump it when a field
@@ -140,8 +141,8 @@ const (
// WhenProbe fires when a probe's output, judged by the sentinel against
// the person's words, says yes. Anything the belt can do is a probe.
WhenProbe WhenKind = "probe"
- // WhenHold never wakes. A rule — "always use tabs here", "never touch the
- // public API" — has no moment, no rhythm and no probe: its whole work is
+ // WhenHold never wakes. A rule, "always use tabs here", "never touch the
+ // public API", has no moment, no rhythm and no probe: its whole work is
// done at birth, riding into the world of every conversation and task it
// reaches (docs/STANDING-ORDERS.md, the birth seam). The pass walks past
// it; it cannot fire, so it cannot spend, so it alone needs no rails and
@@ -149,6 +150,59 @@ const (
WhenHold WhenKind = "hold"
)
+// CardKind is what a proposal card calls this item. It is derived from the
+// when, because that is the fact a person can check: a moment is a reminder,
+// a rhythm is a repeating check, a condition is a watch, and a hold is a rule.
+type CardKind string
+
+const (
+ // CardReminder is one moment. Doing it now is not a smaller version of it.
+ CardReminder CardKind = "reminder"
+ // CardCheck repeats on a cadence.
+ CardCheck CardKind = "check"
+ // CardWatch waits on a condition or an event.
+ CardWatch CardKind = "watch"
+ // CardRule is kept, and never wakes.
+ CardRule CardKind = "rule"
+)
+
+// CardKindOf reports which card this item is. A when this build does not know
+// is read as a watch: it is something to look for, and the card says so.
+func (it Item) CardKindOf() CardKind {
+ switch it.When.Kind {
+ case WhenAt:
+ return CardReminder
+ case WhenEvery:
+ return CardCheck
+ case WhenHold:
+ return CardRule
+ default:
+ return CardWatch
+ }
+}
+
+// shortWordsRunes is how much of a cadence a button may carry. Past it the
+// label eats the row and the other answers disappear.
+const shortWordsRunes = 32
+
+// ShortWords is the cadence a button can carry. A long sentence is cut at a
+// word, because a label that fills the row leaves no room for the other answers.
+func (w When) ShortWords() string {
+ words := strings.TrimSpace(w.Words)
+ if words == "" || utf8.RuneCountInString(words) <= shortWordsRunes {
+ return words
+ }
+ runes := []rune(words)
+ cut := shortWordsRunes
+ for cut > 0 && runes[cut-1] != ' ' {
+ cut--
+ }
+ if cut == 0 {
+ cut = shortWordsRunes
+ }
+ return strings.TrimSpace(string(runes[:cut])) + "..."
+}
+
// When is what wakes an item. Exactly the fields its Kind names are read; the
// rest are left empty and never consulted. Words are always kept: they are the
// person's own cadence or condition, and every surface speaks them back rather
diff --git a/internal/teams/decision.go b/internal/teams/decision.go
new file mode 100644
index 0000000000..7b58ced36f
--- /dev/null
+++ b/internal/teams/decision.go
@@ -0,0 +1,1024 @@
+package teams
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "hash/fnv"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+)
+
+// ── DECISION PACKETS ────────────────────────────────────────────────────────
+//
+// Every decision that has to be made above the conversation that met it (a
+// conflict between parties, a question a manager escalates, a judgement call,
+// a cap reached, a closing report) travels as a SELF-CONTAINED PACKET (ruling
+// c-7): the question, the parties and what each of them said, the options each
+// with what happens if it is chosen, and a recommendation with its reason. The
+// same packet is answered by a manager or by the person, so either can decide
+// it without reading a transcript.
+//
+// WHERE IT LIVES. A packet is written in the file of the team it was raised
+// from (Origin), /teams//decisions.jsonl, and never moves:
+// escalating it changes who decides (Team) and adds a hop to its trail, and
+// the line that says so is appended to the same file. So a packet has one home
+// and one id for its life, and a sub-team's closed view can list everything
+// its members raised.
+//
+// THE FILE IS EVENTS, FOLDED BY ID. One JSON object per line, only ever
+// appended to: a `raise` carrying the whole packet, then `decide` or
+// `escalate` lines naming it by id. Reading folds them in file order into the
+// packet as it stands now. Nothing is rewritten, so a reader never sees a
+// half-written packet and a writer never loses another's line.
+//
+// EVERY WRITE IS UNDER THE FILE'S LOCK and re-reads the fold under it, so two
+// deciders cannot both decide one packet: the second is [ErrDecided]. A cap
+// raise is the same shape: under that lock the writer looks for a packet
+// already raised for the same pool, local day and ceiling, and a second
+// raiser, including one in another process, writes nothing and is handed the
+// packet that is already there. A later day, or the same day at a higher
+// ceiling, is a different crossing and a new packet.
+//
+// READS ARE STAT-FIRST AND INCREMENTAL, for traffic.go's reason: the teams
+// page asks about open packets on its clock. A file whose stamp has not moved
+// is answered from memory, and one that grew is read from the byte where the
+// last read stopped, never from the top. The scan across teams is one
+// directory listing and a stat per team.
+//
+// THE FILE ROTATES LIKE TRAFFIC, and keeps every packet still waiting. Past
+// [decisionsRotateBytes] the file is renamed to decisions.1.jsonl (replacing
+// the one before) and the new file opens with one `carry` line per packet
+// still waiting, the packet whole as it stands (its trail, its escalated
+// state). A reader folds the rotated file and then the current one, and a
+// carry replaces what the rotated file said of that id, so a waiting packet
+// is never lost to a rotation and a decided one stays readable for one
+// rotation more, which is long enough for its raiser to be handed the answer.
+//
+// EVERY CHANGE IS ALSO A LINE OF TRAFFIC, a [KindPacket] entry in the log of
+// the team that raised it, of every team that was asked to decide it, and of
+// every party's team, so the interface tailing a team's Traffic learns of a
+// packet without polling this file, and a session delivering Traffic hands it
+// to the manager and tells the parties.
+//
+// A CONFLICT'S RULING IS A DIRECTIVE TO EVERY PARTY (ruling c-6). Whoever
+// decides a [PacketConflict] (the lowest common manager with [Decide], a
+// manager above it after an escalation, or the person on the teams page, here
+// or over --host through the engine's own [Decide]), the store appends one
+// [KindDirective] to each party's team log, addressed to its handle and
+// carrying the packet's id ([Entry.Packet]). So the ruling reaches every party
+// by the road a directive always takes and wakes it, and it is in every
+// involved team's Traffic, whoever made it. And a party never decides its
+// own case: a manager who is one of the parties is not the decider even when
+// the packet waits on its team ([ErrNotDecider]); the person still may.
+
+// Packet kinds.
+const (
+ PacketQuestion = "question" // a question nobody below could answer
+ PacketConflict = "conflict" // parties disagree; raised by team_raise
+ PacketCap = "cap" // a team reached its daily cap
+ PacketJudgement = "judgement" // a call a manager will not make alone
+ PacketClosing = "closing" // a manager's closing report before a close
+)
+
+var packetKinds = map[string]bool{
+ PacketQuestion: true, PacketConflict: true, PacketCap: true, PacketJudgement: true, PacketClosing: true,
+}
+
+// Packet states. Escalated is still waiting: it is waiting at Team, which is
+// no longer where it was raised.
+const (
+ PacketOpen = "open"
+ PacketDecided = "decided"
+ PacketEscalated = "escalated"
+)
+
+// Person is the decider that is not a team: the person. It is the Team of a
+// packet waiting on them, the by of a decision they made, and a scope.
+const Person = "you"
+
+// ScopeAll is every packet still waiting, whoever decides it.
+const ScopeAll = ""
+
+// The option ids of a closing packet and a cap packet, which the interface
+// and the session both act on.
+const (
+ OptionClose = "close" // close the team
+ OptionCloseNow = "close-now" // close although the wrap-up did not finish
+ OptionKeepGoing = "keep-going" // do not close
+ OptionRaiseCap = "raise" // raise the cap (the option says to what)
+ OptionStopToday = "stop" // stop the team until tomorrow
+)
+
+// Party is one side of a packet: a conversation, its handle and team, and the
+// context it gave in its own words.
+type Party struct {
+ Key string `json:"key"`
+ Handle string `json:"handle,omitempty"`
+ Team string `json:"team,omitempty"`
+ Context string `json:"context,omitempty"`
+}
+
+// Option is one answer and what happens if it is chosen.
+type Option struct {
+ ID string `json:"id"`
+ Label string `json:"label"`
+ Consequence string `json:"consequence"`
+}
+
+// Recommendation is the option the raiser would choose, and why.
+type Recommendation struct {
+ Option string `json:"option"`
+ Reason string `json:"reason"`
+}
+
+// Hop is one escalation: from the decider it left to the one it went to.
+type Hop struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ By string `json:"by"`
+ Reason string `json:"reason,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// CapFacts is what a [PacketCap] packet says about the pool: whose cap it is
+// (Team, the pool's owner), the local day, the cap reached, what the pool had
+// spent when it was raised, and the figure the `raise` option raises it to
+// for the rest of that day. The session reads RaiseTo back when the person
+// picks `raise`, so the amount on the button is the amount that holds.
+type CapFacts struct {
+ Team string `json:"team"`
+ Day string `json:"day"`
+ CapUSD float64 `json:"cap_usd"`
+ SpentUSD float64 `json:"spent_usd"`
+ RaiseTo float64 `json:"raise_to"`
+}
+
+// ClosingReport is what a [PacketClosing] packet says about the team: what
+// was done, what is left, where the files are and what it spent. Incomplete
+// says the wrap-up ran out of time or money before it finished.
+type ClosingReport struct {
+ Done string `json:"done"`
+ Left string `json:"left,omitempty"`
+ Files []string `json:"files,omitempty"`
+ SpendUSD float64 `json:"spend_usd,omitempty"`
+ Incomplete bool `json:"incomplete,omitempty"`
+}
+
+// Packet is one decision, as it stands.
+type Packet struct {
+ // ID is minted by [Raise].
+ ID string `json:"id"`
+ // Team is who decides it now: a team id, whose manager decides, or
+ // [Person].
+ Team string `json:"team"`
+ // Origin is the team it was raised from, whose file holds it.
+ Origin string `json:"origin"`
+ // Kind is one of the Packet kinds.
+ Kind string `json:"kind"`
+ // RaisedBy is the raiser's handle, or [FromManager] or [Person].
+ RaisedBy string `json:"raised_by"`
+ // Parties are the sides, each with its own context. A question or a
+ // judgement may have one.
+ Parties []Party `json:"parties,omitempty"`
+ Question string `json:"question"`
+ Options []Option `json:"options,omitempty"`
+ // Recommendation is optional; when there is one it names an option.
+ Recommendation *Recommendation `json:"recommendation,omitempty"`
+ // Report is a closing packet's report, and nil on every other kind.
+ Report *ClosingReport `json:"report,omitempty"`
+ // Cap is a cap packet's facts, and nil on every other kind.
+ Cap *CapFacts `json:"cap,omitempty"`
+ // State is one of the Packet states.
+ State string `json:"state"`
+ // DecidedBy is the deciding manager's handle, or [Person].
+ DecidedBy string `json:"decided_by,omitempty"`
+ // Decision is the chosen option's id, or the person's own words when none
+ // fitted.
+ Decision string `json:"decision,omitempty"`
+ // Reason is the decider's reason, or the last escalation's.
+ Reason string `json:"reason,omitempty"`
+ // Trail is every escalation, oldest first.
+ Trail []Hop `json:"trail,omitempty"`
+ // Raised is when it was raised, At when it last changed.
+ Raised time.Time `json:"raised"`
+ At time.Time `json:"at"`
+}
+
+// Waiting reports whether the packet still needs a decision.
+func (p Packet) Waiting() bool { return p.State == PacketOpen || p.State == PacketEscalated }
+
+// Option is the option with id.
+func (p Packet) Option(id string) (Option, bool) {
+ for _, o := range p.Options {
+ if o.ID == id {
+ return o, true
+ }
+ }
+ return Option{}, false
+}
+
+// Errors a packet write can answer.
+var (
+ ErrDecided = errors.New("teams: that packet was already decided")
+ ErrNoPacket = errors.New("teams: no such packet")
+ ErrNotDecider = errors.New("teams: only the manager it waits on, or you, can decide or escalate it")
+ ErrSideways = errors.New("teams: a packet goes up the tree or to you, never sideways or down")
+ // errCapAlready is a cap raise whose crossing is already in the file. The
+ // packet on the event has been replaced with the one that was there, and
+ // nothing was written. It stays inside this package: [Raise] answers that
+ // packet and no error.
+ errCapAlready = errors.New("teams: that cap crossing was already raised")
+)
+
+// decisionEvent is one line of a packet file.
+type decisionEvent struct {
+ Op string `json:"op"`
+ At time.Time `json:"at"`
+ Packet *Packet `json:"packet,omitempty"`
+ ID string `json:"id,omitempty"`
+ By string `json:"by,omitempty"`
+ Decision string `json:"decision,omitempty"`
+ To string `json:"to,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+const (
+ opRaise = "raise"
+ opDecide = "decide"
+ opEscalate = "escalate"
+ // opCarry is a waiting packet written again, whole, at the head of a
+ // rotated file. It replaces what an older line said of that id.
+ opCarry = "carry"
+)
+
+// decisionsRotateBytes is the size past which a packet file starts a new one.
+// A packet is a few hundred bytes to a few kilobytes, so a megabyte is
+// hundreds of decisions; a var so a test can rotate in a few lines.
+var decisionsRotateBytes int64 = 1 << 20
+
+func decisionsRotated(path string) string {
+ return strings.TrimSuffix(path, ".jsonl") + ".1.jsonl"
+}
+
+// DecisionsPath is team teamID's packet file.
+func DecisionsPath(profileDir, teamID string) string {
+ return config.ProfilePath(profileDir, filepath.Join("teams", teamID, "decisions.jsonl"))
+}
+
+// ── WRITING ─────────────────────────────────────────────────────────────────
+
+// Raise records p as a new packet and answers it as written: with an id, open,
+// raised now. p.Team is who decides (a team id or [Person]; the caller finds
+// it, with [File.LCA] for parties or [File.Home] for a question), and
+// p.Origin the team it is raised from, which must be p.Team or a team under
+// it; an empty Origin is p.Team. An option with no id is given its place
+// ("1", "2", ...). The kind, the question, the raiser, and a label and a
+// consequence on every option are required; a question may have no options
+// (the answer is words), every other kind must have one.
+//
+// A CAP PACKET IS RAISED ONCE PER CROSSING. The crossing is the pool, the
+// local day and the ceiling ([capCrossing]). The check and the append happen
+// under the decisions file's lock, so two processes that meet the same
+// crossing write one line: the second is handed the packet already there, and
+// no second line of Traffic is written. Any other kind is a new packet every
+// time it is raised.
+func Raise(profileDir string, p Packet) (Packet, error) {
+ if !packetKinds[p.Kind] {
+ return Packet{}, fmt.Errorf("teams: %q is not a packet kind", p.Kind)
+ }
+ if strings.TrimSpace(p.Question) == "" || strings.TrimSpace(p.RaisedBy) == "" {
+ return Packet{}, errors.New("teams: a packet needs a question and who raised it")
+ }
+ if len(p.Options) == 0 && p.Kind != PacketQuestion {
+ return Packet{}, errors.New("teams: a packet needs its options, each with what happens")
+ }
+ p.Options = append([]Option(nil), p.Options...)
+ seen := map[string]bool{}
+ for i := range p.Options {
+ o := &p.Options[i]
+ if o.ID == "" {
+ o.ID = strconv.Itoa(i + 1)
+ }
+ if seen[o.ID] || strings.TrimSpace(o.Label) == "" || strings.TrimSpace(o.Consequence) == "" {
+ return Packet{}, errors.New("teams: every option needs its own id, a label and what happens if it is chosen")
+ }
+ seen[o.ID] = true
+ }
+ if r := p.Recommendation; r != nil && (!seen[r.Option] || strings.TrimSpace(r.Reason) == "") {
+ return Packet{}, errors.New("teams: a recommendation names one of the options and says why")
+ }
+ if p.Origin == "" {
+ p.Origin = p.Team
+ }
+ if err := safeTeamID(p.Origin); err != nil || p.Origin == Person {
+ return Packet{}, errors.New("teams: a packet is raised from a team")
+ }
+ f, err := Load(profileDir)
+ if err != nil {
+ return Packet{}, err
+ }
+ if _, ok := f.Team(p.Origin); !ok {
+ return Packet{}, fmt.Errorf("no team %s", p.Origin)
+ }
+ if p.Team != Person {
+ decider, ok := f.Team(p.Team)
+ if !ok || decider.Closed() {
+ return Packet{}, fmt.Errorf("teams: %q is not an open team to decide it", p.Team)
+ }
+ if p.Team != p.Origin && !isAncestor(f, p.Team, p.Origin) {
+ return Packet{}, ErrSideways
+ }
+ }
+ now := time.Now()
+ p.ID, p.State, p.Raised, p.At = "p"+NewID(), PacketOpen, now, now
+ p.DecidedBy, p.Decision, p.Reason, p.Trail = "", "", "", nil
+ err = appendDecision(profileDir, p.Origin, &decisionEvent{Op: opRaise, At: now, Packet: &p}, nil)
+ if errors.Is(err, errCapAlready) {
+ return p, nil
+ }
+ if err != nil {
+ return Packet{}, err
+ }
+ logPacket(profileDir, f, p, involved(p, p.Origin, p.Team),
+ fmt.Sprintf("%s raised a %s: %s", p.RaisedBy, p.Kind, p.Question))
+ return p, nil
+}
+
+// Decide records by's decision on packet id: an option's id, or the person's
+// own words. by is the handle of the manager of the team the packet waits on,
+// or [Person], who may decide any packet. A packet already decided is
+// [ErrDecided], and nothing is written.
+func Decide(profileDir, id, by, decision, reason string) (Packet, error) {
+ if strings.TrimSpace(decision) == "" {
+ return Packet{}, errors.New("teams: a decision needs an answer")
+ }
+ var out Packet
+ f, err := Load(profileDir)
+ if err != nil {
+ return Packet{}, err
+ }
+ origin, err := packetOrigin(profileDir, id)
+ if err != nil {
+ return Packet{}, err
+ }
+ now := time.Now()
+ e := decisionEvent{Op: opDecide, At: now, ID: id, Decision: decision, Reason: reason}
+ err = appendDecision(profileDir, origin, &e, func(p Packet) error {
+ if !p.Waiting() {
+ return ErrDecided
+ }
+ who, ok := mayDecide(f, p, by)
+ if !ok {
+ return ErrNotDecider
+ }
+ out, e.By = p, who
+ return nil
+ })
+ if err != nil {
+ return Packet{}, err
+ }
+ out = foldDecide(out, e)
+ word := decision
+ if o, ok := out.Option(decision); ok {
+ word = o.Label
+ }
+ // A QUESTION'S ANSWER READS AS ONE on the rail: `answered @web: JSON`,
+ // which the interface draws after the decider's mark.
+ said := fmt.Sprintf("%s decided: %s", e.By, word)
+ if out.Kind == PacketQuestion {
+ said = fmt.Sprintf("answered %s: %s", raiserWord(out.RaisedBy), word)
+ }
+ logPacket(profileDir, f, out, involved(out, out.Origin, out.Team), said)
+ if out.Kind == PacketConflict {
+ rule(profileDir, f, out)
+ }
+ return out, nil
+}
+
+// Escalate sends packet id up: to an open team above the one it waits on, or
+// to [Person]. by is as for [Decide]. Down, sideways or to a closed team is
+// [ErrSideways]; a decided packet is [ErrDecided].
+func Escalate(profileDir, id, by, to, reason string) (Packet, error) {
+ var out Packet
+ f, err := Load(profileDir)
+ if err != nil {
+ return Packet{}, err
+ }
+ origin, err := packetOrigin(profileDir, id)
+ if err != nil {
+ return Packet{}, err
+ }
+ now := time.Now()
+ from := ""
+ e := decisionEvent{Op: opEscalate, At: now, ID: id, To: to, Reason: reason}
+ err = appendDecision(profileDir, origin, &e, func(p Packet) error {
+ if !p.Waiting() {
+ return ErrDecided
+ }
+ who, ok := mayDecide(f, p, by)
+ if !ok {
+ return ErrNotDecider
+ }
+ e.By = who
+ if p.Team == Person {
+ return ErrSideways
+ }
+ if to != Person {
+ t, ok := f.Team(to)
+ if !ok || t.Closed() || !isAncestor(f, to, p.Team) {
+ return ErrSideways
+ }
+ }
+ out, from = p, p.Team
+ return nil
+ })
+ if err != nil {
+ return Packet{}, err
+ }
+ out = foldEscalate(out, e)
+ where := "you"
+ if t, ok := f.Team(to); ok {
+ where = "◆ " + t.Name
+ }
+ logPacket(profileDir, f, out, involved(out, out.Origin, from, to), fmt.Sprintf("%s sent it up to %s: %s", e.By, where, reason))
+ return out, nil
+}
+
+// raiserWord is how a raiser is named in a Traffic line: @handle for a
+// member, the word itself for manager, you or system.
+func raiserWord(by string) string {
+ switch by {
+ case FromManager, FromSystem, Person:
+ return by
+ }
+ return "@" + strings.TrimPrefix(by, "@")
+}
+
+// mayDecide reports whether by may decide or escalate p, and the name it is
+// recorded under: [Person], or the handle of the manager p waits on, which
+// by may give as its handle or as [FromManager].
+func mayDecide(f *File, p Packet, by string) (string, bool) {
+ if by == Person {
+ return Person, true
+ }
+ t, ok := f.Team(p.Team)
+ if !ok || t.Closed() || t.Manager == "" || by == "" {
+ return "", false
+ }
+ m, ok := t.Member(t.Manager)
+ if !ok || (m.Handle != by && by != FromManager) {
+ return "", false
+ }
+ for _, party := range p.Parties {
+ if party.Key != "" && party.Key == t.Manager {
+ return "", false
+ }
+ }
+ if m.Handle != "" {
+ return m.Handle, true
+ }
+ return FromManager, true
+}
+
+// involved is teams followed by every party's team: the logs a packet line
+// goes to.
+func involved(p Packet, teams ...string) []string {
+ for _, party := range p.Parties {
+ teams = append(teams, party.Team)
+ }
+ return teams
+}
+
+// rule appends a decided conflict's ruling to every party's team log, as a
+// directive to that party. A party with no team or a team no longer in the
+// file is skipped; a log that cannot be written costs that line and never the
+// decision, which is written already.
+func rule(profileDir string, f *File, p Packet) {
+ from, by := FromManager, "◆ @"+p.DecidedBy
+ switch p.DecidedBy {
+ case Person:
+ from, by = FromYou, "the person"
+ case FromManager, "":
+ by = "the manager"
+ }
+ if t, ok := f.Team(p.Team); ok && p.DecidedBy != Person {
+ by += fmt.Sprintf(" (manager of %q)", t.Name)
+ }
+ word := p.Decision
+ if o, ok := p.Option(p.Decision); ok {
+ word = o.Label + ": " + o.Consequence
+ }
+ text := fmt.Sprintf("ruling on the conflict %s, by %s: %s", p.ID, by, word)
+ if r := strings.TrimSpace(p.Reason); r != "" {
+ text += ". Because: " + r
+ }
+ text += ". The conflict was: " + p.Question
+ for _, party := range p.Parties {
+ if party.Team == "" {
+ continue
+ }
+ if _, ok := f.Team(party.Team); !ok {
+ continue
+ }
+ to := party.Handle
+ if to == "" {
+ to = ToRoom
+ }
+ _ = AppendTraffic(profileDir, party.Team, Entry{Kind: KindDirective, From: from, To: to, Member: party.Key,
+ Text: text, Packet: p.ID, State: PacketDecided})
+ }
+}
+
+// IsRuling reports whether e is a conflict's ruling ([rule]): a directive
+// that carries its packet's id. A session delivers it to the party it names
+// whoever wrote it, and it wakes that party.
+func IsRuling(e Entry) bool { return e.Kind == KindDirective && e.Packet != "" }
+
+// isAncestor reports whether team above is an ancestor of team id.
+func isAncestor(f *File, above, id string) bool {
+ for _, a := range f.Ancestors(id) {
+ if a.ID == above {
+ return true
+ }
+ }
+ return false
+}
+
+// packetOrigin is the team whose file holds packet id.
+func packetOrigin(profileDir, id string) (string, error) {
+ all, err := allPackets(profileDir)
+ if err != nil {
+ return "", err
+ }
+ for _, p := range all {
+ if p.ID == id {
+ return p.Origin, nil
+ }
+ }
+ return "", ErrNoPacket
+}
+
+// capCrossing is the identity of one cap ask: the pool, the local day, and the
+// ceiling that was crossed. What had been spent, and the figure a raise would
+// lift the ceiling to, are facts of that ask and not part of its identity, so
+// two processes that meet the same ceiling write one packet. An empty key is
+// a packet this rule does not apply to.
+func capCrossing(p *Packet) (string, bool) {
+ if p == nil || p.Kind != PacketCap || p.Cap == nil || p.Cap.Team == "" || p.Cap.Day == "" {
+ return "", false
+ }
+ return p.Cap.Team + "\x00" + p.Cap.Day + "\x00" + strconv.FormatFloat(p.Cap.CapUSD, 'f', -1, 64), true
+}
+
+// capAlready is the packet already raised for p's crossing, read from path
+// under the decisions file's lock. The caller holds that lock.
+func capAlready(path string, p *Packet) (Packet, bool) {
+ key, ok := capCrossing(p)
+ if !ok {
+ return Packet{}, false
+ }
+ folded, err := packetCache.read(path)
+ if err != nil {
+ return Packet{}, false
+ }
+ for _, have := range folded.list() {
+ if got, ok := capCrossing(&have); ok && got == key {
+ return have, true
+ }
+ }
+ return Packet{}, false
+}
+
+// appendDecision appends e to team teamID's packet file under its lock. check,
+// when given, is handed the packet e names as it stands under the lock, may
+// fill in e, and an error from it writes nothing.
+func appendDecision(profileDir, teamID string, e *decisionEvent, check func(Packet) error) error {
+ if err := safeTeamID(teamID); err != nil {
+ return err
+ }
+ path := DecisionsPath(profileDir, teamID)
+ return lockedAt(strings.TrimSuffix(path, ".jsonl")+".lock", lockWait, func() error {
+ // A CAP CROSSING IS ONE LINE. The fold is read under this lock, after
+ // any other raiser has either written or not, so the second process
+ // sees the first's packet and leaves the file alone.
+ if e.Op == opRaise && e.Packet != nil {
+ if existing, ok := capAlready(path, e.Packet); ok {
+ *e.Packet = existing
+ return errCapAlready
+ }
+ }
+ if check != nil {
+ packets, err := packetCache.read(path)
+ if err != nil {
+ return err
+ }
+ p, ok := packets.byID[e.ID]
+ if !ok {
+ return ErrNoPacket
+ }
+ if err := check(p); err != nil {
+ return err
+ }
+ }
+ line, err := json.Marshal(e)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ if info, err := os.Stat(path); err == nil && info.Size() > 0 && info.Size()+int64(len(line))+1 > decisionsRotateBytes {
+ if err := rotateDecisions(path); err != nil {
+ return err
+ }
+ }
+ before := modTime(path)
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ return err
+ }
+ if _, err := file.Write(append(line, '\n')); err != nil {
+ _ = file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ advance(path, before)
+ return nil
+ })
+}
+
+// rotateDecisions starts a new packet file at path, under its lock: the fold
+// as it stands is taken, the file is renamed over the rotated one, and the new
+// file is written (temporary file and rename) with a carry line for every
+// packet still waiting, so nothing waiting lives only in the rotated file.
+func rotateDecisions(path string) error {
+ now, err := packetCache.read(path)
+ if err != nil {
+ return err
+ }
+ var carry bytes.Buffer
+ for _, p := range now.list() {
+ if !p.Waiting() {
+ continue
+ }
+ p := p
+ line, err := json.Marshal(decisionEvent{Op: opCarry, At: p.At, Packet: &p})
+ if err != nil {
+ return err
+ }
+ carry.Write(line)
+ carry.WriteByte('\n')
+ }
+ if err := os.Rename(path, decisionsRotated(path)); err != nil {
+ return err
+ }
+ if carry.Len() == 0 {
+ return nil
+ }
+ temp, err := os.CreateTemp(filepath.Dir(path), ".decisions-*")
+ if err != nil {
+ return err
+ }
+ name := temp.Name()
+ if _, err := temp.Write(carry.Bytes()); err != nil {
+ _ = temp.Close()
+ _ = os.Remove(name)
+ return err
+ }
+ if err := temp.Close(); err != nil {
+ _ = os.Remove(name)
+ return err
+ }
+ return os.Rename(name, path)
+}
+
+// logPacket appends a [KindPacket] line to the Traffic of each distinct team
+// in teams that is a team (not [Person], not ""). A log that cannot be written
+// costs the line and never the packet, which is written already.
+func logPacket(profileDir string, f *File, p Packet, teams []string, text string) {
+ seen := map[string]bool{}
+ for _, id := range teams {
+ if id == "" || id == Person || seen[id] {
+ continue
+ }
+ seen[id] = true
+ if _, ok := f.Team(id); !ok {
+ continue
+ }
+ _ = AppendTraffic(profileDir, id, Entry{Kind: KindPacket, From: FromSystem, To: ToManager,
+ Text: text, Packet: p.ID, State: p.State})
+ }
+}
+
+// ── READING ─────────────────────────────────────────────────────────────────
+
+// OpenPackets is every packet waiting on scope (a team id, [Person], or
+// [ScopeAll] for every waiting packet), oldest first, and the stamp of the
+// packet files it was read from: equal stamps are the same answer, so a
+// reader can hand the stamp back and be told nothing moved ([PacketsStamp]).
+func OpenPackets(profileDir, scope string) ([]Packet, string, error) {
+ stamp := PacketsStamp(profileDir)
+ all, err := allPackets(profileDir)
+ if err != nil {
+ return nil, stamp, err
+ }
+ var out []Packet
+ for _, p := range all {
+ if p.Waiting() && (scope == ScopeAll || p.Team == scope) {
+ out = append(out, p)
+ }
+ }
+ return out, stamp, nil
+}
+
+// Packets is every packet raised from team teamID, decided or not, oldest
+// first: a team's history, and its closing reports.
+func Packets(profileDir, teamID string) ([]Packet, error) {
+ if err := safeTeamID(teamID); err != nil {
+ return nil, err
+ }
+ got, err := packetCache.read(DecisionsPath(profileDir, teamID))
+ if err != nil {
+ return nil, err
+ }
+ return got.list(), nil
+}
+
+// PacketByID is packet id as it stands.
+func PacketByID(profileDir, id string) (Packet, error) {
+ all, err := allPackets(profileDir)
+ if err != nil {
+ return Packet{}, err
+ }
+ for _, p := range all {
+ if p.ID == id {
+ return p, nil
+ }
+ }
+ return Packet{}, ErrNoPacket
+}
+
+// PacketsStamp is one stamp over every team's packet file: one directory
+// listing and a stat per team. It moves when any packet file is written,
+// made or removed.
+func PacketsStamp(profileDir string) string {
+ files := packetFiles(profileDir)
+ if len(files) == 0 {
+ return MissingStamp
+ }
+ h := fnv.New64a()
+ for _, path := range files {
+ _, _ = io.WriteString(h, path)
+ _, _ = io.WriteString(h, "=")
+ _, _ = io.WriteString(h, stampOf(path))
+ _, _ = io.WriteString(h, "\n")
+ }
+ return strconv.FormatUint(h.Sum64(), 36) + "." + strconv.Itoa(len(files))
+}
+
+// packetFiles is every team directory's packet file that exists, sorted.
+func packetFiles(profileDir string) []string {
+ root := config.ProfilePath(profileDir, "teams")
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range entries {
+ if !e.IsDir() || safeTeamID(e.Name()) != nil {
+ continue
+ }
+ path := filepath.Join(root, e.Name(), "decisions.jsonl")
+ if _, err := os.Stat(path); err == nil {
+ out = append(out, path)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// allPackets is every packet in every team's file, oldest raised first.
+func allPackets(profileDir string) ([]Packet, error) {
+ var out []Packet
+ for _, path := range packetFiles(profileDir) {
+ got, err := packetCache.read(path)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, got.list()...)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].Raised.Before(out[j].Raised) })
+ return out, nil
+}
+
+// ── THE FOLD, AND ITS MEMORY ────────────────────────────────────────────────
+
+// folded is one packet file as read so far: the rotated file whole (as it
+// was at rotated), then the current file up to offset.
+type folded struct {
+ stamp string
+ rotated string
+ offset int64
+ order []string
+ byID map[string]Packet
+}
+
+func (f *folded) list() []Packet {
+ out := make([]Packet, 0, len(f.order))
+ for _, id := range f.order {
+ out = append(out, clonePacket(f.byID[id]))
+ }
+ return out
+}
+
+// apply folds one event in.
+func (f *folded) apply(e decisionEvent) {
+ switch e.Op {
+ case opRaise:
+ if e.Packet == nil || e.Packet.ID == "" {
+ return
+ }
+ if _, dup := f.byID[e.Packet.ID]; dup {
+ return
+ }
+ f.order = append(f.order, e.Packet.ID)
+ f.byID[e.Packet.ID] = clonePacket(*e.Packet)
+ case opCarry:
+ if e.Packet == nil || e.Packet.ID == "" {
+ return
+ }
+ if _, known := f.byID[e.Packet.ID]; !known {
+ f.order = append(f.order, e.Packet.ID)
+ }
+ f.byID[e.Packet.ID] = clonePacket(*e.Packet)
+ case opDecide:
+ if p, ok := f.byID[e.ID]; ok && p.Waiting() {
+ f.byID[e.ID] = foldDecide(p, e)
+ }
+ case opEscalate:
+ if p, ok := f.byID[e.ID]; ok && p.Waiting() {
+ f.byID[e.ID] = foldEscalate(p, e)
+ }
+ }
+}
+
+func foldDecide(p Packet, e decisionEvent) Packet {
+ p.State, p.DecidedBy, p.Decision, p.Reason, p.At = PacketDecided, e.By, e.Decision, e.Reason, e.At
+ return p
+}
+
+func foldEscalate(p Packet, e decisionEvent) Packet {
+ p.Trail = append(append([]Hop(nil), p.Trail...), Hop{From: p.Team, To: e.To, By: e.By, Reason: e.Reason, At: e.At})
+ p.State, p.Team, p.Reason, p.At = PacketEscalated, e.To, e.Reason, e.At
+ return p
+}
+
+func clonePacket(p Packet) Packet {
+ p.Parties = append([]Party(nil), p.Parties...)
+ p.Options = append([]Option(nil), p.Options...)
+ p.Trail = append([]Hop(nil), p.Trail...)
+ if p.Recommendation != nil {
+ r := *p.Recommendation
+ p.Recommendation = &r
+ }
+ if p.Report != nil {
+ r := *p.Report
+ r.Files = append([]string(nil), r.Files...)
+ p.Report = &r
+ }
+ if p.Cap != nil {
+ c := *p.Cap
+ p.Cap = &c
+ }
+ return p
+}
+
+// packetMemory is every packet file read so far, by path, shared by every
+// caller in the process (the engine answers several windows from it).
+type packetMemory struct {
+ mu sync.Mutex
+ files map[string]*folded
+ // reads counts the bytes read, for a test to see a quiet file costs none.
+ reads int64
+}
+
+var packetCache packetMemory
+
+// forgetPackets drops what is remembered of profileDir's files, after a
+// delete removed some.
+func forgetPackets(profileDir string) {
+ prefix := config.ProfilePath(profileDir, "teams") + string(filepath.Separator)
+ packetCache.mu.Lock()
+ defer packetCache.mu.Unlock()
+ for path := range packetCache.files {
+ if strings.HasPrefix(path, prefix) {
+ delete(packetCache.files, path)
+ }
+ }
+}
+
+// read is the file at path folded: from memory when its stamp has not moved,
+// from where the last read stopped when it grew, and from the top when it is
+// new to this process or shrank. The answer is a copy.
+func (m *packetMemory) read(path string) (*folded, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.files == nil {
+ m.files = map[string]*folded{}
+ }
+ stamp := stampOf(path)
+ rotated := stampOf(decisionsRotated(path))
+ f := m.files[path]
+ if f != nil && f.stamp == stamp && f.rotated == rotated {
+ return f.copy(), nil
+ }
+ if stamp == MissingStamp && rotated == MissingStamp {
+ delete(m.files, path)
+ return &folded{byID: map[string]Packet{}}, nil
+ }
+ size := int64(0)
+ if stamp != MissingStamp {
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, err
+ }
+ size = info.Size()
+ }
+ // FROM THE TOP when this process has not read it, when it shrank, or when
+ // the rotated file moved (a rotation happened): the rotated file whole,
+ // then the current one.
+ if f == nil || size < f.offset || f.rotated != rotated {
+ f = &folded{byID: map[string]Packet{}}
+ if rotated != MissingStamp {
+ if _, err := m.foldFrom(decisionsRotated(path), 0, f); err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ }
+ f.offset = 0
+ }
+ if stamp != MissingStamp {
+ end, err := m.foldFrom(path, f.offset, f)
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ f.offset = end
+ }
+ // A line still being written has no newline yet; it is read next time,
+ // from the offset that stops before it.
+ f.stamp, f.rotated = stamp, rotated
+ m.files[path] = f
+ return f.copy(), nil
+}
+
+// foldFrom folds the complete lines of the file at path from offset into f,
+// and answers the offset after the last complete line.
+func (m *packetMemory) foldFrom(path string, offset int64, f *folded) (int64, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return offset, err
+ }
+ defer file.Close()
+ if _, err := file.Seek(offset, io.SeekStart); err != nil {
+ return offset, err
+ }
+ r := bufio.NewReader(file)
+ for {
+ line, err := r.ReadBytes('\n')
+ if len(line) > 0 && line[len(line)-1] == '\n' {
+ m.reads += int64(len(line))
+ offset += int64(len(line))
+ var e decisionEvent
+ if json.Unmarshal(bytes.TrimSpace(line), &e) == nil {
+ f.apply(e)
+ }
+ }
+ if err == io.EOF {
+ return offset, nil
+ }
+ if err != nil {
+ return offset, err
+ }
+ }
+}
+
+func (f *folded) copy() *folded {
+ out := &folded{stamp: f.stamp, rotated: f.rotated, offset: f.offset, order: append([]string(nil), f.order...),
+ byID: make(map[string]Packet, len(f.byID))}
+ for id, p := range f.byID {
+ out.byID[id] = p
+ }
+ return out
+}
diff --git a/internal/teams/decision_test.go b/internal/teams/decision_test.go
new file mode 100644
index 0000000000..b05518111b
--- /dev/null
+++ b/internal/teams/decision_test.go
@@ -0,0 +1,278 @@
+package teams
+
+import (
+ "errors"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// packetTeams is harbor (managed by @boss) > dock (managed by @lead), both
+// with members, saved in a fresh profile.
+func packetTeams(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ teams := []Team{
+ {ID: "aaaaaaaaaaaa", Name: "harbor", Manager: "hm",
+ Members: []Member{{Key: "hm", Handle: "boss"}, {Key: "dm", Handle: "lead"}}},
+ {ID: "bbbbbbbbbbbb", Name: "dock", Parent: "aaaaaaaaaaaa", Manager: "dm",
+ Members: []Member{{Key: "dm", Handle: "lead"}, {Key: "w1", Handle: "web"}, {Key: "w2", Handle: "api"}}},
+ }
+ must(t, Save(dir, teams))
+ return dir
+}
+
+func conflict() Packet {
+ return Packet{
+ Team: "bbbbbbbbbbbb", Kind: PacketConflict, RaisedBy: "web",
+ Parties: []Party{
+ {Key: "w1", Handle: "web", Team: "bbbbbbbbbbbb", Context: "the form posts JSON"},
+ {Key: "w2", Handle: "api", Team: "bbbbbbbbbbbb", Context: "the endpoint takes form data"},
+ },
+ Question: "which shape does the signup form send?",
+ Options: []Option{
+ {Label: "JSON", Consequence: "@api changes the handler; the form stays"},
+ {Label: "form data", Consequence: "@web rewrites the submit; the handler stays"},
+ },
+ Recommendation: &Recommendation{Option: "1", Reason: "the other endpoints all take JSON"},
+ }
+}
+
+// A PACKET IS RAISED, DECIDED ONCE, AND READ BACK FOLDED. The raise mints the
+// id and numbers the options; the open list for dock holds it; a decision by
+// dock's manager closes it; a second decision is refused and writes nothing;
+// and the file is events, not a rewritten record.
+func TestAPacketIsRaisedDecidedOnceAndFolded(t *testing.T) {
+ dir := packetTeams(t)
+ p, err := Raise(dir, conflict())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(p.ID, "p") || p.State != PacketOpen || p.Origin != "bbbbbbbbbbbb" || p.Options[1].ID != "2" {
+ t.Fatalf("raised %+v", p)
+ }
+ open, stamp, err := OpenPackets(dir, "bbbbbbbbbbbb")
+ if err != nil || len(open) != 1 || open[0].ID != p.ID || stamp == MissingStamp {
+ t.Fatalf("open for dock: %+v %q %v", open, stamp, err)
+ }
+ if mine, _, _ := OpenPackets(dir, Person); len(mine) != 0 {
+ t.Fatal("a packet for dock's manager waits on the person")
+ }
+ if _, err := Decide(dir, p.ID, "web", "1", "mine"); !errors.Is(err, ErrNotDecider) {
+ t.Fatalf("a member decided: %v", err)
+ }
+ got, err := Decide(dir, p.ID, FromManager, "1", "matches the rest")
+ if err != nil || got.State != PacketDecided || got.DecidedBy != "lead" || got.Decision != "1" {
+ t.Fatalf("decided %+v, %v", got, err)
+ }
+ if _, err := Decide(dir, p.ID, Person, "2", "late"); !errors.Is(err, ErrDecided) {
+ t.Fatalf("a second decision: %v", err)
+ }
+ back, err := PacketByID(dir, p.ID)
+ if err != nil || back.Decision != "1" || back.Reason != "matches the rest" {
+ t.Fatalf("read back %+v, %v", back, err)
+ }
+ if open, next, _ := OpenPackets(dir, "bbbbbbbbbbbb"); len(open) != 0 || next == stamp {
+ t.Fatalf("a decided packet is still open (%d) or the stamp did not move", len(open))
+ }
+ raw, _ := os.ReadFile(DecisionsPath(dir, "bbbbbbbbbbbb"))
+ if lines := strings.Count(string(raw), "\n"); lines != 2 {
+ t.Fatalf("the file has %d lines, want a raise and a decide:\n%s", lines, raw)
+ }
+ // And every change is a line of Traffic, and a conflict's ruling is a
+ // directive to each of its parties (both in dock here).
+ entries, _ := ReadTraffic(dir, "bbbbbbbbbbbb", "", 0)
+ if len(entries) != 4 || entries[0].Kind != KindPacket || entries[1].State != PacketDecided || entries[1].Packet != p.ID ||
+ !IsRuling(entries[2]) || entries[2].To != "web" || !IsRuling(entries[3]) || entries[3].To != "api" {
+ t.Fatalf("traffic %+v", entries)
+ }
+}
+
+// ESCALATION GOES UP OR TO THE PERSON, NEVER SIDEWAYS, AND KEEPS ITS TRAIL.
+func TestAPacketEscalatesUpAndToThePersonOnly(t *testing.T) {
+ dir := packetTeams(t)
+ p, err := Raise(dir, conflict())
+ must(t, err)
+ if _, err := Escalate(dir, p.ID, "lead", "bbbbbbbbbbbb", "self"); !errors.Is(err, ErrSideways) {
+ t.Fatalf("escalated to itself: %v", err)
+ }
+ up, err := Escalate(dir, p.ID, "lead", "aaaaaaaaaaaa", "it touches billing too")
+ if err != nil || up.State != PacketEscalated || up.Team != "aaaaaaaaaaaa" || len(up.Trail) != 1 ||
+ up.Trail[0].From != "bbbbbbbbbbbb" || up.Trail[0].By != "lead" {
+ t.Fatalf("escalated %+v, %v", up, err)
+ }
+ if open, _, _ := OpenPackets(dir, "aaaaaaaaaaaa"); len(open) != 1 {
+ t.Fatal("harbor's manager does not see the escalated packet")
+ }
+ if _, err := Decide(dir, p.ID, "lead", "1", ""); !errors.Is(err, ErrNotDecider) {
+ t.Fatalf("the manager it left decided it: %v", err)
+ }
+ mine, err := Escalate(dir, p.ID, "boss", Person, "a judgement call")
+ if err != nil || mine.Team != Person || len(mine.Trail) != 2 {
+ t.Fatalf("to the person: %+v %v", mine, err)
+ }
+ if _, err := Escalate(dir, p.ID, Person, "aaaaaaaaaaaa", "back down"); !errors.Is(err, ErrSideways) {
+ t.Fatalf("a packet at the person went back down: %v", err)
+ }
+ if all, _, _ := OpenPackets(dir, ScopeAll); len(all) != 1 {
+ t.Fatal("the all scope lost it")
+ }
+ done, err := Decide(dir, p.ID, Person, "keep both, add a shim", "neither side should move today")
+ if err != nil || done.DecidedBy != Person || done.Decision != "keep both, add a shim" {
+ t.Fatalf("the person's own words: %+v %v", done, err)
+ }
+ // The escalated packet is logged in every team it passed through.
+ if e, _ := ReadTraffic(dir, "aaaaaaaaaaaa", "", 0); len(e) < 2 {
+ t.Fatalf("harbor's traffic has %d packet lines", len(e))
+ }
+}
+
+// A PACKET IS COMPLETE OR REFUSED: every option says what happens, a
+// recommendation names an option and a reason, it is raised from the deciding
+// team or below it, and a question may have no options.
+func TestARaiseRefusesAnIncompletePacket(t *testing.T) {
+ dir := packetTeams(t)
+ for name, bad := range map[string]func(*Packet){
+ "no kind": func(p *Packet) { p.Kind = "gossip" },
+ "no question": func(p *Packet) { p.Question = " " },
+ "no raiser": func(p *Packet) { p.RaisedBy = "" },
+ "no options": func(p *Packet) { p.Options = nil },
+ "no consequence": func(p *Packet) { p.Options[0].Consequence = "" },
+ "unknown recommendation": func(p *Packet) { p.Recommendation.Option = "9" },
+ "no reason": func(p *Packet) { p.Recommendation.Reason = "" },
+ "raised from above": func(p *Packet) { p.Origin = "aaaaaaaaaaaa" },
+ "decider unknown": func(p *Packet) { p.Team = "eeeeeeeeeeee"; p.Origin = "bbbbbbbbbbbb" },
+ } {
+ p := conflict()
+ p.Options = append([]Option(nil), p.Options...)
+ r := *p.Recommendation
+ p.Recommendation = &r
+ bad(&p)
+ if _, err := Raise(dir, p); err == nil {
+ t.Errorf("%s: raised", name)
+ }
+ }
+ q := Packet{Team: Person, Origin: "aaaaaaaaaaaa", Kind: PacketQuestion, RaisedBy: "boss",
+ Question: "ship on Friday or Monday?"}
+ if _, err := Raise(dir, q); err != nil {
+ t.Fatalf("a bare question to the person: %v", err)
+ }
+ if mine, _, _ := OpenPackets(dir, Person); len(mine) != 1 {
+ t.Fatal("the person's inbox is empty")
+ }
+}
+
+// A CLOSING PACKET CARRIES ITS REPORT and the close options by their ids.
+func TestAClosingPacketCarriesItsReport(t *testing.T) {
+ dir := packetTeams(t)
+ p, err := Raise(dir, Packet{Team: Person, Origin: "bbbbbbbbbbbb", Kind: PacketClosing, RaisedBy: "lead",
+ Question: "close dock?",
+ Report: &ClosingReport{Done: "signup ships", Left: "the shim", Files: []string{"web/signup.ts"}, SpendUSD: 3.2},
+ Options: []Option{
+ {ID: OptionClose, Label: "Close", Consequence: "dock moves to Closed; members stop"},
+ {ID: OptionKeepGoing, Label: "Keep going", Consequence: "nothing changes"},
+ },
+ Recommendation: &Recommendation{Option: OptionClose, Reason: "the work is merged"}})
+ must(t, err)
+ got, _ := Packets(dir, "bbbbbbbbbbbb")
+ if len(got) != 1 || got[0].Report == nil || got[0].Report.Files[0] != "web/signup.ts" || got[0].ID != p.ID {
+ t.Fatalf("the report did not survive: %+v", got)
+ }
+}
+
+// A QUIET PACKET FILE COSTS A STAT, AND A GROWN ONE IS READ FROM WHERE THE
+// LAST READ STOPPED.
+func TestPacketReadsAreIncremental(t *testing.T) {
+ dir := packetTeams(t)
+ _, err := Raise(dir, conflict())
+ must(t, err)
+ _, _, _ = OpenPackets(dir, ScopeAll)
+ before := packetCache.bytes()
+ _, _, _ = OpenPackets(dir, ScopeAll)
+ if packetCache.bytes() != before {
+ t.Fatal("a quiet file was read again")
+ }
+ size := fileSize(t, DecisionsPath(dir, "bbbbbbbbbbbb"))
+ _, err = Raise(dir, conflict())
+ must(t, err)
+ _, _, _ = OpenPackets(dir, ScopeAll)
+ grew := fileSize(t, DecisionsPath(dir, "bbbbbbbbbbbb")) - size
+ if got := packetCache.bytes() - before; got != grew {
+ t.Fatalf("a grown file read %d bytes, want the %d appended", got, grew)
+ }
+}
+
+// capAsk is one crossing: dock's pool, one local day, one ceiling.
+func capAsk(day string, ceiling float64) Packet {
+ return Packet{
+ Team: Person, Origin: "bbbbbbbbbbbb", Kind: PacketCap, RaisedBy: FromSystem,
+ Question: "dock reached its cap today",
+ Options: []Option{
+ {ID: OptionRaiseCap, Label: "Raise", Consequence: "dock goes on today"},
+ {ID: OptionStopToday, Label: "Stop for today", Consequence: "nothing new starts until tomorrow"},
+ },
+ Cap: &CapFacts{Team: "bbbbbbbbbbbb", Day: day, CapUSD: ceiling, SpentUSD: ceiling + 0.2, RaiseTo: ceiling * 2},
+ }
+}
+
+// TWO RAISERS OF ONE CROSSING WRITE ONE PACKET. Each goroutine is its own
+// raiser on the same directory, the way two processes are: they share no
+// packet in memory, only the file, and the second must find the first's line
+// under the file lock and write nothing. A later day is a different crossing
+// and writes a second packet, and so is the same day at a higher ceiling.
+func TestACapCrossingIsRaisedOnceAcrossRaisers(t *testing.T) {
+ dir := packetTeams(t)
+ var wg sync.WaitGroup
+ got := make([]Packet, 2)
+ errs := make([]error, 2)
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ got[i], errs[i] = Raise(dir, capAsk("2026-09-24", 5))
+ }(i)
+ }
+ wg.Wait()
+ for i, err := range errs {
+ if err != nil {
+ t.Fatalf("raiser %d: %v", i, err)
+ }
+ }
+ if got[0].ID == "" || got[0].ID != got[1].ID {
+ t.Fatalf("two raisers wrote two packets: %s and %s", got[0].ID, got[1].ID)
+ }
+ list, err := Packets(dir, "bbbbbbbbbbbb")
+ if err != nil || len(list) != 1 || list[0].Kind != PacketCap || list[0].ID != got[0].ID {
+ t.Fatalf("the file holds %+v (%v)", list, err)
+ }
+ raw, _ := os.ReadFile(DecisionsPath(dir, "bbbbbbbbbbbb"))
+ if lines := strings.Count(string(raw), "\n"); lines != 1 {
+ t.Fatalf("the file has %d lines, want one raise:\n%s", lines, raw)
+ }
+ next, err := Raise(dir, capAsk("2026-09-25", 5))
+ if err != nil || next.ID == got[0].ID {
+ t.Fatalf("a later day: %+v %v", next, err)
+ }
+ higher, err := Raise(dir, capAsk("2026-09-24", 10))
+ if err != nil || higher.ID == got[0].ID || higher.ID == next.ID {
+ t.Fatalf("a higher ceiling: %+v %v", higher, err)
+ }
+ list, err = Packets(dir, "bbbbbbbbbbbb")
+ if err != nil || len(list) != 3 {
+ t.Fatalf("three crossings, got %+v (%v)", list, err)
+ }
+}
+
+func (m *packetMemory) bytes() int64 {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.reads
+}
+
+func fileSize(t *testing.T, path string) int64 {
+ t.Helper()
+ info, err := os.Stat(path)
+ must(t, err)
+ return info.Size()
+}
diff --git a/internal/teams/delegation_test.go b/internal/teams/delegation_test.go
new file mode 100644
index 0000000000..e07fb091e5
--- /dev/null
+++ b/internal/teams/delegation_test.go
@@ -0,0 +1,514 @@
+package teams
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func ptrF(v float64) *float64 { return &v }
+func ptrI(v int) *int { return &v }
+func ptrB(v bool) *bool { return &v }
+
+// tree is harbor > dock > pier, and a separate top-level team yard.
+func tree() *File {
+ return &File{Version: Version, Teams: []Team{
+ {ID: "aaaaaaaaaaaa", Name: "harbor", Members: []Member{{Key: "hm", Word: "harbor boss"}}},
+ {ID: "bbbbbbbbbbbb", Name: "dock", Parent: "aaaaaaaaaaaa"},
+ {ID: "cccccccccccc", Name: "pier", Parent: "bbbbbbbbbbbb"},
+ {ID: "dddddddddddd", Name: "yard"},
+ }}
+}
+
+var defaults = Defaults{QuestionsUp: true, CapUSDDay: 5, DepthLimit: 3, SubShare: 0.5}
+
+// AN UNSET VALUE IS INHERITED, AND THE RESOLVER SAYS FROM WHERE. pier sets
+// nothing: questions come from Settings, the cap from harbor two levels up,
+// the share from dock one level up; its own depth override is its own.
+func TestEffectiveWalksTheChainAndNamesEachOrigin(t *testing.T) {
+ f := tree()
+ must(t, f.SetSettings("aaaaaaaaaaaa", func(s *Settings) { s.CapUSDDay = ptrF(10) }))
+ must(t, f.SetSettings("bbbbbbbbbbbb", func(s *Settings) { s.SubShare = ptrF(0.25) }))
+ must(t, f.SetSettings("cccccccccccc", func(s *Settings) { s.DepthLimit = ptrI(4) }))
+
+ e := f.Effective("cccccccccccc", defaults)
+ if !e.QuestionsUp || e.QuestionsUpFrom.Kind != OriginSettings || e.QuestionsUpFrom.Words() != "from Settings" {
+ t.Fatalf("questions: %+v", e)
+ }
+ if e.CapUSDDay != 10 || e.CapFrom.Kind != OriginAncestor || e.CapFrom.Team != "aaaaaaaaaaaa" || e.CapFrom.Words() != "from harbor" {
+ t.Fatalf("cap: %v %+v", e.CapUSDDay, e.CapFrom)
+ }
+ if e.SubShare != 0.25 || e.SubShareFrom.Name != "dock" {
+ t.Fatalf("share: %v %+v", e.SubShare, e.SubShareFrom)
+ }
+ if e.DepthLimit != 4 || e.DepthFrom.Kind != OriginTeam || e.DepthFrom.Inherited() || e.DepthFrom.Words() != "" {
+ t.Fatalf("depth: %v %+v", e.DepthLimit, e.DepthFrom)
+ }
+ // Reset is a nil: pier's depth falls back to Settings.
+ must(t, f.SetSettings("cccccccccccc", func(s *Settings) { s.DepthLimit = nil }))
+ if e := f.Effective("cccccccccccc", defaults); e.DepthLimit != 3 || e.DepthFrom.Kind != OriginSettings {
+ t.Fatalf("a reset override: %+v", e)
+ }
+ // An explicit 0 cap is an override (no cap), not an absence.
+ must(t, f.SetSettings("bbbbbbbbbbbb", func(s *Settings) { s.CapUSDDay = ptrF(0) }))
+ if e := f.Effective("cccccccccccc", defaults); e.CapUSDDay != 0 || e.CapFrom.Name != "dock" {
+ t.Fatalf("a zero cap override: %+v", e)
+ }
+ // An unknown team is the defaults throughout.
+ if e := f.Effective("nothere", defaults); e.CapUSDDay != 5 || e.CapFrom.Kind != OriginSettings {
+ t.Fatalf("an unknown team: %+v", e)
+ }
+}
+
+// AN OVERRIDE OUTSIDE ITS BAND IS REFUSED, AND ONE IN A HAND-EDITED FILE IS
+// DROPPED ON LOAD, reading as inherit.
+func TestOverridesAreKeptInTheirBands(t *testing.T) {
+ f := tree()
+ for _, bad := range []func(*Settings){
+ func(s *Settings) { s.CapUSDDay = ptrF(-1) },
+ func(s *Settings) { s.DepthLimit = ptrI(0) },
+ func(s *Settings) { s.SubShare = ptrF(1.5) },
+ func(s *Settings) { s.SubShare = ptrF(0) },
+ } {
+ if err := f.SetSettings("aaaaaaaaaaaa", bad); err != ErrSetting {
+ t.Fatalf("an out-of-band override was taken: %v", err)
+ }
+ }
+ if !f.Teams[0].Settings.Empty() {
+ t.Fatal("a refused override changed the team")
+ }
+ dir := t.TempDir()
+ raw := `{"version":2,"teams":[{"id":"aaaaaaaaaaaa","name":"harbor","parent":"","members":[],"manager":"",` +
+ `"made":"2026-09-24T00:00:00Z","cap_usd_day":-3,"depth_limit":2,"questions_up":false}]}`
+ writeRaw(t, dir, raw)
+ got, err := Load(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := got.Teams[0].Settings
+ if s.CapUSDDay != nil || s.DepthLimit == nil || *s.DepthLimit != 2 || s.QuestionsUp == nil || *s.QuestionsUp {
+ t.Fatalf("the file's overrides read as %+v", s)
+ }
+}
+
+// THE OVERRIDES ARE WRITTEN FLAT ON THE TEAM, only when set, and survive a
+// round trip; a team with none is written as before.
+func TestOverridesAreStoredFlatAndOnlyWhenSet(t *testing.T) {
+ plain, _ := json.Marshal(Team{ID: "aaaaaaaaaaaa", Name: "harbor"})
+ for _, k := range []string{"questions_up", "cap_usd_day", "depth_limit", "sub_share", "state", "closed_at"} {
+ if strings.Contains(string(plain), k) {
+ t.Fatalf("a team with no overrides wrote %s: %s", k, plain)
+ }
+ }
+ team := Team{ID: "aaaaaaaaaaaa", Name: "harbor", Settings: Settings{CapUSDDay: ptrF(0), QuestionsUp: ptrB(false)}}
+ raw, _ := json.Marshal(team)
+ if !strings.Contains(string(raw), `"cap_usd_day":0`) || !strings.Contains(string(raw), `"questions_up":false`) {
+ t.Fatalf("the overrides are not flat on the team: %s", raw)
+ }
+ var back Team
+ must(t, json.Unmarshal(raw, &back))
+ if back.Settings.CapUSDDay == nil || *back.Settings.CapUSDDay != 0 || *back.Settings.QuestionsUp {
+ t.Fatalf("the round trip lost an override: %+v", back.Settings)
+ }
+ if len(back.extra) != 0 {
+ t.Fatalf("an override was kept as an unknown field: %v", back.extra)
+ }
+}
+
+// DEPTH AND THE SHARE A NEW SUB-TEAM IS MADE WITH.
+func TestNestingDepthAndTheSubTeamsShare(t *testing.T) {
+ f := tree()
+ if f.Depth("aaaaaaaaaaaa") != 1 || f.Depth("cccccccccccc") != 3 || f.Depth("nothere") != 0 {
+ t.Fatal("depths are wrong")
+ }
+ if !f.CanNest("bbbbbbbbbbbb", defaults) || f.CanNest("cccccccccccc", defaults) {
+ t.Fatal("a limit of 3 allows a third level and not a fourth")
+ }
+ if got := f.SubTeamCap("aaaaaaaaaaaa", defaults); got != 2.5 {
+ t.Fatalf("half of $5 is %v", got)
+ }
+ if got := f.SubTeamCap("aaaaaaaaaaaa", Defaults{DepthLimit: 3, SubShare: 0.5}); got != 0 {
+ t.Fatalf("a parent with no cap gave its sub-team %v", got)
+ }
+}
+
+// managed is harbor managed by hm, dock unmanaged under it, yard managed by ym.
+func managed() *File {
+ f := tree()
+ f.Teams[0].Manager = "hm"
+ f.Teams[3].Members = []Member{{Key: "ym"}}
+ f.Teams[3].Manager = "ym"
+ return f
+}
+
+// THE HOME IS PICKED IN THE RULING'S ORDER. A conversation only in unmanaged
+// dock reports to harbor above it; one in managed yard directly and in dock
+// reports to yard (nearest); the manager of harbor reports to nobody, and
+// the manager of a sub-team reports one level up.
+func TestHomeIsPickedNearestThenStartedThenFirst(t *testing.T) {
+ f := managed()
+ f.Teams[1].Members = []Member{{Key: "k1"}, {Key: "k2"}}
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "k2"})
+ tidy(f.Teams)
+
+ if h, ok := f.Home("k1"); !ok || h.Team != "aaaaaaaaaaaa" || h.Via != "bbbbbbbbbbbb" || h.Distance != 1 {
+ t.Fatalf("k1 in unmanaged dock reports to %+v %v", h, ok)
+ }
+ if h, _ := f.Home("k2"); h.Team != "dddddddddddd" || h.Distance != 0 {
+ t.Fatalf("k2 reports to %+v, want yard (its own team managed beats one a level up)", h)
+ }
+ if links := f.Links("k2"); len(links) != 1 || links[0].Team != "aaaaaaaaaaaa" {
+ t.Fatalf("k2's links are %+v", links)
+ }
+ if _, ok := f.Home("hm"); ok {
+ t.Fatal("the top manager reports to somebody")
+ }
+ // dock gets a manager of its own: it reports one level up, to harbor.
+ f.Teams[1].Members = append(f.Teams[1].Members, Member{Key: "dm"})
+ f.Teams[1].Manager = "dm"
+ tidy(f.Teams)
+ if h, _ := f.Home("dm"); h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("a sub-team's manager reports to %+v", h)
+ }
+ // k1's home is still its dock membership, whose nearest manager is now
+ // dock's own: giving a team a manager is the person's act, and it is what
+ // the manager is for (see home.go).
+ if h, _ := f.Home("k1"); h.Team != "bbbbbbbbbbbb" || h.Via != "bbbbbbbbbbbb" {
+ t.Fatalf("k1 in newly managed dock reports to %+v", h)
+ }
+
+ // Two managed teams at the same distance: the started one wins over file order.
+ g := managed()
+ g.Teams[0].Members = append(g.Teams[0].Members, Member{Key: "k3"})
+ g.Teams[3].Members = append(g.Teams[3].Members, Member{Key: "k3", Started: true})
+ tidy(g.Teams)
+ if h, _ := g.Home("k3"); h.Team != "dddddddddddd" {
+ t.Fatalf("the started membership lost: %+v", h)
+ }
+ // Without the start, the first in the file.
+ g.Teams[3].Members[1].Started = false
+ for i := range g.Teams {
+ for j := range g.Teams[i].Members {
+ g.Teams[i].Members[j].Home = false
+ }
+ }
+ tidy(g.Teams)
+ if h, _ := g.Home("k3"); h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("the first in the file lost: %+v", h)
+ }
+}
+
+// A HOME NEVER CHANGES BY ITSELF, MOVES WHEN IT IS NO LONGER ONE, AND SETHOME
+// MOVES IT ON PURPOSE.
+func TestHomeIsStableUntilItStopsBeingOne(t *testing.T) {
+ f := managed()
+ f.Teams[0].Members = append(f.Teams[0].Members, Member{Key: "k"})
+ tidy(f.Teams)
+ if h, _ := f.Home("k"); h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("home %+v", h)
+ }
+ // k joins yard, which is just as near: harbor stays.
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "k", Started: true})
+ if tidy(f.Teams) {
+ t.Fatal("a nearer or started membership moved a valid home")
+ }
+ if h, _ := f.Home("k"); h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("home moved to %+v", h)
+ }
+ // The person moves it.
+ must(t, f.SetHome("k", "dddddddddddd"))
+ if h, _ := f.Home("k"); h.Team != "dddddddddddd" {
+ t.Fatalf("SetHome left %+v", h)
+ }
+ flags := 0
+ for _, tm := range f.Teams {
+ if m, ok := tm.Member("k"); ok && m.Home {
+ flags++
+ }
+ }
+ if flags != 1 {
+ t.Fatalf("k carries %d home flags", flags)
+ }
+ // SetHome refuses a team with nothing above it.
+ f.Teams[1].Members = []Member{{Key: "k"}}
+ f.Teams[0].Manager = ""
+ if err := f.SetHome("k", "bbbbbbbbbbbb"); err != ErrNoManagerAbove {
+ t.Fatalf("SetHome on an unmanaged chain: %v", err)
+ }
+ // yard's manager cleared: the home is gone and nothing else is above k.
+ must(t, f.ClearManager("dddddddddddd"))
+ tidy(f.Teams)
+ if _, ok := f.Home("k"); ok {
+ t.Fatal("k still reports somewhere with no managers left")
+ }
+ for _, tm := range f.Teams {
+ if m, ok := tm.Member("k"); ok && m.Home {
+ t.Fatal("a flag was left with nothing above it")
+ }
+ }
+}
+
+// THE HOME FLAG SURVIVES A SAVE AND A LOAD, and a load of a file with none
+// gives every conversation with a manager one.
+func TestHomesAreStoredAndRepairedOnLoad(t *testing.T) {
+ dir := t.TempDir()
+ f := managed()
+ f.Teams[0].Members = append(f.Teams[0].Members, Member{Key: "k"})
+ raw, _ := json.Marshal(disk{Version: Version, Teams: f.Teams})
+ writeRaw(t, dir, string(raw))
+ got, err := Load(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h, ok := got.Home("k"); !ok || h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("a load gave k %+v %v", h, ok)
+ }
+ again, _ := Load(dir)
+ if m, _ := again.Teams[0].Member("k"); !m.Home {
+ t.Fatal("the repair was not written back")
+ }
+}
+
+// THE LCA OF 1..N PARTIES, WITH UNMANAGED GAPS. Parties in pier and dock
+// (neither managed) meet at harbor; a party in yard shares no team with
+// harbor's, so the person decides; a party that manages the meeting team is
+// passed over for the next one up.
+func TestLCAForOneToNPartiesAcrossUnmanagedGaps(t *testing.T) {
+ f := managed()
+ f.Teams[1].Members = []Member{{Key: "d1"}}
+ f.Teams[2].Members = []Member{{Key: "p1"}, {Key: "p2"}}
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "y1"})
+
+ if lca, ok := f.LCA("p1"); !ok || lca.Name != "harbor" {
+ t.Fatalf("one party: %v %v", lca.Name, ok)
+ }
+ if lca, ok := f.LCA("p1", "p2"); !ok || lca.Name != "harbor" {
+ t.Fatalf("two in unmanaged pier: %v %v", lca.Name, ok)
+ }
+ if lca, ok := f.LCA("p1", "d1", "p2"); !ok || lca.Name != "harbor" {
+ t.Fatalf("three across pier and dock: %v %v", lca.Name, ok)
+ }
+ if _, ok := f.LCA("p1", "y1"); ok {
+ t.Fatal("parties in two trees found a common manager")
+ }
+ // Manage pier: the two in pier meet there, pier and dock still at harbor.
+ f.Teams[2].Members = append(f.Teams[2].Members, Member{Key: "pm"})
+ f.Teams[2].Manager = "pm"
+ if lca, _ := f.LCA("p1", "p2"); lca.Name != "pier" {
+ t.Fatalf("two in managed pier meet at %v", lca.Name)
+ }
+ if lca, _ := f.LCA("p1", "d1"); lca.Name != "harbor" {
+ t.Fatalf("pier and dock meet at %v", lca.Name)
+ }
+ // The pier manager as a party: pier is passed over.
+ if lca, _ := f.LCA("pm", "p1"); lca.Name != "harbor" {
+ t.Fatalf("a manager judged its own case at %v", lca.Name)
+ }
+ // A conversation in both yard and pier: yard is shared with y1.
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "p1"})
+ if lca, _ := f.LCA("p1", "y1"); lca.Name != "yard" {
+ t.Fatalf("a shared membership meets at %v", lca.Name)
+ }
+ if _, ok := f.LCA(); ok {
+ t.Fatal("no parties found a decider")
+ }
+}
+
+// CLOSING CASCADES DOWN, TAKES THE TEAM OUT OF EVERY WALK, AND REOPEN BRINGS
+// BACK EXACTLY WHAT IT CLOSED.
+func TestCloseCascadesAndReopenUndoesOnlyItsOwn(t *testing.T) {
+ f := managed()
+ f.Teams[1].Members = []Member{{Key: "k"}}
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "k"})
+ must(t, f.SetSettings("aaaaaaaaaaaa", func(s *Settings) { s.CapUSDDay = ptrF(10) }))
+ tidy(f.Teams)
+ if h, _ := f.Home("k"); h.Team != "dddddddddddd" {
+ t.Fatalf("home before %+v", h)
+ }
+ // pier closed on its own first.
+ at := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC)
+ must(t, f.Close("cccccccccccc", at, ""))
+ must(t, f.Close("aaaaaaaaaaaa", at.Add(time.Hour), "p123"))
+ for _, id := range []string{"aaaaaaaaaaaa", "bbbbbbbbbbbb", "cccccccccccc"} {
+ if tm, _ := f.Team(id); !tm.Closed() {
+ t.Fatalf("%s is open after its ancestor closed", id)
+ }
+ }
+ if tm, _ := f.Team("cccccccccccc"); tm.ClosedWith != "cccccccccccc" || !tm.ClosedAt.Equal(at) {
+ t.Fatalf("pier's own close was overwritten: %+v", tm)
+ }
+ if tm, _ := f.Team("aaaaaaaaaaaa"); tm.Report != "p123" {
+ t.Fatal("the report pointer was not kept")
+ }
+ if len(f.Open()) != 1 || len(f.ClosedTeams()) != 3 || f.ClosedTeams()[0].ID != "aaaaaaaaaaaa" {
+ t.Fatal("open and closed lists are wrong")
+ }
+ // Out of every walk.
+ if lca, ok := f.LCA("hm"); ok {
+ t.Fatalf("a closed team decided: %v", lca.Name)
+ }
+ if e := f.Effective("bbbbbbbbbbbb", defaults); e.CapFrom.Kind != OriginClosed || e.CapUSDDay != 0 {
+ t.Fatalf("a closed team has a cap: %+v", e)
+ }
+ if f.CanNest("bbbbbbbbbbbb", defaults) {
+ t.Fatal("a sub-team can be made under a closed team")
+ }
+ // k's home was yard all along; with dock closed nothing changes, and a
+ // conversation only in harbor's tree now reports nowhere.
+ tidy(f.Teams)
+ if h, _ := f.Home("k"); h.Team != "dddddddddddd" {
+ t.Fatalf("home after %+v", h)
+ }
+ // A sub-team under a closed parent cannot reopen alone.
+ if err := f.Reopen("bbbbbbbbbbbb"); err != ErrParentClosed {
+ t.Fatalf("reopening under a closed parent: %v", err)
+ }
+ must(t, f.Reopen("aaaaaaaaaaaa"))
+ if tm, _ := f.Team("bbbbbbbbbbbb"); tm.Closed() {
+ t.Fatal("dock, closed by harbor's close, stayed closed")
+ }
+ if tm, _ := f.Team("cccccccccccc"); !tm.Closed() {
+ t.Fatal("pier, closed on its own, was reopened with harbor")
+ }
+}
+
+// A CLOSED TEAM ROUND-TRIPS, AND A CONVERSATION WHOSE HOME CLOSES IS GIVEN THE
+// NEXT ONE ON THE SAME WRITE.
+func TestClosingMovesHomesOnTheSameWrite(t *testing.T) {
+ dir := t.TempDir()
+ f := managed()
+ f.Teams[0].Members = append(f.Teams[0].Members, Member{Key: "k"})
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "k"})
+ must(t, Save(dir, f.Teams))
+ g, _ := Load(dir)
+ if h, _ := g.Home("k"); h.Team != "aaaaaaaaaaaa" {
+ t.Fatalf("home %+v", h)
+ }
+ must(t, Update(dir, func(f *File) error { return f.Close("aaaaaaaaaaaa", time.Time{}, "") }))
+ g, _ = Load(dir)
+ if h, _ := g.Home("k"); h.Team != "dddddddddddd" {
+ t.Fatalf("after the close k reports to %+v", h)
+ }
+ tm, _ := g.Team("aaaaaaaaaaaa")
+ if !tm.Closed() || tm.ClosedAt.IsZero() {
+ t.Fatalf("the close did not survive the file: %+v", tm)
+ }
+}
+
+// DELETE IS ONLY FROM CLOSED, AND TAKES THE TEAM'S FILES WITH IT.
+func TestDeleteOnlyAClosedTeamAndItsFiles(t *testing.T) {
+ dir := t.TempDir()
+ f := managed()
+ must(t, Save(dir, f.Teams))
+ must(t, AppendTraffic(dir, "bbbbbbbbbbbb", Entry{Kind: KindNote, From: FromManager, To: ToEveryone, Text: "x"}))
+ if _, err := Delete(dir, "aaaaaaaaaaaa"); err != ErrOpen {
+ t.Fatalf("an open team was deleted: %v", err)
+ }
+ must(t, Update(dir, func(f *File) error { return f.Close("aaaaaaaaaaaa", time.Time{}, "") }))
+ gone, err := Delete(dir, "aaaaaaaaaaaa")
+ if err != nil || len(gone) != 3 {
+ t.Fatalf("delete: %v %v", gone, err)
+ }
+ g, _ := Load(dir)
+ if len(g.Teams) != 1 || g.Teams[0].Name != "yard" {
+ t.Fatalf("left %+v", g.Teams)
+ }
+ if entries, _ := ReadTraffic(dir, "bbbbbbbbbbbb", "", 0); len(entries) != 0 {
+ t.Fatal("a deleted team's traffic is still there")
+ }
+}
+
+func must(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// writeRaw puts raw on disk as dir's teams file, exactly.
+func writeRaw(t *testing.T, dir, raw string) {
+ t.Helper()
+ must(t, os.MkdirAll(dir, 0o700))
+ must(t, os.WriteFile(Path(dir), []byte(raw), 0o600))
+}
+
+// THE GLOBAL MANAGER IS THE MANAGER OF A REAL ROOT, AND EVERY RULE HOLDS
+// WITHOUT A SPECIAL CASE. Making the root moves every top-level team under it;
+// parties in two trees then meet at the root instead of the person; a
+// top-level manager reports to it; its override is inherited as `from All
+// teams`; it is not a level; a team made later at the top lands under it; it
+// cannot be closed; and dissolving it puts the tree back.
+func TestTheRootHoldsEveryTeamAndIsNotALevel(t *testing.T) {
+ f := managed()
+ f.Teams[1].Members = []Member{{Key: "d1"}}
+ f.Teams[3].Members = append(f.Teams[3].Members, Member{Key: "y1"})
+ if _, ok := f.LCA("d1", "y1"); ok {
+ t.Fatal("two trees met before there was a root")
+ }
+ root := f.MakeRoot(time.Time{})
+ must(t, f.AddMember(root, Member{Key: "gm"}))
+ must(t, f.SetManager(root, "gm"))
+ must(t, f.SetSettings(root, func(s *Settings) { s.CapUSDDay = ptrF(20) }))
+ tidy(f.Teams)
+ if lca, ok := f.LCA("d1", "y1"); !ok || lca.ID != root {
+ t.Fatalf("two trees meet at %v %v, want the root", lca.Name, ok)
+ }
+ if h, _ := f.Home("hm"); h.Team != root {
+ t.Fatalf("harbor's manager reports to %+v, want the root", h)
+ }
+ if e := f.Effective("cccccccccccc", defaults); e.CapUSDDay != 20 || e.CapFrom.Words() != "from All teams" {
+ t.Fatalf("the root's cap: %+v", e)
+ }
+ if f.Depth(root) != 0 || f.Depth("aaaaaaaaaaaa") != 1 || f.Depth("cccccccccccc") != 3 {
+ t.Fatal("the root counted as a level")
+ }
+ f.Teams = append(f.Teams, Team{ID: "eeeeeeeeeeee", Name: "late"})
+ tidy(f.Teams)
+ if late, _ := f.Team("eeeeeeeeeeee"); late.Parent != root {
+ t.Fatal("a team made later at the top did not land under the root")
+ }
+ if err := f.Close(root, time.Time{}, ""); err != ErrRoot {
+ t.Fatalf("the root closed: %v", err)
+ }
+ if err := f.SetParent(root, "aaaaaaaaaaaa"); err != ErrRoot {
+ t.Fatalf("the root moved under a team: %v", err)
+ }
+ f.DissolveRoot()
+ if _, ok := f.Root(); ok {
+ t.Fatal("the root survived its dissolving")
+ }
+ if tm, _ := f.Team("aaaaaaaaaaaa"); tm.Parent != "" {
+ t.Fatal("harbor was not put back at the top")
+ }
+}
+
+// ORGANIZE'S QUIET TEAMS: a team nobody touched in the window and with no
+// packet waiting is proposed; one with a recent Traffic line, a recently
+// written member, or a waiting packet is not; neither is a closed team.
+func TestQuietTeamsAreProposedAndBusyOnesAreNot(t *testing.T) {
+ dir := t.TempDir()
+ old := time.Now().Add(-30 * 24 * time.Hour)
+ fresh := filepath.Join(dir, "fresh.jsonl")
+ must(t, os.WriteFile(fresh, []byte("x"), 0o600))
+ stale := filepath.Join(dir, "stale.jsonl")
+ must(t, os.WriteFile(stale, []byte("x"), 0o600))
+ must(t, os.Chtimes(stale, old, old))
+ must(t, Save(dir, []Team{
+ {ID: "aaaaaaaaaaaa", Name: "quiet", Made: old, Members: []Member{{Key: stale}}},
+ {ID: "bbbbbbbbbbbb", Name: "talking", Made: old},
+ {ID: "cccccccccccc", Name: "writing", Made: old, Members: []Member{{Key: fresh}}},
+ {ID: "dddddddddddd", Name: "asked", Made: old, Manager: "m", Members: []Member{{Key: "m", Handle: "boss"}}},
+ {ID: "eeeeeeeeeeee", Name: "closed", Made: old, State: TeamClosed, ClosedAt: old},
+ }))
+ must(t, AppendTraffic(dir, "bbbbbbbbbbbb", Entry{Kind: KindNote, From: FromManager, To: ToEveryone, Text: "hi"}))
+ _, err := Raise(dir, Packet{Team: Person, Origin: "dddddddddddd", Kind: PacketQuestion, RaisedBy: "boss", Question: "?"})
+ must(t, err)
+ f, _ := Load(dir)
+ quiet, err := Quiet(dir, f, time.Now(), QuietAfter)
+ if err != nil || len(quiet) != 1 || quiet[0] != "aaaaaaaaaaaa" {
+ t.Fatalf("quiet teams %v, %v", quiet, err)
+ }
+}
diff --git a/internal/teams/digest.go b/internal/teams/digest.go
index 36e8cbe37c..6c658b0a58 100644
--- a/internal/teams/digest.go
+++ b/internal/teams/digest.go
@@ -34,6 +34,11 @@ type MemberState struct {
Question string
// Files are the files the member has touched.
Files []string
+ // ReportsTo is the name of the team whose manager this member reports to
+ // when that is not this team (home.go): a shared member, which this
+ // team's manager may read and send a note to, and not direct. A shared
+ // member that is running is drawn busy for that team.
+ ReportsTo string
}
// Digest limits.
@@ -114,7 +119,13 @@ func memberLine(m Member, s MemberState, manager bool) string {
if state == "" {
state = "unknown"
}
+ if s.ReportsTo != "" && state == StateRunning {
+ state = "busy for " + s.ReportsTo
+ }
b.WriteString(": " + state)
+ if s.ReportsTo != "" {
+ b.WriteString(", reports to " + s.ReportsTo)
+ }
if s.SinceActive > 0 {
b.WriteString(", active " + ago(s.SinceActive))
}
diff --git a/internal/teams/home.go b/internal/teams/home.go
new file mode 100644
index 0000000000..b226b7de73
--- /dev/null
+++ b/internal/teams/home.go
@@ -0,0 +1,350 @@
+package teams
+
+import (
+ "errors"
+ "fmt"
+)
+
+// ── WHO A CONVERSATION REPORTS TO ───────────────────────────────────────────
+//
+// Every conversation that sits in a team with a manager somewhere above it
+// reports to exactly one manager, its HOME (ruling c-5). Its routine direction
+// and its clarifying questions go there; every other manager it can be reached
+// by is a LINK, who may read it and send it an fyi and nothing else.
+//
+// THE HOME IS STORED, NOT DERIVED ON EVERY READ, because the ruling says it
+// never changes by itself: a conversation that later joins a nearer team keeps
+// reporting where it did until the person moves it ([File.SetHome]). It is a
+// flag on one of the conversation's memberships ([Member.Home]), and the home
+// manager is the nearest manager up THAT membership's chain, the membership's
+// own team first. So the flag may sit on a membership of an unmanaged team
+// whose parent has a manager, which is how a conversation in an unmanaged
+// sub-team reports to the manager above it without being a member there.
+//
+// THE FLAG IS STABLE; THE MANAGER AT THE END OF ITS CHAIN IS WHOEVER THE
+// PERSON MADE MANAGER THERE. A conversation in an unmanaged sub-team reports
+// to the manager above it; when the person gives that sub-team a manager, the
+// same flag now resolves to the new manager, one step nearer. That is not the
+// home changing by itself: making a manager is the person's act, and a
+// sub-team manager nobody under it reported to would be a manager of nothing.
+//
+// A MANAGER IS NEVER ITS OWN HOME. Walking up from a team a conversation
+// manages skips that team, so a sub-team's manager reports to the manager of
+// the team above, which is the ruling's "reports one level up".
+//
+// tidy keeps the flag honest on every load and write: a conversation with no
+// valid flag and something to report to is given one by the rules below, a
+// flag with nothing above it any more (the manager was cleared, the team was
+// closed or the membership removed) is taken away and picked again, and a
+// second flag is dropped. The pick, in order:
+//
+// 1. the membership with the NEAREST manager: its own team managed before a
+// manager one or more levels up, and among equals the deeper, more
+// specific team first;
+// 2. then the membership the manager's team_start made ([Member.Started]);
+// 3. then the first in the file's order. The file keeps teams in the order
+// they were made and a team's members in the order they joined, and the
+// pick is made at the first write that gives a conversation somewhere to
+// report, so for a conversation that joins its managed teams one at a
+// time this is the team it joined first.
+//
+// A CLOSED TEAM IS NOT SOMEWHERE TO REPORT (lifecycle.go). A membership of a
+// closed team is never a home, a closed team's manager is never found by a
+// walk, and a conversation whose home closed is given the next one by the same
+// rules, or none, and is then an ordinary chat.
+
+// Report is one manager a conversation can be reached by.
+type Report struct {
+ // Via is the team of the membership the line runs through.
+ Via string `json:"via"`
+ // Team is the managed team whose manager this is: Via itself or an
+ // ancestor of it.
+ Team string `json:"team"`
+ // Manager is that manager's conversation key.
+ Manager string `json:"manager"`
+ // Distance is how many levels up from Via the manager's team is.
+ Distance int `json:"distance"`
+}
+
+// ErrNoManagerAbove is [File.SetHome] asked for a membership with no manager
+// anywhere up its chain.
+var ErrNoManagerAbove = errors.New("teams: no manager above that team to report to")
+
+// Home is the manager the conversation with key reports to, false when it has
+// none (it is an ordinary chat).
+func (f *File) Home(key string) (Report, bool) {
+ for _, t := range f.Teams {
+ m, ok := t.Member(key)
+ if !ok || !m.Home || t.Closed() {
+ continue
+ }
+ if r, ok := managerUp(f.Teams, t.ID, key); ok {
+ return r, true
+ }
+ }
+ return Report{}, false
+}
+
+// Links is every other manager the conversation with key can be reached by:
+// the nearest manager up each of its memberships, in the pick order, each
+// managed team once, the home left out.
+func (f *File) Links(key string) []Report {
+ home, _ := f.Home(key)
+ seen := map[string]bool{home.Team: true}
+ var out []Report
+ for _, r := range candidates(f.Teams, key) {
+ if seen[r.Team] {
+ continue
+ }
+ seen[r.Team] = true
+ out = append(out, r)
+ }
+ return out
+}
+
+// SetHome makes the membership of key in team id its home. The membership
+// must exist and have a manager up its chain, else [ErrNoManagerAbove].
+func (f *File) SetHome(key, id string) error {
+ t, ok := f.Team(id)
+ if !ok {
+ return fmt.Errorf("no team %s", id)
+ }
+ if !t.Holds(key) {
+ return fmt.Errorf("%s is not in team %s", key, t.Name)
+ }
+ if _, ok := managerUp(f.Teams, id, key); !ok {
+ return ErrNoManagerAbove
+ }
+ setHomeFlag(f.Teams, key, id)
+ return nil
+}
+
+// setHomeFlag puts key's one home flag on its membership of team id.
+func setHomeFlag(teams []Team, key, id string) {
+ for i := range teams {
+ for j := range teams[i].Members {
+ if teams[i].Members[j].Key == key {
+ teams[i].Members[j].Home = teams[i].ID == id
+ }
+ }
+ }
+}
+
+// managerUp is the nearest manager up from team id, id itself first, that is
+// not key: the team must be open and have a manager. The walk is bounded by
+// the list, so a loop cannot hang it.
+func managerUp(teams []Team, id, key string) (Report, bool) {
+ at := id
+ for distance := 0; at != "" && distance <= len(teams); distance++ {
+ i := Index(teams, at)
+ if i < 0 {
+ return Report{}, false
+ }
+ t := teams[i]
+ if !t.Closed() && t.Manager != "" && t.Manager != key {
+ return Report{Via: id, Team: t.ID, Manager: t.Manager, Distance: distance}, true
+ }
+ at = t.Parent
+ }
+ return Report{}, false
+}
+
+// candidate is one membership that could be a home, with what the pick order
+// reads.
+type candidate struct {
+ r Report
+ depth int
+ started bool
+ order int
+}
+
+// before is the pick order between two candidates (see the file header).
+func (a candidate) before(b candidate) bool {
+ if a.r.Distance != b.r.Distance {
+ return a.r.Distance < b.r.Distance
+ }
+ if a.depth != b.depth {
+ return a.depth > b.depth
+ }
+ if a.started != b.started {
+ return a.started
+ }
+ return a.order < b.order
+}
+
+// candidates is every open membership of key with a manager up its chain, as
+// a Report, in the pick order.
+func candidates(teams []Team, key string) []Report {
+ var all []candidate
+ for i, t := range teams {
+ if t.Closed() {
+ continue
+ }
+ m, ok := t.Member(key)
+ if !ok {
+ continue
+ }
+ r, ok := managerUp(teams, t.ID, key)
+ if !ok {
+ continue
+ }
+ all = append(all, candidate{r: r, depth: depthIn(teams, r.Team), started: m.Started, order: i})
+ }
+ // An insertion sort: a conversation is in a handful of teams.
+ for i := 1; i < len(all); i++ {
+ for j := i; j > 0 && all[j].before(all[j-1]); j-- {
+ all[j], all[j-1] = all[j-1], all[j]
+ }
+ }
+ out := make([]Report, len(all))
+ for i, c := range all {
+ out[i] = c.r
+ }
+ return out
+}
+
+// depthIn is how many levels team id stands at, 1 at the top.
+func depthIn(teams []Team, id string) int {
+ depth := 0
+ for at := id; at != "" && depth <= len(teams); depth++ {
+ i := Index(teams, at)
+ if i < 0 {
+ break
+ }
+ at = teams[i].Parent
+ }
+ return depth
+}
+
+// assignHomes keeps one valid home flag for every conversation that has a
+// manager to report to and none for any other, and reports whether it moved
+// a flag. A valid flag is never moved.
+func assignHomes(teams []Team) bool {
+ changed := false
+ done := map[string]bool{}
+ for _, t := range teams {
+ for _, m := range t.Members {
+ if done[m.Key] {
+ continue
+ }
+ done[m.Key] = true
+ if homeHolds(teams, m.Key) {
+ continue
+ }
+ want := ""
+ if c := candidates(teams, m.Key); len(c) > 0 {
+ want = c[0].Via
+ }
+ if clearOrSet(teams, m.Key, want) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+// homeHolds reports whether key carries exactly one home flag and it is on an
+// open membership with a manager up its chain.
+func homeHolds(teams []Team, key string) bool {
+ flags, valid := 0, false
+ for _, t := range teams {
+ m, ok := t.Member(key)
+ if !ok || !m.Home {
+ continue
+ }
+ flags++
+ if !t.Closed() {
+ _, valid = managerUp(teams, t.ID, key)
+ }
+ }
+ if flags == 0 {
+ // No flag is right exactly when there is nothing to report to.
+ return len(candidates(teams, key)) == 0
+ }
+ return flags == 1 && valid
+}
+
+// clearOrSet leaves key's one flag on its membership of want ("" for none) and
+// reports whether anything moved. A key with several flags of which one is
+// valid keeps that one.
+func clearOrSet(teams []Team, key, want string) bool {
+ for _, t := range teams {
+ if m, ok := t.Member(key); ok && m.Home && !t.Closed() {
+ if _, valid := managerUp(teams, t.ID, key); valid {
+ want = t.ID
+ break
+ }
+ }
+ }
+ changed := false
+ for i := range teams {
+ for j := range teams[i].Members {
+ m := &teams[i].Members[j]
+ if m.Key != key {
+ continue
+ }
+ on := want != "" && teams[i].ID == want
+ if m.Home != on {
+ m.Home, changed = on, true
+ }
+ }
+ }
+ return changed
+}
+
+// ── WHO DECIDES BETWEEN SEVERAL ─────────────────────────────────────────────
+
+// LCA is the team whose manager decides between the conversations keys
+// (ruling c-6): the lowest team that is at or above some membership of every
+// one of them, is open, and has a manager who is not one of them. The lowest
+// is the deepest; among equally deep teams the first in the file. false is
+// nobody, and then the person decides. One key is that conversation's nearest
+// manager, which for a conversation in one team is its home.
+//
+// A MANAGER WHO IS A PARTY DOES NOT JUDGE ITS OWN CASE: a team whose manager
+// is one of keys is passed over for the next team up that holds them all.
+func (f *File) LCA(keys ...string) (Team, bool) {
+ if len(keys) == 0 {
+ return Team{}, false
+ }
+ party := map[string]bool{}
+ for _, k := range keys {
+ party[k] = true
+ }
+ // above[k] is every team at or above one of k's open memberships.
+ var common map[string]bool
+ for _, k := range keys {
+ above := map[string]bool{}
+ for _, t := range f.Teams {
+ if t.Closed() || !t.Holds(k) {
+ continue
+ }
+ above[t.ID] = true
+ for _, a := range f.Ancestors(t.ID) {
+ above[a.ID] = true
+ }
+ }
+ if common == nil {
+ common = above
+ continue
+ }
+ for id := range common {
+ if !above[id] {
+ delete(common, id)
+ }
+ }
+ }
+ best, bestDepth := -1, 0
+ for i, t := range f.Teams {
+ if !common[t.ID] || t.Closed() || t.Manager == "" || party[t.Manager] {
+ continue
+ }
+ if d := depthIn(f.Teams, t.ID); best < 0 || d > bestDepth {
+ best, bestDepth = i, d
+ }
+ }
+ if best < 0 {
+ return Team{}, false
+ }
+ return f.Teams[best], true
+}
diff --git a/internal/teams/lifecycle.go b/internal/teams/lifecycle.go
new file mode 100644
index 0000000000..5c009fbfd4
--- /dev/null
+++ b/internal/teams/lifecycle.go
@@ -0,0 +1,279 @@
+package teams
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+)
+
+// ── A TEAM IS OPEN OR CLOSED, AND ONLY A CLOSED ONE CAN BE DELETED ──────────
+//
+// The ruling (c-9): a team the work is done with is CLOSED, not deleted. It
+// keeps its members, its Traffic, its packets and its closing report, it is
+// drawn folded under `Closed · N`, and it can be reopened. Deleting forgets
+// the grouping, its Traffic and its packets, and is offered only from Closed,
+// so nothing that is running is ever one keystroke from gone. The
+// conversations are never deleted; they stay in history.
+//
+// WHAT CLOSED MEANS TO THE REST OF THIS PACKAGE. A closed team is outside
+// every walk: it is never anybody's home and its manager is found by no walk
+// (home.go), it is never the team that decides between parties ([File.LCA]),
+// its overrides are skipped by a team under it ([File.Effective]) and its own
+// effective cap is none, because a closed team spends nothing. tidy re-runs
+// the home rule on the same write that closes a team, so a conversation whose
+// home closed reports to its next manager, or to nobody, from that write on.
+//
+// CLOSING CASCADES DOWN AND ONLY DOWN. Closing a team closes every open team
+// under it and records which close closed them ([Team.ClosedWith]), so
+// reopening the team reopens exactly those and not a sub-team the person had
+// closed on its own before. A sub-team may close alone; its parent stays open.
+// A team under a closed parent cannot be reopened until the parent is.
+//
+// THE SESSION'S WRAP-UP IS NOT HERE. What a manager does before a close (tell
+// members to finish, answer what it can, write the closing report as a
+// [KindClosing] packet) is internal/session's; stopping member turns and
+// closing tabs is the interface's. This file is the record those two write.
+
+// Team states.
+const (
+ TeamOpen = "open"
+ TeamClosed = "closed"
+)
+
+// ErrOpen is [Delete] asked to delete a team that is not closed.
+var ErrOpen = errors.New("teams: only a closed team can be deleted; close it first")
+
+// ErrParentClosed is [File.Reopen] asked to reopen a team whose parent is
+// closed.
+var ErrParentClosed = errors.New("teams: its parent team is closed; reopen that first")
+
+// Closed reports whether the team is closed.
+func (t Team) Closed() bool { return t.State == TeamClosed }
+
+// Descendants is every team under id, at any depth, parents before children.
+func (f *File) Descendants(id string) []Team {
+ var out []Team
+ frontier := []string{id}
+ seen := map[string]bool{id: true}
+ for len(frontier) > 0 && len(out) < len(f.Teams) {
+ next := frontier[0]
+ frontier = frontier[1:]
+ for _, t := range f.Children(next) {
+ if seen[t.ID] {
+ continue
+ }
+ seen[t.ID] = true
+ out = append(out, t)
+ frontier = append(frontier, t.ID)
+ }
+ }
+ return out
+}
+
+// Close closes team id at at, with report the id of its closing report packet
+// ("" for none), and every open team under it. A team already closed is left
+// as it was, and so is a sub-team already closed on its own.
+func (f *File) Close(id string, at time.Time, report string) error {
+ i, err := f.at(id)
+ if err != nil {
+ return err
+ }
+ if f.Teams[i].Root {
+ return ErrRoot
+ }
+ if f.Teams[i].Closed() {
+ return nil
+ }
+ if at.IsZero() {
+ at = time.Now()
+ }
+ t := &f.Teams[i]
+ t.State, t.ClosedAt, t.ClosedWith, t.Report = TeamClosed, at, id, report
+ // A closed team has no wrap-up left to resume.
+ t.Wrap = nil
+ for _, d := range f.Descendants(id) {
+ j := Index(f.Teams, d.ID)
+ if f.Teams[j].Closed() {
+ continue
+ }
+ c := &f.Teams[j]
+ c.State, c.ClosedAt, c.ClosedWith = TeamClosed, at, id
+ c.Wrap = nil
+ }
+ return nil
+}
+
+// Reopen opens team id again, and every team under it that its own close
+// closed. Its closing report stays recorded; a team reopened and closed again
+// gets the new one. A team whose parent is closed is [ErrParentClosed].
+func (f *File) Reopen(id string) error {
+ i, err := f.at(id)
+ if err != nil {
+ return err
+ }
+ if !f.Teams[i].Closed() {
+ return nil
+ }
+ if p := f.Teams[i].Parent; p != "" {
+ if parent, ok := f.Team(p); ok && parent.Closed() {
+ return ErrParentClosed
+ }
+ }
+ reopen := func(j int) {
+ t := &f.Teams[j]
+ t.State, t.ClosedAt, t.ClosedWith = "", time.Time{}, ""
+ }
+ for _, d := range f.Descendants(id) {
+ if d.Closed() && d.ClosedWith == id {
+ reopen(Index(f.Teams, d.ID))
+ }
+ }
+ reopen(i)
+ return nil
+}
+
+// Open is the teams that are open, in stored order.
+func (f *File) Open() []Team {
+ var out []Team
+ for _, t := range f.Teams {
+ if !t.Closed() {
+ out = append(out, t)
+ }
+ }
+ return out
+}
+
+// ClosedTeams is the teams that are closed, most recently closed first, for
+// the folded `Closed · N` section.
+func (f *File) ClosedTeams() []Team {
+ var out []Team
+ for _, t := range f.Teams {
+ if t.Closed() {
+ out = append(out, t)
+ }
+ }
+ for i := 1; i < len(out); i++ {
+ for j := i; j > 0 && out[j].ClosedAt.After(out[j-1].ClosedAt); j-- {
+ out[j], out[j-1] = out[j-1], out[j]
+ }
+ }
+ return out
+}
+
+// TeamDir is team id's own directory, /teams/, which holds its
+// Traffic and its decision packets.
+func TeamDir(profileDir, teamID string) string {
+ return config.ProfilePath(profileDir, filepath.Join("teams", teamID))
+}
+
+// Delete forgets closed team id and every team under it (closed with it, by
+// the cascade): their entries in the teams file, then their Traffic and packet
+// files. It answers the ids it deleted. A team that is open is [ErrOpen] and
+// nothing is changed; so is one with an open team under it, which only a
+// hand-edited file can have. The conversations are not touched.
+//
+// THE FILE IS WRITTEN FIRST. A crash between the two leaves directories no
+// team names, which cost a few kilobytes and are harmless; the other order
+// could leave a team whose Traffic was gone.
+func Delete(profileDir, id string) ([]string, error) {
+ var gone []string
+ err := Update(profileDir, func(f *File) error {
+ t, ok := f.Team(id)
+ if !ok {
+ return fmt.Errorf("no team %s", id)
+ }
+ if !t.Closed() {
+ return ErrOpen
+ }
+ gone = []string{id}
+ for _, d := range f.Descendants(id) {
+ if !d.Closed() {
+ return ErrOpen
+ }
+ gone = append(gone, d.ID)
+ }
+ drop := map[string]bool{}
+ for _, g := range gone {
+ drop[g] = true
+ }
+ kept := f.Teams[:0:0]
+ for _, t := range f.Teams {
+ if !drop[t.ID] {
+ kept = append(kept, t)
+ }
+ }
+ f.Teams = kept
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ for _, g := range gone {
+ if safeTeamID(g) == nil {
+ _ = os.RemoveAll(TeamDir(profileDir, g))
+ }
+ }
+ forgetPackets(profileDir)
+ return gone, nil
+}
+
+// QuietAfter is how long a team goes without activity before Organize may
+// propose closing it (ruling c-9's "about seven days").
+const QuietAfter = 7 * 24 * time.Hour
+
+// Quiet is every open team in f, the root aside, that Organize may propose
+// closing at now: nothing in its Traffic, its packets or its members'
+// transcripts for idle, and no packet waiting on it or raised from it. It is
+// a proposal's input and closes nothing. It reads one Traffic line and one
+// packet fold per team and stats each member's transcript, so it is asked off
+// the loop, when Organize is.
+func Quiet(profileDir string, f *File, now time.Time, idle time.Duration) ([]string, error) {
+ waiting := map[string]bool{}
+ open, _, err := OpenPackets(profileDir, ScopeAll)
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range open {
+ waiting[p.Team], waiting[p.Origin] = true, true
+ }
+ cut := now.Add(-idle)
+ var out []string
+ for _, t := range f.Teams {
+ if t.Closed() || t.Root || waiting[t.ID] {
+ continue
+ }
+ if last := lastActivity(profileDir, t); last.After(cut) {
+ continue
+ }
+ out = append(out, t.ID)
+ }
+ return out, nil
+}
+
+// lastActivity is the latest of team t's last Traffic line, its last packet
+// change and its members' transcripts' modification times; a team with none
+// of these is as old as it was made.
+func lastActivity(profileDir string, t Team) time.Time {
+ last := t.Made
+ later := func(at time.Time) {
+ if at.After(last) {
+ last = at
+ }
+ }
+ if tail, err := ReadTraffic(profileDir, t.ID, "", 1); err == nil && len(tail) == 1 {
+ later(tail[0].At)
+ }
+ if packets, err := Packets(profileDir, t.ID); err == nil {
+ for _, p := range packets {
+ later(p.At)
+ }
+ }
+ for _, m := range t.Members {
+ later(modTime(m.Key))
+ }
+ return last
+}
diff --git a/internal/teams/move.go b/internal/teams/move.go
new file mode 100644
index 0000000000..557c9185e4
--- /dev/null
+++ b/internal/teams/move.go
@@ -0,0 +1,536 @@
+package teams
+
+import (
+ "sort"
+ "strings"
+)
+
+// ── MOVING TEAMS: WHERE ONE MAY GO, AND WHAT A MOVE CHANGES ─────────────────
+//
+// The person moves a team by putting it inside another (ruling c-12, amended
+// by c-13): `Move into…` on the teams page, a drag in its rail, or `Inside:` on
+// the team's card. The write itself is [File.SetParent], which refuses only
+// what would break the tree (a loop, the root under something). Two questions
+// come before it, and both are here rather than in the interface because the
+// session's own restructuring tools will ask the same ones:
+//
+// WHERE MAY IT GO ([File.MoveCheck]). Not into itself or anything under it,
+// not into a closed team, and not so deep that the deepest team it carries
+// would stand past the depth limit of the team it lands in. The answer is a
+// [MoveBlock] with the facts a sentence needs (`harbor is 3 levels deep ·
+// limit 3 · Settings`), and the interface draws every target, dimming the ones
+// that are blocked and saying why, so nothing is hidden.
+//
+// WHAT WOULD CHANGE ([File.MoveEffects]). A move is only a line in a tree
+// until it moves authority, money or judgement, and those are what a person
+// should see before it happens: whose manager each conversation carried along
+// reports to ([File.Home]), whose capped pool the moved team's spend now counts
+// toward (a cap is a pool over the subtree, spend.go), and which manager
+// decides a conflict among the team's own conversations ([File.LCA]). Each is
+// computed on a copy of the file as the move would leave it, tidied exactly as
+// the store's write would tidy it, so the answer is the one the next read will
+// show. A move that changes none of them is applied at once.
+
+// The kinds of [MoveBlock].
+const (
+ // MoveBlockSelf is a team asked to go inside itself.
+ MoveBlockSelf = "self"
+ // MoveBlockInside is a team asked to go inside a team under it.
+ MoveBlockInside = "inside"
+ // MoveBlockClosed is a closed target.
+ MoveBlockClosed = "closed"
+ // MoveBlockDepth is a move that would stand a team past the target's
+ // depth limit.
+ MoveBlockDepth = "depth"
+ // MoveBlockHere is a target every moved team is already directly under.
+ MoveBlockHere = "here"
+ // MoveBlockRoot is the root team asked to move (it holds every team).
+ MoveBlockRoot = "root"
+ // MoveBlockGone is a team or a target that is not in the file.
+ MoveBlockGone = "gone"
+)
+
+// MoveBlock is why teams cannot go inside a target. Team names the team the
+// reason is about: the moved team for self, inside and root, the target for
+// closed, depth and here. For depth, Depth is how many levels deep the target
+// stands, Need how many levels the moved teams take up (a team with one level
+// of sub-teams under it needs two), Limit the target's effective depth limit
+// and LimitFrom where that limit came from.
+type MoveBlock struct {
+ Kind string
+ Team string
+ Name string
+ Depth int
+ Need int
+ Limit int
+ LimitFrom Origin
+}
+
+// MoveTarget is the id a move into parent really writes: "" is the top level,
+// which is the root team when there is one (root.go keeps every other
+// top-level team under it).
+func (f *File) MoveTarget(parent string) string {
+ if parent != "" {
+ return parent
+ }
+ if r, ok := f.Root(); ok {
+ return r.ID
+ }
+ return ""
+}
+
+// MoveRoots is ids with every id that sits under another of them left out, in
+// the order given: a team moved together with its parent goes along inside
+// it and is not moved on its own.
+func (f *File) MoveRoots(ids []string) []string {
+ set := map[string]bool{}
+ for _, id := range ids {
+ set[id] = true
+ }
+ var out []string
+ seen := map[string]bool{}
+ for _, id := range ids {
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ carried := false
+ for _, a := range f.Ancestors(id) {
+ if set[a.ID] {
+ carried = true
+ break
+ }
+ }
+ if !carried {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// levels is how many levels team id and its open sub-teams take up: 1 for a
+// team with none under it. The walk is bounded by the list, so a loop in a
+// file not yet tidied cannot hang it.
+func (f *File) levels(id string) int { return f.levelsWithin(id, len(f.Teams)) }
+
+func (f *File) levelsWithin(id string, budget int) int {
+ most := 1
+ if budget <= 0 {
+ return most
+ }
+ for _, c := range f.Children(id) {
+ if c.Closed() {
+ continue
+ }
+ if n := 1 + f.levelsWithin(c.ID, budget-1); n > most {
+ most = n
+ }
+ }
+ return most
+}
+
+// MoveCheck reports whether the teams ids may go inside parent ("" the top
+// level), and when not, why. Among several teams the first that cannot go is
+// the answer; a team already under parent is not moved and does not block,
+// unless every one of them is (MoveBlockHere).
+func (f *File) MoveCheck(ids []string, parent string, d Defaults) (MoveBlock, bool) {
+ ids = f.MoveRoots(ids)
+ if len(ids) == 0 {
+ return MoveBlock{Kind: MoveBlockGone}, false
+ }
+ target := f.MoveTarget(parent)
+ var p Team
+ if target != "" {
+ var ok bool
+ if p, ok = f.Team(target); !ok {
+ return MoveBlock{Kind: MoveBlockGone, Team: target}, false
+ }
+ if p.Closed() {
+ return MoveBlock{Kind: MoveBlockClosed, Team: p.ID, Name: p.Name}, false
+ }
+ }
+ limit, from := d.DepthLimit, Origin{Kind: OriginSettings}
+ if target != "" {
+ e := f.Effective(target, d)
+ limit, from = e.DepthLimit, e.DepthFrom
+ }
+ moving := 0
+ for _, id := range ids {
+ t, ok := f.Team(id)
+ if !ok {
+ return MoveBlock{Kind: MoveBlockGone, Team: id}, false
+ }
+ if t.Root {
+ return MoveBlock{Kind: MoveBlockRoot, Team: t.ID, Name: t.Name}, false
+ }
+ if id == target {
+ return MoveBlock{Kind: MoveBlockSelf, Team: t.ID, Name: t.Name}, false
+ }
+ if target != "" && ParentLoops(f.Teams, id, target) {
+ return MoveBlock{Kind: MoveBlockInside, Team: t.ID, Name: t.Name}, false
+ }
+ if t.Parent == target {
+ continue
+ }
+ moving++
+ need := f.levels(id)
+ if depth := f.Depth(target); limit > 0 && depth+need > limit {
+ return MoveBlock{Kind: MoveBlockDepth, Team: p.ID, Name: p.Name, Depth: depth, Need: need,
+ Limit: limit, LimitFrom: from}, false
+ }
+ }
+ if moving == 0 {
+ return MoveBlock{Kind: MoveBlockHere, Team: p.ID, Name: p.Name}, false
+ }
+ return MoveBlock{}, true
+}
+
+// Move puts every team in ids inside parent ("" the top level), each team
+// carried along inside another moved one left where it is. It checks nothing
+// [File.SetParent] does not; ask [File.MoveCheck] first.
+func (f *File) Move(ids []string, parent string) error {
+ target := f.MoveTarget(parent)
+ for _, id := range f.MoveRoots(ids) {
+ if t, ok := f.Team(id); ok && t.Parent == target {
+ continue
+ }
+ if err := f.SetParent(id, target); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// ReportMove is one conversation whose manager a move changes: the manager it
+// reports to before and after, each with whether there is one.
+type ReportMove struct {
+ Key string
+ Before, After Report
+ Had, Has bool
+}
+
+// PoolMove is one moved team whose spend counts toward another capped pool:
+// Before and After are the teams that own the pool above it ("" none), and
+// BeforeCap and AfterCap those pools' daily caps.
+type PoolMove struct {
+ Team string
+ Before, After string
+ BeforeCap, AfterCap float64
+}
+
+// JudgeMove is one moved team whose conflicts another manager decides: Before
+// and After are the deciding teams ([File.LCA]), "" for the person.
+type JudgeMove struct {
+ Team string
+ Before, After string
+}
+
+// MoveEffect is what a move changes.
+type MoveEffect struct {
+ Reports []ReportMove
+ Pools []PoolMove
+ Judges []JudgeMove
+}
+
+// Changes reports whether the move changes anything a person is asked about.
+func (e MoveEffect) Changes() bool {
+ return len(e.Reports) > 0 || len(e.Pools) > 0 || len(e.Judges) > 0
+}
+
+// MoveEffects is what moving ids inside parent would change, computed on
+// copies of the file tidied as the store's write tidies them. The file is not
+// changed. An error is a move [File.SetParent] refuses.
+func (f *File) MoveEffects(ids []string, parent string, d Defaults) (MoveEffect, error) {
+ before := f.tidyCopy()
+ after := f.tidyCopy()
+ if err := after.Move(ids, parent); err != nil {
+ return MoveEffect{}, err
+ }
+ tidy(after.Teams)
+ var out MoveEffect
+ for _, id := range before.MoveRoots(ids) {
+ t, ok := before.Team(id)
+ if !ok {
+ continue
+ }
+ // Every conversation carried: the team's and every open team's under it.
+ keys := before.subtreeKeys(id)
+ for _, k := range keys {
+ was, had := before.Home(k)
+ now, has := after.Home(k)
+ if had != has || was.Manager != now.Manager || was.Team != now.Team {
+ out.Reports = append(out.Reports, ReportMove{Key: k, Before: was, After: now, Had: had, Has: has})
+ }
+ }
+ was, wasCap := before.poolAbove(t.Parent, d)
+ nt, _ := after.Team(id)
+ now, nowCap := after.poolAbove(nt.Parent, d)
+ if was != now {
+ out.Pools = append(out.Pools, PoolMove{Team: id, Before: was, After: now, BeforeCap: wasCap, AfterCap: nowCap})
+ }
+ if len(keys) > 0 {
+ jb, okb := before.LCA(keys...)
+ ja, oka := after.LCA(keys...)
+ if okb != oka || jb.ID != ja.ID {
+ out.Judges = append(out.Judges, JudgeMove{Team: id, Before: jb.ID, After: ja.ID})
+ }
+ }
+ }
+ return out, nil
+}
+
+// tidyCopy is a copy of f that shares nothing with it, tidied.
+func (f *File) tidyCopy() *File {
+ g := &File{Version: f.Version, Teams: make([]Team, len(f.Teams))}
+ for i, t := range f.Teams {
+ g.Teams[i] = t.Clone()
+ }
+ tidy(g.Teams)
+ return g
+}
+
+// subtreeKeys is every member key of team id and of the open teams under it,
+// each once, in file order.
+func (f *File) subtreeKeys(id string) []string {
+ in := map[string]bool{id: true}
+ for _, t := range f.Descendants(id) {
+ if !t.Closed() {
+ in[t.ID] = true
+ }
+ }
+ var out []string
+ seen := map[string]bool{}
+ for _, t := range f.Teams {
+ if !in[t.ID] {
+ continue
+ }
+ for _, m := range t.Members {
+ if !seen[m.Key] {
+ seen[m.Key] = true
+ out = append(out, m.Key)
+ }
+ }
+ }
+ return out
+}
+
+// poolAbove is the team owning the capped pool a team directly under parent
+// counts toward, and that pool's cap: "" and 0 for a team at the top or under
+// no cap. An inherited cap is its owner's one pool; the profile's default cap
+// is measured over the whole chain, so its pool is the top of it.
+func (f *File) poolAbove(parent string, d Defaults) (string, float64) {
+ if parent == "" {
+ return "", 0
+ }
+ e := f.Effective(parent, d)
+ if e.CapUSDDay <= 0 {
+ return "", 0
+ }
+ switch e.CapFrom.Kind {
+ case OriginTeam, OriginAncestor:
+ return e.CapFrom.Team, e.CapUSDDay
+ case OriginSettings:
+ top := parent
+ for _, a := range f.Ancestors(parent) {
+ if !a.Closed() {
+ top = a.ID
+ }
+ }
+ return top, e.CapUSDDay
+ }
+ return "", 0
+}
+
+// MoveNotice is one Traffic line a committed move writes: Team is whose log,
+// Entry the line. The store appends it; a frame never does.
+type MoveNotice struct {
+ Team string
+ Entry Entry
+}
+
+// MoveNotices is the Traffic written when ids have moved, read off the file
+// before the move and the file after it. A team whose parent did not change
+// contributes nothing, so a refused move (the file left as it was) writes
+// nothing, and asking twice about the same pair does not invent a second move.
+//
+// EACH AFFECTED TEAM GETS ONE [KindEvent] PER MEMBER THAT MOVED WITH THE TEAM.
+// The team that was left, and the moved team, say `@handle moved to harbor`.
+// The team that was joined says `@handle joined from ops`. A manager reads
+// those on its next turn through the ordinary Traffic read. The lines are
+// from codeaf to everyone and carry no member state, so they do not start a
+// wake of their own. A member with no handle is named by the team's name,
+// once, so a team of unnamed conversations is still told.
+func MoveNotices(before, after *File, ids []string) []MoveNotice {
+ if before == nil || after == nil {
+ return nil
+ }
+ var out []MoveNotice
+ for _, id := range before.MoveRoots(ids) {
+ was, ok := before.Team(id)
+ if !ok {
+ continue
+ }
+ now, ok := after.Team(id)
+ if !ok || now.Parent == was.Parent {
+ continue
+ }
+ to := placeName(after, now.Parent)
+ from := placeName(before, was.Parent)
+ for _, h := range moveHandles(was) {
+ moved := "@" + h + " moved to " + to
+ joined := "@" + h + " joined from " + from
+ // The moved team, and the team it left, both lost the line it had.
+ out = append(out, moveEvent(id, moved))
+ if was.Parent != "" {
+ out = append(out, moveEvent(was.Parent, moved))
+ }
+ // The team it joined gained it.
+ if now.Parent != "" {
+ out = append(out, moveEvent(now.Parent, joined))
+ }
+ }
+ }
+ return out
+}
+
+// moveHandles is who a move names: each member's handle, or the team's name
+// once when nobody has one.
+func moveHandles(t Team) []string {
+ var out []string
+ seen := map[string]bool{}
+ for _, m := range t.Members {
+ h := strings.TrimPrefix(strings.TrimSpace(m.Handle), "@")
+ if h == "" || seen[h] {
+ continue
+ }
+ seen[h] = true
+ out = append(out, h)
+ }
+ if len(out) == 0 && strings.TrimSpace(t.Name) != "" {
+ out = append(out, t.Name)
+ }
+ return out
+}
+
+// placeName is team id's name, or "the top" when id is the top level.
+func placeName(f *File, id string) string {
+ if id == "" {
+ return "the top"
+ }
+ if t, ok := f.Team(id); ok && t.Name != "" {
+ return t.Name
+ }
+ return id
+}
+
+// moveEvent is one move line, from codeaf to everyone, with no member state
+// so the wake watch does not start a turn for it.
+func moveEvent(team, text string) MoveNotice {
+ return MoveNotice{Team: team, Entry: Entry{
+ Kind: KindEvent, From: FromSystem, To: ToEveryone, Text: text,
+ }}
+}
+
+// MemberMoveNotices is the Traffic for conversations that left one team and
+// joined another between before and after. A team move that only changes a
+// parent is not one of these (that is [MoveNotices]). A file that did not
+// change membership writes nothing, so a refused transfer writes nothing.
+// Each team left says `@handle moved to harbor`. Each team joined says
+// `@handle joined from ops`.
+func MemberMoveNotices(before, after *File) []MoveNotice {
+ if before == nil || after == nil {
+ return nil
+ }
+ type seat struct {
+ team Team
+ mem Member
+ }
+ was := map[string][]seat{}
+ for _, t := range before.Teams {
+ for _, m := range t.Members {
+ if m.Key == "" {
+ continue
+ }
+ was[m.Key] = append(was[m.Key], seat{t, m})
+ }
+ }
+ now := map[string][]seat{}
+ for _, t := range after.Teams {
+ for _, m := range t.Members {
+ if m.Key == "" {
+ continue
+ }
+ now[m.Key] = append(now[m.Key], seat{t, m})
+ }
+ }
+ var out []MoveNotice
+ seen := map[string]bool{}
+ for key, froms := range was {
+ tos := now[key]
+ for _, from := range froms {
+ still := false
+ for _, to := range tos {
+ if to.team.ID == from.team.ID {
+ still = true
+ break
+ }
+ }
+ if still {
+ continue
+ }
+ h := moveHandle(from.mem)
+ if h == "" {
+ continue
+ }
+ for _, to := range tos {
+ if to.team.ID == from.team.ID {
+ continue
+ }
+ held := false
+ for _, back := range froms {
+ if back.team.ID == to.team.ID {
+ held = true
+ break
+ }
+ }
+ if held {
+ continue
+ }
+ mark := from.team.ID + "->" + to.team.ID + ":" + h
+ if seen[mark] {
+ continue
+ }
+ seen[mark] = true
+ out = append(out, moveEvent(from.team.ID, "@"+h+" moved to "+placeName(after, to.team.ID)))
+ out = append(out, moveEvent(to.team.ID, "@"+h+" joined from "+placeName(before, from.team.ID)))
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Team != out[j].Team {
+ return out[i].Team < out[j].Team
+ }
+ return out[i].Entry.Text < out[j].Entry.Text
+ })
+ return out
+}
+
+// moveHandle is the handle a move line uses, or "" when the member has none.
+func moveHandle(m Member) string {
+ return strings.TrimPrefix(strings.TrimSpace(m.Handle), "@")
+}
+
+// WriteMoveNotices appends each notice to that team's Traffic, once per
+// notice. A refused move hands none, and this writes none.
+func WriteMoveNotices(profileDir string, notes []MoveNotice) error {
+ var err error
+ for _, n := range notes {
+ if n.Team == "" || strings.TrimSpace(n.Entry.Text) == "" {
+ continue
+ }
+ if e := AppendTraffic(profileDir, n.Team, n.Entry); e != nil && err == nil {
+ err = e
+ }
+ }
+ return err
+}
diff --git a/internal/teams/move_test.go b/internal/teams/move_test.go
new file mode 100644
index 0000000000..2a6de24ad2
--- /dev/null
+++ b/internal/teams/move_test.go
@@ -0,0 +1,254 @@
+package teams
+
+import (
+ "strings"
+ "testing"
+)
+
+// moveFile is harbor (managed, $10 a day) over dock, with api at the top
+// level holding its own $3 cap, and a closed team shut beside them.
+//
+// harbor ◆ boss $10/day
+// dock
+// api $3/day (web, srv)
+// shut closed
+func moveFile() *File {
+ ten, three := 10.0, 3.0
+ f := &File{Teams: []Team{
+ {ID: "harbor", Name: "harbor", Members: []Member{{Key: "boss"}}, Manager: "boss",
+ Settings: Settings{CapUSDDay: &ten}},
+ {ID: "dock", Name: "dock", Parent: "harbor", Members: []Member{{Key: "crane"}}},
+ {ID: "api", Name: "api", Members: []Member{{Key: "web"}, {Key: "srv"}}, Settings: Settings{CapUSDDay: &three}},
+ {ID: "shut", Name: "shut", State: TeamClosed},
+ }}
+ tidy(f.Teams)
+ return f
+}
+
+// WHERE A TEAM MAY GO: never into itself or under itself, never into a closed
+// team, never past the depth limit, and a target it is already in is said.
+func TestMoveCheckSaysWhyATargetIsBlocked(t *testing.T) {
+ f := moveFile()
+ d := Defaults{DepthLimit: 2}
+ for _, c := range []struct {
+ ids []string
+ parent string
+ kind string
+ }{
+ {[]string{"harbor"}, "harbor", MoveBlockSelf},
+ {[]string{"harbor"}, "dock", MoveBlockInside},
+ {[]string{"api"}, "shut", MoveBlockClosed},
+ {[]string{"api"}, "dock", MoveBlockDepth},
+ {[]string{"dock"}, "harbor", MoveBlockHere},
+ {[]string{"nobody"}, "", MoveBlockGone},
+ } {
+ b, ok := f.MoveCheck(c.ids, c.parent, d)
+ if ok || b.Kind != c.kind {
+ t.Fatalf("%v into %q: got %+v ok %v, want %s", c.ids, c.parent, b, ok, c.kind)
+ }
+ }
+ b, _ := f.MoveCheck([]string{"api"}, "dock", d)
+ if b.Name != "dock" || b.Depth != 2 || b.Need != 1 || b.Limit != 2 || b.LimitFrom.Kind != OriginSettings {
+ t.Fatalf("the depth block lacks its facts: %+v", b)
+ }
+ if _, ok := f.MoveCheck([]string{"api"}, "harbor", d); !ok {
+ t.Fatal("api may not go into harbor")
+ }
+ // A team with a level under it needs two levels where it lands.
+ if _, ok := f.MoveCheck([]string{"harbor"}, "api", d); ok {
+ t.Fatal("harbor and dock under api stand three deep past a limit of two")
+ }
+ if _, ok := f.MoveCheck([]string{"dock"}, "", d); !ok {
+ t.Fatal("dock may not go to the top level")
+ }
+}
+
+// SEVERAL TEAMS MOVE AS ONE, and a team selected with its parent rides along
+// inside it rather than being moved out on its own.
+func TestMoveCarriesATeamSelectedWithItsParent(t *testing.T) {
+ f := moveFile()
+ if got := f.MoveRoots([]string{"dock", "harbor", "api"}); len(got) != 2 || got[0] != "harbor" || got[1] != "api" {
+ t.Fatalf("roots of the selection: %v", got)
+ }
+ if err := f.Move([]string{"harbor", "dock"}, "api"); err != nil {
+ t.Fatal(err)
+ }
+ h, _ := f.Team("harbor")
+ k, _ := f.Team("dock")
+ if h.Parent != "api" || k.Parent != "harbor" {
+ t.Fatalf("harbor under %q, dock under %q", h.Parent, k.Parent)
+ }
+}
+
+// THE TOP LEVEL IS THE ROOT when there is one: a move to "" writes the root.
+func TestMoveToTheTopLevelLandsUnderTheRoot(t *testing.T) {
+ f := moveFile()
+ root := f.MakeRoot(f.Teams[0].Made)
+ if got := f.MoveTarget(""); got != root {
+ t.Fatalf("the top level is %q, want the root %q", got, root)
+ }
+ if b, ok := f.MoveCheck([]string{root}, "harbor", Defaults{DepthLimit: 5}); ok || b.Kind != MoveBlockRoot {
+ t.Fatalf("the root may move: %+v", b)
+ }
+ if err := f.Move([]string{"dock"}, ""); err != nil {
+ t.Fatal(err)
+ }
+ if k, _ := f.Team("dock"); k.Parent != root {
+ t.Fatalf("dock at the top is under %q", k.Parent)
+ }
+}
+
+// WHAT A MOVE CHANGES: api into harbor puts its conversations under harbor's
+// manager, its spend in harbor's pool and its conflicts before harbor's
+// manager; a move inside one unmanaged, uncapped place changes nothing.
+func TestMoveEffectsNameAuthorityPoolAndJudge(t *testing.T) {
+ f := moveFile()
+ d := Defaults{DepthLimit: 5}
+ e, err := f.MoveEffects([]string{"api"}, "harbor", d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(e.Reports) != 2 || e.Reports[0].Had || !e.Reports[0].Has || e.Reports[0].After.Team != "harbor" {
+ t.Fatalf("the reports: %+v", e.Reports)
+ }
+ if len(e.Pools) != 1 || e.Pools[0].Before != "" || e.Pools[0].After != "harbor" || e.Pools[0].AfterCap != 10 {
+ t.Fatalf("the pool: %+v", e.Pools)
+ }
+ if len(e.Judges) != 1 || e.Judges[0].Before != "" || e.Judges[0].After != "harbor" {
+ t.Fatalf("the judge: %+v", e.Judges)
+ }
+ // The file itself did not move.
+ if a, _ := f.Team("api"); a.Parent != "" {
+ t.Fatal("MoveEffects moved the team")
+ }
+ // Two unmanaged teams at the top with no cap: nothing to ask.
+ g := &File{Teams: []Team{{ID: "a", Name: "a", Members: []Member{{Key: "x"}}}, {ID: "b", Name: "b"}}}
+ e, err = g.MoveEffects([]string{"a"}, "b", d)
+ if err != nil || e.Changes() {
+ t.Fatalf("a quiet move changes %+v (%v)", e, err)
+ }
+}
+
+// A COMMITTED MOVE WRITES ONE TRAFFIC LINE PER AFFECTED TEAM PER MEMBER, and a
+// refused move writes none. Writing the notices of that one move once is the
+// whole log: the same pair of files does not grow a second copy inside
+// MoveNotices, and a file that did not move has nothing to append.
+func TestMoveTrafficIsAppendedOnceAndNeverOnARefusal(t *testing.T) {
+ f := moveFile()
+ f.Teams = append(f.Teams, Team{ID: "ops", Name: "ops", Members: []Member{{Key: "lead", Handle: "lead"}}})
+ for i := range f.Teams {
+ if f.Teams[i].ID == "api" {
+ f.Teams[i].Parent = "ops"
+ f.Teams[i].Members[0].Handle = "web"
+ f.Teams[i].Members[1].Handle = "srv"
+ }
+ }
+ tidy(f.Teams)
+ before := f.tidyCopy()
+ d := Defaults{DepthLimit: 5}
+ if _, ok := f.MoveCheck([]string{"api"}, "shut", d); ok {
+ t.Fatal("a closed team accepted the move")
+ }
+ if n := MoveNotices(before, f, []string{"api"}); len(n) != 0 {
+ t.Fatalf("a refused move wrote %d lines", len(n))
+ }
+ if err := f.Move([]string{"api"}, "api"); err == nil {
+ t.Fatal("a team moved inside itself")
+ }
+ if n := MoveNotices(before, f, []string{"api"}); len(n) != 0 {
+ t.Fatalf("a move the store refused wrote %d lines", len(n))
+ }
+ if err := f.Move([]string{"api"}, "harbor"); err != nil {
+ t.Fatal(err)
+ }
+ notes := MoveNotices(before, f, []string{"api"})
+ want := map[string][]string{
+ "api": {"@web moved to harbor", "@srv moved to harbor"},
+ "ops": {"@web moved to harbor", "@srv moved to harbor"},
+ "harbor": {"@web joined from ops", "@srv joined from ops"},
+ }
+ if len(notes) != 6 {
+ t.Fatalf("got %d notices, want 6: %+v", len(notes), textsOf(notes))
+ }
+ got := map[string][]string{}
+ for _, n := range notes {
+ if n.Entry.Kind != KindEvent || n.Entry.From != FromSystem || n.Entry.To != ToEveryone || n.Entry.State != "" {
+ t.Fatalf("a move line is not an ordinary event: %+v", n.Entry)
+ }
+ got[n.Team] = append(got[n.Team], n.Entry.Text)
+ }
+ for team, lines := range want {
+ if strings.Join(got[team], "\n") != strings.Join(lines, "\n") {
+ t.Fatalf("%s traffic: %q, want %q", team, got[team], lines)
+ }
+ }
+ dir := t.TempDir()
+ if err := WriteMoveNotices(dir, notes); err != nil {
+ t.Fatal(err)
+ }
+ for team, lines := range want {
+ log, err := ReadTraffic(dir, team, "", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(log) != len(lines) {
+ t.Fatalf("%s has %d lines, want %d (written once)", team, len(log), len(lines))
+ }
+ for i, line := range lines {
+ if log[i].Text != line {
+ t.Fatalf("%s line %d is %q, want %q", team, i, log[i].Text, line)
+ }
+ }
+ }
+ // The move already happened: asking again about the file as it stands
+ // adds nothing, so a second commit of the same move cannot double the log.
+ if n := MoveNotices(f, f, []string{"api"}); len(n) != 0 {
+ t.Fatalf("the move that already landed wrote %d more lines", len(n))
+ }
+}
+
+// A CONVERSATION MOVED FROM ONE TEAM TO ANOTHER is one line on each side, and
+// a transfer that did not happen (the same membership) is none.
+func TestMemberMoveTrafficIsAppendedOnceAndNeverOnARefusal(t *testing.T) {
+ before := &File{Teams: []Team{
+ {ID: "ops", Name: "ops", Members: []Member{{Key: "k", Handle: "web"}}},
+ {ID: "harbor", Name: "harbor"},
+ }}
+ if n := MemberMoveNotices(before, before); len(n) != 0 {
+ t.Fatalf("a refused transfer wrote %+v", textsOf(n))
+ }
+ after := before.tidyCopy()
+ if err := after.AddMember("harbor", Member{Key: "k", Handle: "web"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := after.RemoveMember("ops", "k"); err != nil {
+ t.Fatal(err)
+ }
+ notes := MemberMoveNotices(before, after)
+ if len(notes) != 2 {
+ t.Fatalf("got %+v", textsOf(notes))
+ }
+ by := map[string]string{}
+ for _, n := range notes {
+ by[n.Team] = n.Entry.Text
+ }
+ if by["ops"] != "@web moved to harbor" || by["harbor"] != "@web joined from ops" {
+ t.Fatalf("texts %+v", by)
+ }
+ dir := t.TempDir()
+ if err := WriteMoveNotices(dir, notes); err != nil {
+ t.Fatal(err)
+ }
+ log, err := ReadTraffic(dir, "ops", "", 0)
+ if err != nil || len(log) != 1 || log[0].Text != "@web moved to harbor" {
+ t.Fatalf("ops log %+v (%v)", log, err)
+ }
+}
+
+func textsOf(notes []MoveNotice) []string {
+ out := make([]string, len(notes))
+ for i, n := range notes {
+ out[i] = n.Team + ": " + n.Entry.Text
+ }
+ return out
+}
diff --git a/internal/teams/nest_test.go b/internal/teams/nest_test.go
new file mode 100644
index 0000000000..3941c70173
--- /dev/null
+++ b/internal/teams/nest_test.go
@@ -0,0 +1,172 @@
+package teams
+
+// NESTING, AS THE STORE KEEPS IT: a conflict's ruling reaches every party as a
+// directive in its own team's log whoever decides it, a party never decides its
+// own case, and the global manager's members are the top-level managers.
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+)
+
+// siblings is P (manager pm) with two sub-teams, A (manager am; member web) and
+// B (no manager; member api), saved under dir.
+func siblings(t *testing.T, dir string) {
+ t.Helper()
+ must(t, Save(dir, []Team{
+ {ID: "pppppppppppp", Name: "harbor", Manager: "pm", Members: []Member{{Key: "pm", Handle: "boss"}, {Key: "am", Handle: "front"}}},
+ {ID: "aaaaaaaaaaaa", Name: "front", Parent: "pppppppppppp", Manager: "am", Members: []Member{{Key: "am", Handle: "lead"}, {Key: "web", Handle: "web"}}},
+ {ID: "bbbbbbbbbbbb", Name: "back", Parent: "pppppppppppp", Members: []Member{{Key: "api", Handle: "api"}}},
+ }))
+}
+
+func conflictBetween(t *testing.T, dir string) Packet {
+ t.Helper()
+ f, err := Load(dir)
+ must(t, err)
+ lca, ok := f.LCA("web", "api")
+ if !ok || lca.ID != "pppppppppppp" {
+ t.Fatalf("web and api meet at %+v %v, want harbor", lca, ok)
+ }
+ p, err := Raise(dir, Packet{Team: lca.ID, Origin: "aaaaaaaaaaaa", Kind: PacketConflict, RaisedBy: "web",
+ Question: "which shape does the signup form send?",
+ Parties: []Party{
+ {Key: "web", Handle: "web", Team: "aaaaaaaaaaaa", Context: "the form posts JSON"},
+ {Key: "api", Handle: "api", Team: "bbbbbbbbbbbb"},
+ },
+ Options: []Option{{Label: "JSON", Consequence: "@api changes the handler"}, {Label: "form data", Consequence: "@web rewrites the submit"}}})
+ must(t, err)
+ return p
+}
+
+// A CONFLICT'S LINES ARE IN EVERY INVOLVED TEAM, and its ruling is a directive
+// to each party in its own team's log, carrying the packet.
+func TestAConflictsRulingIsADirectiveToEveryPartyInItsOwnTeam(t *testing.T) {
+ dir := t.TempDir()
+ siblings(t, dir)
+ p := conflictBetween(t, dir)
+ for _, team := range []string{"pppppppppppp", "aaaaaaaaaaaa", "bbbbbbbbbbbb"} {
+ log, _ := ReadTraffic(dir, team, "", 0)
+ if len(log) != 1 || log[0].Kind != KindPacket || log[0].Packet != p.ID {
+ t.Fatalf("the raise is not in %s's traffic: %+v", team, log)
+ }
+ }
+ decided, err := Decide(dir, p.ID, "boss", "1", "the other endpoints take JSON")
+ must(t, err)
+ if decided.DecidedBy != "boss" {
+ t.Fatalf("decided by %q", decided.DecidedBy)
+ }
+ for team, handle := range map[string]string{"aaaaaaaaaaaa": "web", "bbbbbbbbbbbb": "api"} {
+ log, _ := ReadTraffic(dir, team, "", 0)
+ var ruling Entry
+ for _, e := range log {
+ if IsRuling(e) {
+ ruling = e
+ }
+ }
+ if ruling.To != handle || ruling.Member != handle || ruling.From != FromManager || ruling.Packet != p.ID {
+ t.Fatalf("%s's party was not directed: %+v", team, log)
+ }
+ for _, want := range []string{"ruling on the conflict " + p.ID, `◆ @boss (manager of "harbor")`, "JSON: @api changes the handler", "the other endpoints take JSON", "which shape"} {
+ if !strings.Contains(ruling.Text, want) {
+ t.Errorf("the ruling lacks %q: %s", want, ruling.Text)
+ }
+ }
+ }
+ // The decider's own team has the decision line and no directive.
+ log, _ := ReadTraffic(dir, "pppppppppppp", "", 0)
+ for _, e := range log {
+ if IsRuling(e) {
+ t.Fatalf("a ruling went to the decider's team: %+v", e)
+ }
+ }
+}
+
+// THE PERSON'S RULING IS THE PERSON'S, and says so.
+func TestThePersonsRulingIsFromThePerson(t *testing.T) {
+ dir := t.TempDir()
+ siblings(t, dir)
+ p := conflictBetween(t, dir)
+ _, err := Decide(dir, p.ID, Person, "keep both shapes for now", "")
+ must(t, err)
+ log, _ := ReadTraffic(dir, "bbbbbbbbbbbb", "", 0)
+ e := log[len(log)-1]
+ if !IsRuling(e) || e.From != FromYou || !strings.Contains(e.Text, "by the person: keep both shapes for now") {
+ t.Fatalf("the person's ruling: %+v", e)
+ }
+}
+
+// A PARTY NEVER DECIDES ITS OWN CASE, even when the packet waits on the team
+// it manages.
+func TestAManagerWhoIsAPartyIsNotTheDecider(t *testing.T) {
+ dir := t.TempDir()
+ siblings(t, dir)
+ p, err := Raise(dir, Packet{Team: "pppppppppppp", Origin: "aaaaaaaaaaaa", Kind: PacketConflict, RaisedBy: "web", Question: "who owns the form?",
+ Parties: []Party{{Key: "web", Handle: "web", Team: "aaaaaaaaaaaa"}, {Key: "pm", Handle: "boss", Team: "pppppppppppp"}},
+ Options: []Option{{Label: "web", Consequence: "web owns it"}}})
+ must(t, err)
+ if _, err := Decide(dir, p.ID, "boss", "1", ""); !errors.Is(err, ErrNotDecider) {
+ t.Fatalf("a party decided its own case: %v", err)
+ }
+ if _, err := Decide(dir, p.ID, Person, "1", ""); err != nil {
+ t.Fatalf("the person could not decide it: %v", err)
+ }
+}
+
+// THE GLOBAL MANAGER'S MEMBERS ARE THE TOP-LEVEL MANAGERS: seated in the root
+// with their handles while the root has a manager, and TopManagers is exactly
+// the ones who manage a top-level team now.
+func TestTheRootSeatsTheTopLevelManagers(t *testing.T) {
+ dir := t.TempDir()
+ must(t, Save(dir, []Team{
+ {ID: "aaaaaaaaaaaa", Name: "harbor", Manager: "hm", Members: []Member{{Key: "hm", Handle: "harbor"}}},
+ {ID: "bbbbbbbbbbbb", Name: "yard", Manager: "ym", Members: []Member{{Key: "ym", Handle: "yard"}}},
+ {ID: "cccccccccccc", Name: "dock", Parent: "aaaaaaaaaaaa", Manager: "dm", Members: []Member{{Key: "dm", Handle: "dock"}}},
+ {ID: "dddddddddddd", Name: "quiet"},
+ }))
+ root := ""
+ must(t, Update(dir, func(f *File) error {
+ root = f.MakeRoot(time.Time{})
+ if err := f.AddMember(root, Member{Key: "gm", Handle: "all"}); err != nil {
+ return err
+ }
+ return f.SetManager(root, "gm")
+ }))
+ f, err := Load(dir)
+ must(t, err)
+ r, _ := f.Root()
+ if !r.Holds("hm") || !r.Holds("ym") || r.Holds("dm") {
+ t.Fatalf("the root holds %+v; want the two top-level managers and not dock's", r.Members)
+ }
+ if m, _ := r.Member("ym"); m.Handle != "yard" {
+ t.Fatalf("yard's manager is %q in the root", m.Handle)
+ }
+ top := f.TopManagers()
+ if len(top) != 2 || top[0].Key != "hm" || top[1].Key != "ym" {
+ t.Fatalf("TopManagers: %+v", top)
+ }
+ if h, _ := f.Home("ym"); h.Team != root {
+ t.Fatalf("yard's manager reports to %+v, want the root", h)
+ }
+ // A top-level manager replaced is no longer one of the global manager's.
+ must(t, Update(dir, func(f *File) error {
+ if err := f.AddMember("bbbbbbbbbbbb", Member{Key: "y2", Handle: "yard2"}); err != nil {
+ return err
+ }
+ return f.SetManager("bbbbbbbbbbbb", "y2")
+ }))
+ f, _ = Load(dir)
+ top = f.TopManagers()
+ if len(top) != 2 || top[1].Key != "y2" {
+ t.Fatalf("after yard's manager changed: %+v", top)
+ }
+ // With no manager on the root nothing is seated.
+ must(t, Save(dir, []Team{{ID: "aaaaaaaaaaaa", Name: "harbor", Manager: "hm", Members: []Member{{Key: "hm"}}}}))
+ must(t, Update(dir, func(f *File) error { f.MakeRoot(time.Time{}); return nil }))
+ f, _ = Load(dir)
+ if r, _ := f.Root(); len(r.Members) != 0 {
+ t.Fatalf("a root with no manager was given members: %+v", r.Members)
+ }
+}
diff --git a/internal/teams/root.go b/internal/teams/root.go
new file mode 100644
index 0000000000..1dea3442b0
--- /dev/null
+++ b/internal/teams/root.go
@@ -0,0 +1,184 @@
+package teams
+
+import (
+ "errors"
+ "time"
+)
+
+// ── THE OPTIONAL GLOBAL MANAGER IS THE MANAGER OF A REAL ROOT TEAM ──────────
+//
+// The ruling's goal is a person who talks to the top manager and steps away.
+// With several top-level teams there is no top manager until the person makes
+// one, on the teams page's `All teams` row. That manager could have been a
+// special case beside the tree (a field on the file, a pseudo-team every walk
+// would have to know about); it is instead the manager of an ordinary team
+// marked Root that every other top-level team is moved under. So every rule
+// already written holds without a special case: two parties in different trees
+// meet at the root ([File.LCA]) and its manager decides instead of the person;
+// a top-level team's manager reports to it ([File.Home]); an override on the
+// root is what `· from All teams` means ([File.Effective]); and its spend is
+// the whole machine's teams ([TeamSpend]).
+//
+// THE ROOT IS NOT A LEVEL. [File.Depth] does not count it, so a depth limit
+// of three still means three levels of the person's own teams.
+//
+// tidy keeps it true: at most one root (the first), at the top, open, and
+// every other top-level team under it, so a team made later at the top level
+// lands under the root on the same write. [File.DissolveRoot] undoes it: the
+// root team is removed and its children are top-level again; the global
+// manager's conversation stays a conversation.
+//
+// Until the person asks, there is no root team, and the `All teams` row is a
+// row the interface draws over the top level with nothing stored behind it.
+//
+// THE GLOBAL MANAGER'S MEMBERS ARE THE TOP-LEVEL MANAGERS (ruling c-5: orders go
+// one level down, and a sub-team's manager is a member of the team above). So
+// while the root has a manager, tidy makes every open top-level team's manager
+// a member of the root, with the handle it has in its own team when that is
+// free there. Its directives then reach them by the ordinary road, its digest
+// lists them, and each reports to it by the ordinary home rule. It is only ever
+// added: a membership the person removes comes back while that conversation
+// still manages a top-level team, and one left behind by a manager who stopped
+// being one stays until the person removes it (the session's view of the root
+// shows only the current managers).
+
+// RootName is the root team's name.
+const RootName = "All teams"
+
+// ErrRoot is a change the root cannot take: closing it (dissolve it instead)
+// or putting it under another team.
+var ErrRoot = errors.New("teams: All teams holds every team; remove its manager or dissolve it instead")
+
+// Root is the root team, false when there is none.
+func (f *File) Root() (Team, bool) {
+ for _, t := range f.Teams {
+ if t.Root {
+ return t, true
+ }
+ }
+ return Team{}, false
+}
+
+// MakeRoot makes the root team, when there is none, and moves every other
+// top-level team under it. It answers the root's id. The caller then makes
+// the global manager with [File.AddMember] and [File.SetManager], in the same
+// write.
+func (f *File) MakeRoot(at time.Time) string {
+ if r, ok := f.Root(); ok {
+ return r.ID
+ }
+ if at.IsZero() {
+ at = time.Now()
+ }
+ root := Team{ID: NewID(), Name: RootName, Made: at, Root: true}
+ f.Teams = append([]Team{root}, f.Teams...)
+ tidyRoot(f.Teams)
+ return root.ID
+}
+
+// DissolveRoot removes the root team and puts every team under it back at the
+// top level. A file with no root is left as it is.
+func (f *File) DissolveRoot() {
+ r, ok := f.Root()
+ if !ok {
+ return
+ }
+ kept := f.Teams[:0:0]
+ for _, t := range f.Teams {
+ if t.ID == r.ID {
+ continue
+ }
+ if t.Parent == r.ID {
+ t.Parent = ""
+ }
+ kept = append(kept, t)
+ }
+ f.Teams = kept
+}
+
+// tidyRoot keeps one open root at the top with every other top-level team
+// under it, and reports whether it changed anything.
+func tidyRoot(teams []Team) bool {
+ changed := false
+ rootID := ""
+ for i := range teams {
+ t := &teams[i]
+ if !t.Root {
+ continue
+ }
+ if rootID != "" {
+ t.Root, changed = false, true
+ continue
+ }
+ rootID = t.ID
+ if t.Parent != "" {
+ t.Parent, changed = "", true
+ }
+ if t.Closed() {
+ t.State, t.ClosedAt, t.ClosedWith, changed = "", time.Time{}, "", true
+ }
+ }
+ if rootID == "" {
+ return changed
+ }
+ for i := range teams {
+ if teams[i].ID != rootID && teams[i].Parent == "" {
+ teams[i].Parent, changed = rootID, true
+ }
+ }
+ if seatTopManagers(teams, rootID) {
+ changed = true
+ }
+ return changed
+}
+
+// seatTopManagers makes every open top-level team's manager a member of the
+// root rootID while the root has a manager, and reports whether it added one.
+func seatTopManagers(teams []Team, rootID string) bool {
+ r := Index(teams, rootID)
+ if r < 0 || teams[r].Manager == "" {
+ return false
+ }
+ changed := false
+ for _, t := range teams {
+ if t.Parent != rootID || t.Closed() || t.Manager == "" || teams[r].Holds(t.Manager) {
+ continue
+ }
+ m, ok := t.Member(t.Manager)
+ if !ok {
+ continue
+ }
+ m.Home, m.Started = false, false
+ if m.Handle != "" && handleProblem(teams[r], m.Key, m.Handle) != nil {
+ m.Handle = ""
+ }
+ teams[r].Members = append(teams[r].Members, m)
+ assignHandles(&teams[r])
+ changed = true
+ }
+ return changed
+}
+
+// TopManagers is the members of the root who manage an open top-level team
+// right now, in the root's member order: the global manager's own members.
+// Anything else the root holds (a manager who stopped being one, a
+// conversation the person put there) is not one of them.
+func (f *File) TopManagers() []Member {
+ r, ok := f.Root()
+ if !ok {
+ return nil
+ }
+ managing := map[string]bool{}
+ for _, t := range f.Teams {
+ if t.Parent == r.ID && !t.Closed() && t.Manager != "" {
+ managing[t.Manager] = true
+ }
+ }
+ var out []Member
+ for _, m := range r.Members {
+ if managing[m.Key] && m.Key != r.Manager {
+ out = append(out, m)
+ }
+ }
+ return out
+}
diff --git a/internal/teams/spend.go b/internal/teams/spend.go
new file mode 100644
index 0000000000..8a369df923
--- /dev/null
+++ b/internal/teams/spend.go
@@ -0,0 +1,278 @@
+package teams
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Agent-Field/codeaf/internal/home"
+)
+
+// ── WHAT A TEAM HAS SPENT TODAY ─────────────────────────────────────────────
+//
+// A team's cap (teamsettings.go) is measured against what its conversations
+// spent in one local day, and that figure already exists: every model call
+// this machine makes is one line of the usage ledger (internal/session's
+// usage_ledger.go), /v3/usage.jsonl, carrying the local day, the cost,
+// the 16-hex id of the conversation it was made in (session) and, for the
+// work a conversation started, that conversation's id again (root). A member
+// is known here by its transcript path, //transcript.jsonl, and
+// the id is its folder's name. So a team's spend on a day is the sum of the
+// ledger's lines on that day whose session or root is one of its members, or
+// a member of any team under it: a cap is a POOL over the subtree.
+//
+// A CONVERSATION IN SEVERAL COUNTED TEAMS IS COUNTED ONCE, and a line whose
+// session and root are both members is one line. A sub-team closed today still
+// counts toward its parent's day: the money was spent, and a closed team
+// spends nothing more.
+//
+// IT IS READ ONCE AND THEN ONLY WHAT WAS APPENDED. The ledger is machine-wide
+// and grows by a line per call, and the teams page asks about spend on its
+// clock. So the ledger is folded, once per process, into totals by day and by
+// (session, root) pair, and afterwards a read whose stamp has not moved is
+// answered from memory and one that grew reads from the byte the last read
+// stopped at. This package does not import internal/session (it imports this
+// one), so it reads the five fields it needs itself; internal/session's
+// TestTeamSpendReadsTheSessionsLedger pins that both name the same file.
+//
+// NOTHING HERE ENFORCES A CAP. It is the read model the session's cap check
+// and the teams page's `$1.20 of $5 today` are both drawn from.
+
+// UsageLedgerPath is the machine's usage ledger, the same path
+// internal/session's UsageLedgerPath names.
+func UsageLedgerPath() string { return home.Join("v3", "usage.jsonl") }
+
+// dayLayout is the ledger's day, the LOCAL calendar day.
+const dayLayout = "2006-01-02"
+
+// Today is today's local day in the ledger's spelling.
+func Today() string { return time.Now().Local().Format(dayLayout) }
+
+// Spend is what a team and every team under it spent on one day.
+type Spend struct {
+ Team string `json:"team"`
+ Day string `json:"day"`
+ USD float64 `json:"usd"`
+ Calls int `json:"calls"`
+ // ByMember is each counted conversation's share, by conversation key.
+ ByMember map[string]float64 `json:"by_member,omitempty"`
+}
+
+// TeamSpend is team teamID's spend on day ("2006-01-02", local) from this
+// machine's usage ledger.
+func TeamSpend(profileDir, teamID, day string) (Spend, error) {
+ return TeamSpendIn(profileDir, UsageLedgerPath(), teamID, day)
+}
+
+// TeamSpendIn is [TeamSpend] against the ledger at ledger.
+func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) {
+ f, err := Load(profileDir)
+ if err != nil {
+ return Spend{}, err
+ }
+ out := Spend{Team: teamID, Day: day}
+ team, ok := f.Team(teamID)
+ if !ok {
+ return out, nil
+ }
+ ids := map[string]string{} // session id -> member key
+ for _, t := range append([]Team{team}, f.Descendants(teamID)...) {
+ for _, m := range t.Members {
+ for _, id := range sessionIDs(m.Key) {
+ if _, ok := ids[id]; !ok {
+ ids[id] = m.Key
+ }
+ }
+ }
+ }
+ totals, err := spendCache.day(ledger, day)
+ if err != nil {
+ return out, err
+ }
+ for pair, sum := range totals {
+ key, ok := ids[pair.session]
+ if !ok {
+ key, ok = ids[pair.root]
+ }
+ if !ok {
+ continue
+ }
+ out.USD += sum.usd
+ out.Calls += sum.calls
+ if out.ByMember == nil {
+ out.ByMember = map[string]float64{}
+ }
+ out.ByMember[key] += sum.usd
+ }
+ return out, nil
+}
+
+// SpendStamp is one stamp over what a team's spend is read from: the teams
+// file (membership) and the ledger. Equal stamps on one day are one answer.
+func SpendStamp(profileDir, ledger string) string {
+ return Stamp(profileDir) + "|" + stampOf(ledger)
+}
+
+// TeamSpendStamp is the stamp of one team's day as [TeamSpend] reads it: the
+// day, the team, and [SpendStamp] over the machine's ledger. The engine and a
+// local window both answer "same" from it.
+func TeamSpendStamp(profileDir, teamID, day string) string {
+ return day + "|" + teamID + "|" + SpendStamp(profileDir, UsageLedgerPath())
+}
+
+// sessionIDs is the ledger ids a conversation key can carry: its folder's
+// name, the ordinary layout, and for an older flat file its own name.
+func sessionIDs(key string) []string {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return nil
+ }
+ var out []string
+ if dir := filepath.Base(filepath.Dir(key)); isSessionID(dir) {
+ out = append(out, dir)
+ }
+ if base := strings.TrimSuffix(filepath.Base(key), filepath.Ext(key)); isSessionID(base) {
+ out = append(out, base)
+ }
+ return out
+}
+
+// isSessionID reports whether s looks like a session id: 16 hex digits.
+func isSessionID(s string) bool {
+ if len(s) != 16 {
+ return false
+ }
+ for _, r := range s {
+ if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+// spendPair is where a ledger line's money went.
+type spendPair struct{ session, root string }
+
+type spendSum struct {
+ usd float64
+ calls int
+}
+
+// ledgerFold is one ledger as read so far.
+type ledgerFold struct {
+ stamp string
+ offset int64
+ days map[string]map[spendPair]spendSum
+}
+
+// spendMemory is every ledger folded so far, shared by the process.
+type spendMemory struct {
+ mu sync.Mutex
+ ledgers map[string]*ledgerFold
+ // reads counts the bytes read, for a test to see a quiet ledger costs none.
+ reads int64
+}
+
+var spendCache spendMemory
+
+// ledgerLine is the five fields of a usage line this package reads.
+type ledgerLine struct {
+ At time.Time `json:"at"`
+ Day string `json:"day"`
+ Calls int `json:"calls"`
+ USD float64 `json:"usd"`
+ Session string `json:"session"`
+ Root string `json:"root"`
+}
+
+// day is the ledger's totals for one day, a copy, brought up to date first.
+func (m *spendMemory) day(path, day string) (map[spendPair]spendSum, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.ledgers == nil {
+ m.ledgers = map[string]*ledgerFold{}
+ }
+ f, err := m.refresh(path)
+ if err != nil {
+ return nil, err
+ }
+ out := make(map[spendPair]spendSum, len(f.days[day]))
+ for k, v := range f.days[day] {
+ out[k] = v
+ }
+ return out, nil
+}
+
+// refresh brings the fold of path up to date: nothing when its stamp has not
+// moved, the appended bytes when it grew, the whole file when it is new here
+// or shrank. The caller holds m.mu.
+func (m *spendMemory) refresh(path string) (*ledgerFold, error) {
+ stamp := stampOf(path)
+ f := m.ledgers[path]
+ if f != nil && f.stamp == stamp {
+ return f, nil
+ }
+ if stamp == MissingStamp {
+ f = &ledgerFold{stamp: stamp, days: map[string]map[spendPair]spendSum{}}
+ m.ledgers[path] = f
+ return f, nil
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, err
+ }
+ if f == nil || info.Size() < f.offset {
+ f = &ledgerFold{days: map[string]map[spendPair]spendSum{}}
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ if _, err := file.Seek(f.offset, io.SeekStart); err != nil {
+ return nil, err
+ }
+ r := bufio.NewReaderSize(file, 32<<10)
+ for {
+ raw, err := r.ReadBytes('\n')
+ if len(raw) > 0 && raw[len(raw)-1] == '\n' {
+ m.reads += int64(len(raw))
+ f.offset += int64(len(raw))
+ var line ledgerLine
+ if json.Unmarshal(raw, &line) == nil && (line.Session != "" || line.Root != "") {
+ day := line.Day
+ if day == "" && !line.At.IsZero() {
+ day = line.At.Local().Format(dayLayout)
+ }
+ calls := line.Calls
+ if calls == 0 {
+ calls = 1
+ }
+ totals := f.days[day]
+ if totals == nil {
+ totals = map[spendPair]spendSum{}
+ f.days[day] = totals
+ }
+ pair := spendPair{session: line.Session, root: line.Root}
+ sum := totals[pair]
+ sum.usd += line.USD
+ sum.calls += calls
+ totals[pair] = sum
+ }
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+ f.stamp = stamp
+ m.ledgers[path] = f
+ return f, nil
+}
diff --git a/internal/teams/spend_test.go b/internal/teams/spend_test.go
new file mode 100644
index 0000000000..c8d3cdfeda
--- /dev/null
+++ b/internal/teams/spend_test.go
@@ -0,0 +1,86 @@
+package teams
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// A TEAM'S DAY IS ITS SUBTREE'S LEDGER LINES, EACH ONCE. harbor holds one
+// conversation, dock under it another; a third is in neither. A task's line
+// counts through its root; a line on another day, or of an outsider, does not.
+func TestTeamSpendSumsTheSubtreeFromTheLedger(t *testing.T) {
+ dir := t.TempDir()
+ a, b, x := "0123456789abcdef", "fedcba9876543210", "1111111111111111"
+ key := func(id string) string { return filepath.Join("/srv/sessions", id, "transcript.jsonl") }
+ must(t, Save(dir, []Team{
+ {ID: "aaaaaaaaaaaa", Name: "harbor", Members: []Member{{Key: key(a)}}},
+ {ID: "bbbbbbbbbbbb", Name: "dock", Parent: "aaaaaaaaaaaa", Members: []Member{{Key: key(b)}, {Key: key(a)}}},
+ }))
+ ledger := filepath.Join(dir, "usage.jsonl")
+ lines := []string{
+ line("2026-09-24", 1.00, a, ""),
+ line("2026-09-24", 0.25, "9999999999999999", b), // a task b started
+ line("2026-09-24", 5.00, x, ""), // somebody else
+ line("2026-09-23", 7.00, a, ""), // yesterday
+ line("2026-09-24", 0.50, b, ""),
+ }
+ writeLedger(t, ledger, lines...)
+
+ got, err := TeamSpendIn(dir, ledger, "aaaaaaaaaaaa", "2026-09-24")
+ if err != nil || fmt.Sprintf("%.2f", got.USD) != "1.75" || got.Calls != 3 {
+ t.Fatalf("harbor's day: %+v, %v", got, err)
+ }
+ if fmt.Sprintf("%.2f", got.ByMember[key(b)]) != "0.75" {
+ t.Fatalf("by member: %+v", got.ByMember)
+ }
+ dock, _ := TeamSpendIn(dir, ledger, "bbbbbbbbbbbb", "2026-09-24")
+ if fmt.Sprintf("%.2f", dock.USD) != "1.75" {
+ t.Fatalf("dock (which holds a too) spent %v", dock.USD)
+ }
+ if none, _ := TeamSpendIn(dir, ledger, "cccccccccccc", "2026-09-24"); none.USD != 0 {
+ t.Fatal("an unknown team spent something")
+ }
+
+ // Quiet: no bytes read. Grown: only the appended line.
+ before := spendCache.bytes()
+ _, _ = TeamSpendIn(dir, ledger, "aaaaaaaaaaaa", "2026-09-24")
+ if spendCache.bytes() != before {
+ t.Fatal("a quiet ledger was read again")
+ }
+ extra := line("2026-09-24", 2.00, a, "")
+ appendLedger(t, ledger, extra)
+ again, _ := TeamSpendIn(dir, ledger, "aaaaaaaaaaaa", "2026-09-24")
+ if fmt.Sprintf("%.2f", again.USD) != "3.75" || spendCache.bytes()-before != int64(len(extra)+1) {
+ t.Fatalf("after one more line: %v, read %d bytes", again.USD, spendCache.bytes()-before)
+ }
+}
+
+func line(day string, usd float64, session, root string) string {
+ return fmt.Sprintf(`{"at":"2026-09-24T10:00:00Z","day":%q,"usd":%v,"calls":1,"session":%q,"root":%q}`, day, usd, session, root)
+}
+
+func writeLedger(t *testing.T, path string, lines ...string) {
+ t.Helper()
+ body := ""
+ for _, l := range lines {
+ body += l + "\n"
+ }
+ must(t, os.WriteFile(path, []byte(body), 0o600))
+}
+
+func appendLedger(t *testing.T, path, l string) {
+ t.Helper()
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
+ must(t, err)
+ _, err = f.WriteString(l + "\n")
+ must(t, err)
+ must(t, f.Close())
+}
+
+func (m *spendMemory) bytes() int64 {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.reads
+}
diff --git a/internal/teams/store_test.go b/internal/teams/store_test.go
index c37a704d64..17a92c311c 100644
--- a/internal/teams/store_test.go
+++ b/internal/teams/store_test.go
@@ -406,3 +406,82 @@ func TestLoadRepairsIdsManagersAndHandles(t *testing.T) {
t.Fatalf("the repair was not written:\n%+v\n%+v", again.Teams, f.Teams)
}
}
+
+// A FILE FROM BEFORE THE WRAP-UP CLOCK HAS NO wrap KEY, and loading it leaves
+// the team with none. Writing it back does not invent one.
+func TestAnOldTeamsFileLoadsWithNoWrapUp(t *testing.T) {
+ dir := t.TempDir()
+ in := `{"version":2,"teams":[{"id":"abcdefabcdef","name":"harbor","parent":"","members":[{"key":"k1","file":"","where":"","word":"one"}],` +
+ `"manager":"k1","hue":120,"tier":1,"made":"2026-09-20T10:00:00Z"}]}`
+ if err := os.WriteFile(Path(dir), []byte(in), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ f, err := Load(dir)
+ if err != nil || len(f.Teams) != 1 || f.Teams[0].Wrap != nil || f.Teams[0].Name != "harbor" {
+ t.Fatalf("old file: %+v %v", f, err)
+ }
+ if err := Save(dir, f.Teams); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(Path(dir))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), `"wrap"`) {
+ t.Fatalf("an old team grew a wrap key:\n%s", raw)
+ }
+}
+
+// THE CLOCK SURVIVES THE FILE. Change writes the start and the bound, the
+// stamp moves, a reload reads them back, and a second SetWrap keeps the first.
+func TestWrapUpRoundTripThroughChange(t *testing.T) {
+ dir := t.TempDir()
+ started := time.Date(2026, 9, 24, 15, 4, 0, 0, time.UTC)
+ bound := 15 * time.Minute
+ if err := Save(dir, []Team{{ID: "abcdefabcdef", Name: "harbor", hued: true, Hue: 1}}); err != nil {
+ t.Fatal(err)
+ }
+ before := Stamp(dir)
+ wrote, stamp, err := Change(dir, func(f *File) error {
+ return f.SetWrap("abcdefabcdef", started, bound)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stamp == before || stamp == MissingStamp || stamp != Stamp(dir) {
+ t.Fatalf("stamp %q, was %q, file %q", stamp, before, Stamp(dir))
+ }
+ if wrote == nil || wrote.Teams[0].Wrap == nil || !wrote.Teams[0].Wrap.Started.Equal(started) || wrote.Teams[0].Wrap.Bound != bound {
+ t.Fatalf("wrote %+v", wrote)
+ }
+ got, err := Load(dir)
+ if err != nil || got.Teams[0].Wrap == nil || !got.Teams[0].Wrap.Started.Equal(started) || got.Teams[0].Wrap.Bound != bound {
+ t.Fatalf("reloaded %+v %v", got, err)
+ }
+ later := started.Add(time.Hour)
+ if _, _, err := Change(dir, func(f *File) error {
+ return f.SetWrap("abcdefabcdef", later, time.Minute)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = Load(dir)
+ if !got.Teams[0].Wrap.Started.Equal(started) || got.Teams[0].Wrap.Bound != bound {
+ t.Fatalf("a second wrap-up reset the clock: %+v", got.Teams[0].Wrap)
+ }
+ if _, _, err := ChangeIf(dir, "not-the-stamp", func(f *File) error {
+ return f.ClearWrap("abcdefabcdef")
+ }); !errors.Is(err, ErrStale) {
+ t.Fatalf("a stale clear: %v", err)
+ }
+ if _, _, err := Change(dir, func(f *File) error { return f.ClearWrap("abcdefabcdef") }); err != nil {
+ t.Fatal(err)
+ }
+ got, _ = Load(dir)
+ if got.Teams[0].Wrap != nil {
+ t.Fatalf("cleared wrap still there: %+v", got.Teams[0].Wrap)
+ }
+ raw, _ := os.ReadFile(Path(dir))
+ if strings.Contains(string(raw), `"wrap"`) {
+ t.Fatalf("a cleared wrap was still written:\n%s", raw)
+ }
+}
diff --git a/internal/teams/teams.go b/internal/teams/teams.go
index beaa8714b5..2696503522 100644
--- a/internal/teams/teams.go
+++ b/internal/teams/teams.go
@@ -25,6 +25,16 @@ type Member struct {
// Handle is the member's short name inside this team (handle.go). It is
// empty only while the member has no title to derive one from.
Handle string `json:"handle,omitempty"`
+ // Home marks the one membership, among all of this conversation's, that
+ // names the manager it reports to (home.go): the nearest manager up this
+ // team's chain. At most one membership of a key carries it, and only one
+ // with a manager somewhere up its chain. [File.SetHome] moves it; tidy
+ // picks it when there is none and never moves a valid one.
+ Home bool `json:"home,omitempty"`
+ // Started says this membership was made by the team manager's team_start
+ // (a [KindStart] the interface carried out), which is the second rule a
+ // home is picked by.
+ Started bool `json:"started,omitempty"`
// HandleBy is who chose Handle: [HandleByWords] for the word list's
// instant guess, [HandleByModel] for the title model's word, and
// [HandleByTyped] for a handle a person or the manager gave. "" is a
@@ -49,12 +59,26 @@ type Team struct {
Hue float64
Tier int
Made time.Time
- // WakeOff turns the team's auto-wake off: a directive no longer starts an
- // idle member's turn, and a member's reply no longer starts the manager's.
- // Everything is still delivered, at the next turn each conversation takes.
- // It is stored as "wake": false and only then, so a file that never said
- // anything about waking wakes.
- WakeOff bool
+ // State is [TeamOpen] or [TeamClosed] (lifecycle.go); the empty string a
+ // file from before the lifecycle wrote reads as open. ClosedAt is when it
+ // closed, ClosedWith the id of the team whose close closed it (itself, or
+ // the ancestor a cascade came from), and Report the id of its closing
+ // report packet, "" for a team closed without one.
+ State string
+ ClosedAt time.Time
+ ClosedWith string
+ Report string
+ // Root marks the one team that holds every other (root.go): the `All
+ // teams` row, made when the person gives it a manager.
+ Root bool
+ // Settings are the team's own delegation overrides (teamsettings.go),
+ // each unset field inheriting from the parent chain and then the
+ // profile's `teams.` defaults. They are stored flat on the team.
+ Settings Settings
+ // Wrap is a wrap-up in progress (wrap.go): when it started and how long
+ // it was given. Nil is none, which is also what a file from before the
+ // field was kept reads as.
+ Wrap *Wrap
// hued says the team has a colour: the file gave it one or [Team.SetHue]
// did. A hue of 0 is a real hue, so absence is kept apart from the value.
@@ -72,7 +96,10 @@ type File struct {
// knownFields is every key [Team] reads itself.
var knownFields = map[string]bool{
"id": true, "name": true, "parent": true, "members": true, "manager": true,
- "hue": true, "tier": true, "made": true, "wake": true,
+ "hue": true, "tier": true, "made": true,
+ "state": true, "closed_at": true, "closed_with": true, "report": true, "root": true,
+ "questions_up": true, "cap_usd_day": true, "depth_limit": true, "sub_share": true, "wake": true,
+ "wrap": true,
}
// wireTeam is the stored shape. Hue and Tier are pointers so a team with no
@@ -87,7 +114,18 @@ type wireTeam struct {
Hue *float64 `json:"hue,omitempty"`
Tier *int `json:"tier,omitempty"`
Made time.Time `json:"made"`
- Wake *bool `json:"wake,omitempty"`
+ // The lifecycle, written only for a closed team.
+ State string `json:"state,omitempty"`
+ ClosedAt *time.Time `json:"closed_at,omitempty"`
+ ClosedWith string `json:"closed_with,omitempty"`
+ Report string `json:"report,omitempty"`
+ Root bool `json:"root,omitempty"`
+ // Wrap is written only while a wrap-up is in progress, so a team with
+ // none is written exactly as before.
+ Wrap *Wrap `json:"wrap,omitempty"`
+ // The overrides are written flat beside the fields above, each only when
+ // set, so a team with none is written exactly as before.
+ Settings
}
// UnmarshalJSON reads a team, keeping every field it does not know.
@@ -100,8 +138,11 @@ func (t *Team) UnmarshalJSON(raw []byte) error {
if err := json.Unmarshal(raw, &all); err != nil {
return err
}
- *t = Team{ID: w.ID, Name: w.Name, Parent: w.Parent, Members: w.Members, Manager: w.Manager, Made: w.Made}
- t.WakeOff = w.Wake != nil && !*w.Wake
+ *t = Team{ID: w.ID, Name: w.Name, Parent: w.Parent, Members: w.Members, Manager: w.Manager, Made: w.Made,
+ Settings: w.Settings, State: w.State, ClosedWith: w.ClosedWith, Report: w.Report, Root: w.Root, Wrap: w.Wrap}
+ if w.ClosedAt != nil {
+ t.ClosedAt = *w.ClosedAt
+ }
if w.Hue != nil {
t.Hue, t.hued = *w.Hue, true
}
@@ -123,10 +164,16 @@ func (t *Team) UnmarshalJSON(raw []byte) error {
// MarshalJSON writes the known fields in their order, then any field a later
// build wrote, sorted, exactly as it was read.
func (t Team) MarshalJSON() ([]byte, error) {
- w := wireTeam{ID: t.ID, Name: t.Name, Parent: t.Parent, Members: t.Members, Manager: t.Manager, Made: t.Made}
- if t.WakeOff {
- off := false
- w.Wake = &off
+ w := wireTeam{ID: t.ID, Name: t.Name, Parent: t.Parent, Members: t.Members, Manager: t.Manager, Made: t.Made,
+ Settings: t.Settings, ClosedWith: t.ClosedWith, Report: t.Report, Root: t.Root, Wrap: t.Wrap}
+ // A state this build does not know is written back as it was read, so a
+ // later build's word survives; open is written as nothing.
+ if t.State != "" && t.State != TeamOpen {
+ w.State = t.State
+ }
+ if !t.ClosedAt.IsZero() {
+ at := t.ClosedAt
+ w.ClosedAt = &at
}
if t.Hued() {
hue, tier := t.Hue, t.Tier
@@ -154,10 +201,10 @@ func (t Team) MarshalJSON() ([]byte, error) {
return b.Bytes(), nil
}
-// Wakes reports whether team traffic wakes the team's idle conversations: a
-// directive its member, a reply or an event its manager. It is on unless the
-// team was turned off ([Team.WakeOff]).
-func (t Team) Wakes() bool { return !t.WakeOff }
+// Wakes reports whether the team's OWN setting leaves waking on: true unless
+// the team itself says "wake": false. It does not walk the chain; whether team
+// traffic really wakes a conversation is [Effective].Wake, which inherits.
+func (t Team) Wakes() bool { return t.Settings.Wake == nil || *t.Settings.Wake }
// Hued reports whether the team has a colour. A team built in code with a
// non-zero hue or tier counts as coloured.
@@ -205,6 +252,11 @@ func (t Team) member(key string) int {
// Clone is a copy of t that shares nothing with it.
func (t Team) Clone() Team {
t.Members = append([]Member(nil), t.Members...)
+ t.Settings = t.Settings.clone()
+ if t.Wrap != nil {
+ w := *t.Wrap
+ t.Wrap = &w
+ }
if t.extra != nil {
extra := make(map[string]json.RawMessage, len(t.extra))
for k, v := range t.extra {
@@ -298,6 +350,9 @@ func (f *File) SetParent(id, parent string) error {
if err != nil {
return err
}
+ if f.Teams[i].Root && parent != "" {
+ return ErrRoot
+ }
if parent != "" {
if Index(f.Teams, parent) < 0 {
return fmt.Errorf("no team %s", parent)
@@ -417,8 +472,9 @@ func (f *File) SetHandle(id, key, h string) error {
// tidy puts a list in order, in place, and reports whether it changed
// anything: an id for every team (and a new one for an id used twice), a
// parent that exists and does not lead back, handles for members with titles
-// and no valid unique handle, and a manager that is a member. Colour is not
-// its business ([File.Colour] is).
+// and no valid unique handle, a manager that is a member, overrides inside
+// their bands, and one home for every conversation that has a manager to
+// report to (home.go). Colour is not its business ([File.Colour] is).
func tidy(teams []Team) bool {
changed := false
seen := map[string]bool{}
@@ -450,6 +506,17 @@ func tidy(teams []Team) bool {
teams[i].Manager = ""
changed = true
}
+ if teams[i].Settings.tidy() {
+ changed = true
+ }
+ }
+ // The root holds every other top-level team (root.go).
+ if tidyRoot(teams) {
+ changed = true
+ }
+ // Homes last: they depend on the managers and parents settled above.
+ if assignHomes(teams) {
+ changed = true
}
return changed
}
diff --git a/internal/teams/teams_test.go b/internal/teams/teams_test.go
index 87a445d112..4e848d9229 100644
--- a/internal/teams/teams_test.go
+++ b/internal/teams/teams_test.go
@@ -132,8 +132,10 @@ func TestAddMemberAssignsAHandleOnce(t *testing.T) {
}
}
-// A TEAM WAKES UNLESS IT WAS TURNED OFF, and only the off is written: a file
-// that never mentioned waking reads as on, and on is written as nothing.
+// A TEAM WAKES UNLESS IT WAS TURNED OFF, and only what was set is written: a
+// file that never mentioned waking reads as unset (inherit, on by default),
+// unset is written as nothing, and the stored "wake": false spelling from
+// before wake was inheritable still reads as off.
func TestATeamWakesUnlessTurnedOff(t *testing.T) {
var fresh Team
if err := json.Unmarshal([]byte(`{"id":"a","name":"a"}`), &fresh); err != nil {
@@ -149,7 +151,8 @@ func TestATeamWakesUnlessTurnedOff(t *testing.T) {
if strings.Contains(string(raw), `"wake"`) {
t.Fatalf("a team that wakes wrote the field: %s", raw)
}
- fresh.WakeOff = true
+ off := false
+ fresh.Settings.Wake = &off
raw, err = json.Marshal(fresh)
if err != nil {
t.Fatal(err)
@@ -171,3 +174,38 @@ func TestATeamWakesUnlessTurnedOff(t *testing.T) {
t.Fatal("wake true reads as off")
}
}
+
+// WAKE IS INHERITED LIKE THE OTHER SETTINGS. Unset everywhere, it is the
+// profile's default with origin Settings; a parent's stored "wake": false (the
+// spelling a file from before this build wrote) turns its sub-teams off too,
+// named as from the parent; a child's own true overrides it.
+func TestWakeInheritsWithProvenance(t *testing.T) {
+ var f File
+ raw := `{"version":2,"teams":[
+ {"id":"aaaaaaaaaaaa","name":"harbor","wake":false},
+ {"id":"bbbbbbbbbbbb","name":"dock","parent":"aaaaaaaaaaaa"},
+ {"id":"cccccccccccc","name":"yard"}]}`
+ if err := json.Unmarshal([]byte(raw), &f); err != nil {
+ t.Fatal(err)
+ }
+ on := Defaults{Wake: true}
+ if e := f.Effective("cccccccccccc", on); !e.Wake || e.WakeFrom.Kind != OriginSettings {
+ t.Fatalf("an unset team does not take the default: %+v", e)
+ }
+ if e := f.Effective("aaaaaaaaaaaa", on); e.Wake || e.WakeFrom.Kind != OriginTeam {
+ t.Fatalf("the stored wake false is not the team's own off: %+v", e)
+ }
+ if e := f.Effective("bbbbbbbbbbbb", on); e.Wake || e.WakeFrom.Words() != "from harbor" {
+ t.Fatalf("a sub-team does not inherit its parent's off: %+v", e)
+ }
+ yes := true
+ if err := f.SetSettings("bbbbbbbbbbbb", func(s *Settings) { s.Wake = &yes }); err != nil {
+ t.Fatal(err)
+ }
+ if e := f.Effective("bbbbbbbbbbbb", on); !e.Wake || e.WakeFrom.Kind != OriginTeam {
+ t.Fatalf("a sub-team's own on does not override: %+v", e)
+ }
+ if e := f.Effective("cccccccccccc", Defaults{}); e.Wake {
+ t.Fatalf("a profile default of off is not honoured: %+v", e)
+ }
+}
diff --git a/internal/teams/teamsettings.go b/internal/teams/teamsettings.go
new file mode 100644
index 0000000000..1ce77917db
--- /dev/null
+++ b/internal/teams/teamsettings.go
@@ -0,0 +1,297 @@
+package teams
+
+import (
+ "errors"
+ "math"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+)
+
+// ── A TEAM'S DELEGATION SETTINGS, AND WHERE EACH ONE CAME FROM ──────────────
+//
+// Five things about how work is delegated can differ from team to team:
+// whether team traffic wakes an idle conversation (wake: a directive its
+// member, a member's reply its manager), whether a member's clarifying
+// questions go up to the manager first (questions_up), what the team and everything under it may spend in a local
+// day (cap_usd_day, 0 for no cap), how many levels of teams may stand under it
+// counting the top as one (depth_limit), and what share of its own cap a new
+// sub-team is handed (sub_share, a fraction in (0, 1]).
+//
+// EACH IS OPTIONAL, AND UNSET IS INHERIT. A team that says nothing takes its
+// parent's value, the parent its own parent's, and the top of the chain takes
+// the profile's `teams.` defaults (internal/config's teamdefaults.go). The
+// resolver ([File.Effective]) answers every value together with where it came
+// from ([Origin]), so a settings card can draw an inherited value dim with
+// `· from Settings` or `· from harbor` and an overridden one in ink with a
+// `reset` beside it, and never has to walk the tree itself.
+//
+// A CLOSED TEAM IS NOT IN ANY WALK (lifecycle.go). Its overrides are kept, so
+// a reopened team has them back, but a team under it (which a cascade closed
+// too, so this is only a hand-edited file) skips it on the way up, and its own
+// effective cap is none: a closed team spends nothing, so it has nothing to
+// cap.
+
+// Settings are a team's own overrides. A nil field is unset.
+type Settings struct {
+ QuestionsUp *bool `json:"questions_up,omitempty"`
+ CapUSDDay *float64 `json:"cap_usd_day,omitempty"`
+ DepthLimit *int `json:"depth_limit,omitempty"`
+ SubShare *float64 `json:"sub_share,omitempty"`
+ // Wake is stored as "wake". A file from before wake was inheritable wrote
+ // only "wake": false (on was written as nothing), and that spelling reads
+ // here unchanged as an override to off.
+ Wake *bool `json:"wake,omitempty"`
+}
+
+// The bands an override is kept inside; a value outside them is dropped by
+// tidy (it reads as inherit) and refused by [File.SetSettings].
+const (
+ depthLimitMax = 10
+)
+
+// ErrSetting is an override outside its band.
+var ErrSetting = errors.New("teams: a cap is 0 or more, a depth 1 to 10, a share above 0 and at most 1")
+
+// Empty reports whether the team overrides nothing.
+func (s Settings) Empty() bool {
+ return s.QuestionsUp == nil && s.CapUSDDay == nil && s.DepthLimit == nil && s.SubShare == nil && s.Wake == nil
+}
+
+// valid reports whether every set field is inside its band.
+func (s Settings) valid() bool {
+ return (s.CapUSDDay == nil || validCap(*s.CapUSDDay)) &&
+ (s.DepthLimit == nil || *s.DepthLimit >= 1 && *s.DepthLimit <= depthLimitMax) &&
+ (s.SubShare == nil || validShare(*s.SubShare))
+}
+
+func validCap(v float64) bool { return v >= 0 && !math.IsInf(v, 0) && !math.IsNaN(v) }
+func validShare(v float64) bool { return v > 0 && v <= 1 }
+
+// tidy drops every set field outside its band, and reports whether it did.
+func (s *Settings) tidy() bool {
+ changed := false
+ if s.CapUSDDay != nil && !validCap(*s.CapUSDDay) {
+ s.CapUSDDay, changed = nil, true
+ }
+ if s.DepthLimit != nil && (*s.DepthLimit < 1 || *s.DepthLimit > depthLimitMax) {
+ s.DepthLimit, changed = nil, true
+ }
+ if s.SubShare != nil && !validShare(*s.SubShare) {
+ s.SubShare, changed = nil, true
+ }
+ return changed
+}
+
+// clone is s with its own copies of every set value.
+func (s Settings) clone() Settings {
+ if s.QuestionsUp != nil {
+ v := *s.QuestionsUp
+ s.QuestionsUp = &v
+ }
+ if s.CapUSDDay != nil {
+ v := *s.CapUSDDay
+ s.CapUSDDay = &v
+ }
+ if s.DepthLimit != nil {
+ v := *s.DepthLimit
+ s.DepthLimit = &v
+ }
+ if s.SubShare != nil {
+ v := *s.SubShare
+ s.SubShare = &v
+ }
+ if s.Wake != nil {
+ v := *s.Wake
+ s.Wake = &v
+ }
+ return s
+}
+
+// SetSettings changes team id's overrides with change, which sets a field to
+// override it and nils it to reset it to inherit. A result outside a band is
+// [ErrSetting] and nothing is changed.
+func (f *File) SetSettings(id string, change func(*Settings)) error {
+ i, err := f.at(id)
+ if err != nil {
+ return err
+ }
+ next := f.Teams[i].Settings.clone()
+ change(&next)
+ if !next.valid() {
+ return ErrSetting
+ }
+ f.Teams[i].Settings = next
+ return nil
+}
+
+// Defaults are the profile's `teams.` rows as the resolver takes them.
+type Defaults struct {
+ QuestionsUp bool `json:"questions_up"`
+ CapUSDDay float64 `json:"cap_usd_day"`
+ DepthLimit int `json:"depth_limit"`
+ // SubShare is a fraction, the row's whole percentage over 100.
+ SubShare float64 `json:"sub_share"`
+ // Wake is whether team traffic wakes idle conversations.
+ Wake bool `json:"wake"`
+}
+
+// DefaultsAt reads the five rows from profileDir's config.json in one read
+// (config's TeamDefaultsAt). An empty profileDir is the ordinary launch.
+func DefaultsAt(profileDir string) Defaults {
+ d := config.TeamDefaultsAt(profileDir)
+ return Defaults{
+ QuestionsUp: d.QuestionsUp,
+ CapUSDDay: d.CapUSDDay,
+ DepthLimit: d.DepthLimit,
+ SubShare: float64(d.SubSharePct) / 100,
+ Wake: d.Wake,
+ }
+}
+
+// Where a value came from.
+const (
+ // OriginTeam is the team's own override.
+ OriginTeam = "team"
+ // OriginAncestor is an override on a team up the chain, named by Team.
+ OriginAncestor = "ancestor"
+ // OriginSettings is the profile's `teams.` default.
+ OriginSettings = "settings"
+ // OriginClosed is a closed team's cap, which is none.
+ OriginClosed = "closed"
+)
+
+// Origin is where one resolved value came from: Kind is one of the Origin
+// constants, and Team and Name name the team that set it for OriginTeam and
+// OriginAncestor.
+type Origin struct {
+ Kind string `json:"kind"`
+ Team string `json:"team,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// Inherited reports whether the value is not the team's own.
+func (o Origin) Inherited() bool { return o.Kind != OriginTeam }
+
+// Words is the dim words an inherited value is drawn with, "" for the team's
+// own: `from Settings`, `from harbor`, `closed`.
+func (o Origin) Words() string {
+ switch o.Kind {
+ case OriginSettings:
+ return "from Settings"
+ case OriginAncestor:
+ return "from " + o.Name
+ case OriginClosed:
+ return "closed"
+ }
+ return ""
+}
+
+// Effective is a team's five settings resolved, each with its origin.
+type Effective struct {
+ QuestionsUp bool `json:"questions_up"`
+ QuestionsUpFrom Origin `json:"questions_up_from"`
+ CapUSDDay float64 `json:"cap_usd_day"`
+ // CapFrom names the team whose cap this is. A cap is a POOL: a team's
+ // spend counts every team under it ([TeamSpend]), so an inherited cap is
+ // the ancestor's one pool, shared, and not a second allowance of the same
+ // size; the spend to set beside it is CapFrom.Team's.
+ CapFrom Origin `json:"cap_from"`
+ DepthLimit int `json:"depth_limit"`
+ DepthFrom Origin `json:"depth_from"`
+ SubShare float64 `json:"sub_share"`
+ SubShareFrom Origin `json:"sub_share_from"`
+ // Wake is whether team traffic wakes the team's idle conversations: a
+ // directive its member, a member's reply or event its manager.
+ Wake bool `json:"wake"`
+ WakeFrom Origin `json:"wake_from"`
+}
+
+// Effective resolves team id's settings: each from the team's own override,
+// else the nearest open ancestor's, else d. An id not in the file is d
+// throughout.
+func (f *File) Effective(id string, d Defaults) Effective {
+ settings := Origin{Kind: OriginSettings}
+ out := Effective{
+ QuestionsUp: d.QuestionsUp, QuestionsUpFrom: settings,
+ CapUSDDay: d.CapUSDDay, CapFrom: settings,
+ DepthLimit: d.DepthLimit, DepthFrom: settings,
+ SubShare: d.SubShare, SubShareFrom: settings,
+ Wake: d.Wake, WakeFrom: settings,
+ }
+ self, ok := f.Team(id)
+ if !ok {
+ return out
+ }
+ var got struct{ questions, cap, depth, share, wake bool }
+ chain := append([]Team{self}, f.Ancestors(id)...)
+ for i, t := range chain {
+ if t.Closed() && i > 0 {
+ continue
+ }
+ origin := Origin{Kind: OriginAncestor, Team: t.ID, Name: t.Name}
+ if i == 0 {
+ origin.Kind = OriginTeam
+ }
+ s := t.Settings
+ if !got.questions && s.QuestionsUp != nil {
+ out.QuestionsUp, out.QuestionsUpFrom, got.questions = *s.QuestionsUp, origin, true
+ }
+ if !got.cap && s.CapUSDDay != nil {
+ out.CapUSDDay, out.CapFrom, got.cap = *s.CapUSDDay, origin, true
+ }
+ if !got.depth && s.DepthLimit != nil {
+ out.DepthLimit, out.DepthFrom, got.depth = *s.DepthLimit, origin, true
+ }
+ if !got.share && s.SubShare != nil {
+ out.SubShare, out.SubShareFrom, got.share = *s.SubShare, origin, true
+ }
+ if !got.wake && s.Wake != nil {
+ out.Wake, out.WakeFrom, got.wake = *s.Wake, origin, true
+ }
+ }
+ if self.Closed() {
+ out.CapUSDDay, out.CapFrom = 0, Origin{Kind: OriginClosed, Team: self.ID, Name: self.Name}
+ }
+ return out
+}
+
+// Depth is how many levels team id stands at, the top level being 1, and 0
+// for an id not in the file. The root team (root.go) is not a level: it is 0,
+// and a team directly under it is 1.
+func (f *File) Depth(id string) int {
+ t, ok := f.Team(id)
+ if !ok || t.Root {
+ return 0
+ }
+ depth := 1
+ for _, a := range f.Ancestors(id) {
+ if !a.Root {
+ depth++
+ }
+ }
+ return depth
+}
+
+// CanNest reports whether a new sub-team may be made under parent: the parent
+// is open and one more level stays inside the parent's effective depth limit.
+func (f *File) CanNest(parent string, d Defaults) bool {
+ t, ok := f.Team(parent)
+ if !ok || t.Closed() {
+ return false
+ }
+ return f.Depth(parent)+1 <= f.Effective(parent, d).DepthLimit
+}
+
+// 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 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
+// exists.
+func (f *File) SubTeamCap(parent string, d Defaults) float64 {
+ e := f.Effective(parent, d)
+ if e.CapUSDDay <= 0 {
+ return 0
+ }
+ return math.Round(e.CapUSDDay*e.SubShare*100) / 100
+}
diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go
index 887d528045..5bbdf6bfac 100644
--- a/internal/teams/traffic.go
+++ b/internal/teams/traffic.go
@@ -36,6 +36,12 @@ const (
KindStop = "stop" // a member was stopped
KindStart = "start" // a member was started
KindYou = "you" // the person spoke to the team
+ // The delegation kinds (DESIGN.md section 8).
+ KindQuestion = "question" // a member asks its home manager a clarifying question
+ KindAnswer = "answer" // the manager answers one; Reply is the question's id
+ KindPacket = "packet" // a decision packet was raised, decided or escalated
+ KindClose = "close" // the team was closed
+ KindReopen = "reopen" // the team was reopened
)
// Addresses that are not a member's handle.
@@ -46,6 +52,8 @@ const (
ToEveryone = "everyone"
ToManager = "manager"
ToRoom = "room"
+ // ToYou is the person, as the one a question or a packet is put to.
+ ToYou = "you"
// ToSeveral is a message to more than one member and fewer than all of
// them, by name: [Entry.Handles] lists who (thread.go).
ToSeveral = "several"
@@ -53,6 +61,7 @@ const (
var kinds = map[string]bool{
KindEvent: true, KindNote: true, KindDirective: true, KindStop: true, KindStart: true, KindYou: true,
+ KindQuestion: true, KindAnswer: true, KindPacket: true, KindClose: true, KindReopen: true,
}
// Entry is one line of the Traffic log. This shape is the contract between
@@ -86,9 +95,20 @@ type Entry struct {
// the State constants ([StateFinished], [StateFailed], [StateAsking],
// [StateIdle] for a turn that was stopped, [StateRunning] for one that
// carried on after its question was answered). It is empty on every other
- // kind. A reader colours by it rather than by reading Text, which is the
+ // kind but [KindPacket]. A reader colours by it rather than by reading Text, which is the
// words a person reads.
State string `json:"state,omitempty"`
+ // Packet is the decision packet a [KindPacket] entry is about, and State
+ // is then the packet's state after the change it records.
+ Packet string `json:"packet,omitempty"`
+ // Reply is the id of the [KindQuestion] entry a [KindAnswer] answers.
+ Reply string `json:"reply,omitempty"`
+ // Team is, on a [KindStart], the sub-team the started conversation is to
+ // manage (a manager's `team_start` of kind team): the interface opens and
+ // adds the member as for any start, and the new conversation, reading its
+ // brief, makes itself that team's manager. It is empty on every other
+ // start and every other kind.
+ Team string `json:"team,omitempty"`
}
// trafficRotateBytes is the size past which the log starts a new file.
diff --git a/internal/teams/wrap.go b/internal/teams/wrap.go
new file mode 100644
index 0000000000..e2a8af3cab
--- /dev/null
+++ b/internal/teams/wrap.go
@@ -0,0 +1,67 @@
+package teams
+
+import (
+ "errors"
+ "time"
+)
+
+// ── THE WRAP-UP CLOCK, KEPT IN THE FILE ─────────────────────────────────────
+//
+// A wrap-up is bounded by time (internal/session's wrapUpFor, fifteen minutes
+// when the product sets it). That clock used to live only in the manager
+// process, so quitting codeaf or an engine restart forgot it and the team
+// never closed, or the next process started the fifteen minutes again.
+//
+// THE RECORD IS THIS, on the team, written through [Change] and [ChangeIf]
+// like every other mutation of teams.json. Started is when the wrap-up began
+// and Bound is how long it was given, so a process that finds one computes
+// the time left as Bound minus how long since Started, and one already past
+// Bound closes on the same road a live clock would have. Absent is none: a
+// file from before this field loads with [Team.Wrap] nil, and a team with no
+// wrap-up is written without the key.
+//
+// A SECOND REQUEST DOES NOT RESET THE CLOCK. [File.SetWrap] keeps the first
+// start, the same rule the session keeps in memory. [File.ClearWrap] is the
+// end: the report went out, or the bound was already past and the incomplete
+// report was raised. Closing the team clears it too ([File.Close]).
+
+// Wrap is one team's wrap-up in progress.
+type Wrap struct {
+ // Started is when the wrap-up began.
+ Started time.Time `json:"started"`
+ // Bound is how long it was given. It is stored as a number of nanoseconds,
+ // which is how a duration is written in JSON, so an old reader that does
+ // not know the field still leaves it alone.
+ Bound time.Duration `json:"bound"`
+}
+
+// ErrWrap is [File.SetWrap] handed a start or a bound that cannot be resumed.
+var ErrWrap = errors.New("teams: a wrap-up needs a start and a bound")
+
+// SetWrap records a wrap-up on team id, once. A wrap-up already recorded is
+// left as it was, so a second request keeps the first clock. started and
+// bound must both be set.
+func (f *File) SetWrap(id string, started time.Time, bound time.Duration) error {
+ i, err := f.at(id)
+ if err != nil {
+ return err
+ }
+ if f.Teams[i].Wrap != nil {
+ return nil
+ }
+ if started.IsZero() || bound <= 0 {
+ return ErrWrap
+ }
+ f.Teams[i].Wrap = &Wrap{Started: started, Bound: bound}
+ return nil
+}
+
+// ClearWrap forgets team id's wrap-up. A team with none is left as it was.
+func (f *File) ClearWrap(id string) error {
+ i, err := f.at(id)
+ if err != nil {
+ return err
+ }
+ f.Teams[i].Wrap = nil
+ return nil
+}
diff --git a/internal/teams/wrapup.go b/internal/teams/wrapup.go
new file mode 100644
index 0000000000..add703b88d
--- /dev/null
+++ b/internal/teams/wrapup.go
@@ -0,0 +1,89 @@
+package teams
+
+import (
+ "errors"
+ "strings"
+ "time"
+)
+
+// ── WRAP UP FIRST, AND THE CLOSE THAT FOLLOWS A CLOSING REPORT ──────────────
+//
+// The ruling (c-9): when a team with running work is closed, the person's
+// default is `Wrap up first`. The manager tells its members to finish and
+// commit, answers what it can, and brings the person a closing report as a
+// [PacketClosing] packet; the team closes only when the person accepts it.
+//
+// THE REQUEST IS ONE LINE OF TRAFFIC, because Traffic is the only channel
+// between the interface and the session (section 5 of the design): a
+// [KindDirective] from [FromYou] to [ToManager] whose State is [StateWrapUp].
+// State, not the words, is the marker, so the interface can say whatever it
+// likes in Text and a reader never matches prose. [WrapUpRequest] builds it
+// and [IsWrapUp] reads it; both sides use these two and nothing else.
+//
+// ACCEPTING THE REPORT CLOSES THE TEAM, through [AcceptClosing], which both
+// the interface (on the person's click) and the manager's session (on reading
+// the decision) may call: it is idempotent, so whichever runs second finds the
+// team closed and does nothing.
+
+// StateWrapUp marks a [KindDirective] as the person's `Wrap up first`.
+const StateWrapUp = "wrap-up"
+
+// WrapUpText is the words a wrap-up request carries when the interface gives
+// none.
+const WrapUpText = "Wrap up first: ask everyone to finish and commit, then bring me a closing report."
+
+// WrapUpRequest is the Traffic entry the interface appends to a team's log to
+// ask its manager to wrap up. text may be "" for [WrapUpText].
+func WrapUpRequest(text string) Entry {
+ if strings.TrimSpace(text) == "" {
+ text = WrapUpText
+ }
+ return Entry{Kind: KindDirective, From: FromYou, To: ToManager, State: StateWrapUp, Text: text}
+}
+
+// IsWrapUp reports whether e is the person's wrap-up request.
+func IsWrapUp(e Entry) bool {
+ return e.Kind == KindDirective && e.From == FromYou && e.State == StateWrapUp
+}
+
+// ErrNotClosing is [AcceptClosing] handed a packet that is not a closing
+// report.
+var ErrNotClosing = errors.New("teams: that packet is not a closing report")
+
+// AcceptClosing closes the team a decided closing packet was raised from when
+// its decision is [OptionClose] or [OptionCloseNow], records the packet as the
+// team's report, and appends a [KindClose] line to its Traffic. It reports
+// whether this call closed the team: false for a decision to keep going, for
+// a team already closed (by the other side, or by hand), and for a packet
+// still waiting.
+func AcceptClosing(profileDir string, p Packet) (bool, error) {
+ if p.Kind != PacketClosing {
+ return false, ErrNotClosing
+ }
+ if p.State != PacketDecided || (p.Decision != OptionClose && p.Decision != OptionCloseNow) {
+ return false, nil
+ }
+ closed := false
+ name := ""
+ err := Update(profileDir, func(f *File) error {
+ t, ok := f.Team(p.Origin)
+ if !ok || t.Closed() {
+ return nil
+ }
+ if err := f.Close(p.Origin, time.Now(), p.ID); err != nil {
+ return err
+ }
+ closed, name = true, t.Name
+ return nil
+ })
+ if err != nil || !closed {
+ return false, err
+ }
+ by := FromYou
+ if p.DecidedBy != Person && p.DecidedBy != "" {
+ by = FromManager
+ }
+ _ = AppendTraffic(profileDir, p.Origin, Entry{Kind: KindClose, From: by, To: ToEveryone,
+ Packet: p.ID, Text: "closed " + name + " on its closing report"})
+ return true, nil
+}
diff --git a/internal/teams/wrapup_test.go b/internal/teams/wrapup_test.go
new file mode 100644
index 0000000000..5723664a3b
--- /dev/null
+++ b/internal/teams/wrapup_test.go
@@ -0,0 +1,128 @@
+package teams
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// THE PACKET FILE ROTATES AND LOSES NOTHING WAITING. With a tiny rotation
+// size, a packet raised, one escalated and one decided, then enough raises to
+// rotate twice: every packet still waiting reads back as it stood (the
+// escalated one still escalated, at the team it went to), the rotated file
+// exists, the current file is small again, and a packet decided just before
+// the last rotation is still readable by id (its raiser is handed the answer
+// from it).
+func TestThePacketFileRotatesAndKeepsEveryWaitingPacket(t *testing.T) {
+ dir := packetTeams(t)
+ old := decisionsRotateBytes
+ decisionsRotateBytes = 4096
+ t.Cleanup(func() { decisionsRotateBytes = old })
+
+ waiting, err := Raise(dir, conflict())
+ must(t, err)
+ up, err := Raise(dir, conflict())
+ must(t, err)
+ if _, err := Escalate(dir, up.ID, "lead", "aaaaaaaaaaaa", "not mine to judge"); err != nil {
+ t.Fatal(err)
+ }
+ var decided Packet
+ for i := 0; i < 12; i++ {
+ p, err := Raise(dir, conflict())
+ must(t, err)
+ decided, err = Decide(dir, p.ID, "lead", "1", "matches the rest")
+ must(t, err)
+ }
+ path := DecisionsPath(dir, "bbbbbbbbbbbb")
+ if _, err := os.Stat(decisionsRotated(path)); err != nil {
+ t.Fatalf("the file never rotated: %v", err)
+ }
+ if info, err := os.Stat(path); err != nil || info.Size() > decisionsRotateBytes {
+ t.Fatalf("the current file is %v after rotating (%v)", info, err)
+ }
+ open, _, err := OpenPackets(dir, ScopeAll)
+ must(t, err)
+ got := map[string]Packet{}
+ for _, p := range open {
+ got[p.ID] = p
+ }
+ if len(open) != 2 || got[waiting.ID].State != PacketOpen {
+ t.Fatalf("waiting packets after rotation: %+v", open)
+ }
+ if e := got[up.ID]; e.State != PacketEscalated || e.Team != "aaaaaaaaaaaa" || len(e.Trail) != 1 {
+ t.Fatalf("the escalated packet lost its state across rotation: %+v", e)
+ }
+ back, err := PacketByID(dir, decided.ID)
+ if err != nil || back.State != PacketDecided || back.Decision != "1" {
+ t.Fatalf("the last decided packet: %+v %v", back, err)
+ }
+ // And a fresh process (an empty memory) folds the two files the same way.
+ forgetPackets(dir)
+ again, _, err := OpenPackets(dir, ScopeAll)
+ if err != nil || len(again) != 2 {
+ t.Fatalf("a fresh fold across rotation: %+v %v", again, err)
+ }
+ // A waiting packet can still be decided after its raise rotated away.
+ if _, err := Decide(dir, waiting.ID, "lead", "2", "the handler is newer"); err != nil {
+ t.Fatalf("a carried packet could not be decided: %v", err)
+ }
+}
+
+// A QUESTION'S ANSWER READS AS ONE ON THE RAIL.
+func TestAQuestionsAnswerIsLoggedAsAnswered(t *testing.T) {
+ dir := packetTeams(t)
+ p, err := Raise(dir, Packet{Team: "bbbbbbbbbbbb", Kind: PacketQuestion, RaisedBy: "web", Question: "tabs or spaces?"})
+ must(t, err)
+ if _, err := Decide(dir, p.ID, FromManager, "tabs", "the repo uses tabs"); err != nil {
+ t.Fatal(err)
+ }
+ log, err := ReadTraffic(dir, "bbbbbbbbbbbb", "", 0)
+ must(t, err)
+ last := log[len(log)-1]
+ if last.Kind != KindPacket || last.State != PacketDecided || last.Text != "answered @web: tabs" {
+ t.Fatalf("the answer's line: %+v", last)
+ }
+}
+
+// THE WRAP-UP MARKER IS THE STATE, NOT THE WORDS, and accepting a closing
+// report closes the team once, whichever side calls it first.
+func TestWrapUpMarkerAndAcceptingAClosingReport(t *testing.T) {
+ e := WrapUpRequest("")
+ if !IsWrapUp(e) || e.Text != WrapUpText || e.To != ToManager {
+ t.Fatalf("the request: %+v", e)
+ }
+ if IsWrapUp(Entry{Kind: KindDirective, From: FromYou, To: ToManager, Text: WrapUpText}) {
+ t.Fatal("words without the marker read as a wrap-up")
+ }
+ dir := packetTeams(t)
+ p, err := Raise(dir, Packet{Team: Person, Origin: "bbbbbbbbbbbb", Kind: PacketClosing, RaisedBy: FromManager,
+ Question: "close dock?", Report: &ClosingReport{Done: "the form", Files: []string{"web/form.go"}},
+ Options: []Option{{ID: OptionClose, Label: "Close", Consequence: "dock closes"},
+ {ID: OptionKeepGoing, Label: "Keep going", Consequence: "dock stays open"}}})
+ must(t, err)
+ if closed, err := AcceptClosing(dir, p); closed || err != nil {
+ t.Fatalf("a waiting report closed the team: %v %v", closed, err)
+ }
+ p, err = Decide(dir, p.ID, Person, OptionClose, "")
+ must(t, err)
+ closed, err := AcceptClosing(dir, p)
+ if !closed || err != nil {
+ t.Fatalf("accepting did not close: %v %v", closed, err)
+ }
+ if again, err := AcceptClosing(dir, p); again || err != nil {
+ t.Fatalf("a second accept closed again: %v %v", again, err)
+ }
+ f, err := Load(dir)
+ must(t, err)
+ dock, _ := f.Team("bbbbbbbbbbbb")
+ if !dock.Closed() || dock.Report != p.ID {
+ t.Fatalf("dock after accepting: %+v", dock)
+ }
+ log, _ := ReadTraffic(dir, "bbbbbbbbbbbb", "", 0)
+ if last := log[len(log)-1]; last.Kind != KindClose || !strings.Contains(last.Text, "closed dock") {
+ t.Fatalf("no close line: %+v", last)
+ }
+ if _, err := AcceptClosing(dir, Packet{Kind: PacketCap}); err != ErrNotClosing {
+ t.Fatalf("a cap packet: %v", err)
+ }
+}
diff --git a/internal/tui3/app.go b/internal/tui3/app.go
index 3e0cdddaa5..9c583051d9 100644
--- a/internal/tui3/app.go
+++ b/internal/tui3/app.go
@@ -283,6 +283,10 @@ type entry struct {
// ([app.noteBlock] says why, and a subharness card is the only shape that
// asks for it). It is false on every other note, which is nearly all of them.
block bool
+ // sheet says this note is a column, so a wrapped row keeps the column its
+ // first line already uses ([wrapSheet]). /help is the one note that asks
+ // for it: a continuation that started at the margin read as another key.
+ sheet bool
// told says this note is ADDRESSED TO THE PERSON rather than narration
// about the machinery, so the work chip may not swallow it (workfold.go's
@@ -1392,6 +1396,18 @@ type app struct {
teamsDisk teamsDisk
// teamMenu is the strip chip's team switcher (teammenu.go).
teamMenu teamMenu
+ // tp is the teams page's own state: its selection, its reading of the
+ // store and the targets it drew (teamspage.go).
+ tp teamsPage
+ // tsheet is a team's card: its settings, its close and its delete
+ // (teamsheet.go).
+ tsheet teamSheet
+ // tmove is the `Move into…` picker, and tdrag a drag in the teams page's
+ // rail (teammove.go, teamdrag.go); tcrew is the teams page's members card
+ // (teamcrew.go).
+ tmove teamMove
+ tdrag teamDrag
+ tcrew teamCrew
// frontWaits is the engine's answer to whether the conversation in front is
// stopped on a person ([session.Agent.NeedsPerson]), asked once per message
// on the loop and read by every frame ([app.frontSignal]). It is the front
@@ -2038,39 +2054,32 @@ type app struct {
// stamp stands still for ever and this is the only thing that says a task
// somebody just started belongs on the page they are looking at.
railStamp uint64
- // THE ROSTER'S OWN FACTS (task.go's rail). railOpen holds the FAMILIES a
- // person has folded or opened AGAINST their default — nil is the design as
- // shipped, and an absent key is a family nobody has touched, which is why this
- // is a map keyed by node id and not a flag on the node. railTop is the
- // window's offset into the SCROLLING PART of the roster's line list — the part
- // under the pinned live head ([app.railLiveHead]), because work that is still
- // going never leaves this column — resolved by the same [listTop] every other
- // list on this surface scrolls with. railWhere is the focused row,
- // named by id rather than by index because a fold takes rows out from under a
- // cursor while nobody is looking, and railHold says the roster has been GIVEN
- // the keyboard (alt+t) — without it there is no cursor, and every key still
- // belongs to the draft.
+ // THE ROSTER'S OWN FACTS (task.go's rail). railTop is the window's offset
+ // into the roster's line list, resolved by the same [listTop] every other
+ // list on this surface scrolls with. railWhere is the focused row, named by
+ // identity rather than by index because a fold takes rows out from under a
+ // cursor while nobody is looking, and railHold says the roster has been
+ // GIVEN the keyboard (alt+t): without it there is no cursor, and every key
+ // still belongs to the draft.
//
- // railWide is the third width tier, asked for with w and sticky until it is
- // asked for again. railCramped is what earns the offer of it: the last layout
- // cut a title with its own indent, and it is written where that is discovered
- // ([app.railEntryRows]) and read by the footer, the way [app.railTop] is
- // written by the window it resolves.
+ // railWide is the third width tier, asked for with alt+w and sticky until
+ // it is asked for again.
//
// railAway is the person's own standing answer to whether there is a column at
- // all (ctrl+g, [app.railStow]). It outranks every width tier and the roster's
- // own "one node raises it" rule alike — a column somebody put away stays away,
- // through landings and new work and the next session, until they ask for it
- // back — and it is the one piece of this block that survives the process,
- // because it is the only one a person chose deliberately (config's
- // ui.task_column).
- railOpen map[uint64]bool
- railTop int
- railWhere railSpot
- railHold bool
- railWide bool
- railCramped bool
- railAway bool
+ // all (alt+l or ctrl+g, [app.railStow]). It outranks every width tier: a
+ // column somebody put away stays away, through landings and new work and the
+ // next session, until they ask for it back, and it is the one piece of this
+ // block that survives the process, because it is the only one a person chose
+ // deliberately (config's ui.task_column).
+ railTop int
+ railWhere railSpot
+ railHold bool
+ railWide bool
+ railAway bool
+ // side is the side column's own memory for the session: which of its two
+ // words is in front per kind of chat, what is folded, and where the
+ // Traffic's `new` line stands (sidecol.go).
+ side sideState
// away is the last reading of what the project's OTHER windows have out
// right now, and when it was taken (taskview.go's [app.refreshElsewhere]).
// It is a CACHE and not a subscription: the reading is a readdir and a
@@ -2233,15 +2242,19 @@ type app struct {
// so the flags are gone and the frame, the keyboard, the pointer and the tab
// bar all read this.
page page
- // tabs and tabRow are WHERE THE TAB BAR WAS LAST PAINTED — one span per chip
- // that survived the width ladder, and the row of the terminal the bar landed
- // on (-1 when a short frame cut it off). They are written by the draw
- // (pages.go's [placeFrameWithBar]) and read by the press, which is the same
- // bargain every hit map on this surface strikes: a click resolves against
- // what was actually drawn, never against what a second computation thinks
- // was drawn.
+ // tabs and tabRow are WHERE THE NAV WAS LAST PAINTED: one span per place's
+ // button that survived the width ladder, and the row of the terminal the
+ // nav landed on (-1 on a frame with no head). They are written by the draw
+ // (head.go's [app.headRows], topnav.go's [app.navLine]) and read by the
+ // press, which is the same bargain every hit map on this surface strikes: a
+ // click resolves against what was actually drawn, never against what a
+ // second computation thinks was drawn.
tabs []placeTabSpan
tabRow int
+ // navMemo is the nav's row as it was last laid out, and navMore is its
+ // fold, `more ▾`, and the menu of places behind it (topnav.go, navmore.go).
+ navMemo navMemo
+ navMore navMore
// boxRow and boxRows are WHERE A PLACE'S COMPOSER WAS LAST PAINTED — the row
// its first line landed on and how many lines it took — written by the same
// draw and read by the same press, on [app.chatTabs]'s bargain exactly. A click
@@ -2263,9 +2276,9 @@ type app struct {
// answers to one question.
bar barCursor
// tabHover is the place whose word the POINTER is resting on, and [pageNone]
- // — the zero value — is "the pointer is not on the bar at all". It is what
- // lifts one word's ink by one tier and changes nothing else on the frame
- // (placemouse.go's [app.placeTabHover]).
+ // (the zero value) is "the pointer is not on the nav at all". It is what
+ // puts one word on the pointer's ground and changes nothing else on the
+ // frame (topnav.go's [app.navHover]).
tabHover page
// searchArm is how the search place's QUIET INTERVAL is armed, and nil — the
// real 150ms timer — everywhere but a test (place_search.go's
@@ -3460,6 +3473,12 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if write := a.teamsWrite(); write != nil {
cmd = tea.Batch(cmd, write)
}
+ // AND THE TEAMS PAGE SETTLES WHICH CONVERSATION ITS PANE HOSTS, after
+ // every message that could have moved the front (teamspagehost.go). One
+ // comparison on every other place.
+ if bring := a.teamsSync(); bring != nil {
+ cmd = tea.Batch(cmd, bring)
+ }
// AND THE TERMINAL'S TITLE IS ASKED AFTER EVERY MESSAGE, because this is
// the one place every change to where a person stands has already happened
// by — a place entered, a name arriving, a question coming up — and it is
@@ -3491,6 +3510,12 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
// not survive has news, and a person who presses a key gets it rather than
// waiting for whatever repaints next.
a.takeLinkNotice()
+ // THE TEAMS PAGE TAKES WHAT IS ITS OWN AND HANDS THE REST TO THE MANAGER'S
+ // CONVERSATION it hosts (teamspagehost.go). One comparison on every other
+ // place.
+ if cmd, took := a.teamsRoute(msg); took {
+ return a, cmd
+ }
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// A zero size is a terminal that could not say — a headless boot, a
@@ -3855,6 +3880,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.teamMenu.on {
a.closeTeamMenu()
}
+ if a.tsheet.on || a.tmove.on {
+ return a, nil
+ }
if a.wall.on {
a.wallWheel(msg.Mouse().X, msg.Mouse().Y, msg.Mouse().Button == tea.MouseWheelDown)
return a, nil
@@ -3893,11 +3921,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.questionDialogWheel(msg) {
return a, nil
}
- // THE TAB BAR IS READ BEFORE EVERY PLACE'S OWN ROWS, exactly as it is for
- // the press: it is the router's row, drawn on all seven places in the same
- // cells, so a wheel answered by the place under it would scroll a list for
- // a gesture made over a row that is not that list's. Over the bar the
- // wheel walks the PLACES, one room a tick (placemouse.go's
+ // THE NAV IS READ BEFORE EVERY PLACE'S OWN ROWS, exactly as it is for
+ // the press: it is the router's row, drawn on every page in the same
+ // cells, so a wheel answered by the place under it would scroll a list
+ // for a gesture made over a row that is not that list's. Over the nav on
+ // a place the wheel walks the PLACES, one room a tick (placemouse.go's
// [app.placeTabWheel]).
if cmd, took := a.placeTabWheel(msg.Mouse().Y, placeWheelDelta(msg.Mouse().Button)); took {
return a, cmd
@@ -4078,6 +4106,25 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.teamMenu.on && msg.Mouse().Button == tea.MouseLeft {
return a, a.teamMenuPress(msg.Mouse().X, msg.Mouse().Y)
}
+ // AND THE NAV'S FOLD MENU, on the same terms (navmore.go).
+ if a.navMore.on && msg.Mouse().Button == tea.MouseLeft {
+ return a, a.navMorePress(msg.Mouse().X, msg.Mouse().Y)
+ }
+ // The move picker, over everything, and then a team's card own the
+ // press while they are up, on the same terms (teammove.go,
+ // teamsheet.go).
+ if a.tmove.on {
+ if msg.Mouse().Button == tea.MouseLeft {
+ return a, a.teamMovePress(msg.Mouse().X, msg.Mouse().Y)
+ }
+ return a, nil
+ }
+ if a.tsheet.on {
+ if msg.Mouse().Button == tea.MouseLeft {
+ return a, a.teamSheetPress(msg.Mouse().X, msg.Mouse().Y)
+ }
+ return a, nil
+ }
if a.wall.on && msg.Mouse().Button == tea.MouseLeft {
if cmd, took := a.wallPress(msg.Mouse().X, msg.Mouse().Y); took {
return a, cmd
@@ -4114,13 +4161,14 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}
if msg.Mouse().Button == tea.MouseLeft {
- // THE TAB BAR IS READ BEFORE EVERY PLACE'S OWN ROWS, because it is
- // the router's row and not any place's: it is drawn on every one of
- // them, in the same cells, and a press answered by the place under it
- // would be the one row of the frame that means something different
- // depending on which room you happen to be standing in
- // (placemouse.go's [app.placeTabPress]).
- if cmd, took := a.placeTabPress(msg.Mouse().X, msg.Mouse().Y); took {
+ // THE NAV IS READ BEFORE EVERY PAGE'S OWN ROWS, because it is the
+ // router's and not any page's: row zero is the places on every
+ // page, and a press answered by the page under it would be the one
+ // row of the frame that means something different depending on
+ // where you happen to be standing (topnav.go's [app.navPress]).
+ // The strip is a chat's row. On a place it is not drawn, so the
+ // row under the nav is the page's.
+ if cmd, took := a.navPress(msg.Mouse().X, msg.Mouse().Y); took {
return a, cmd
}
// AND HOME'S RULE IS READ BEFORE HOME'S OWN ROWS, on the tab bar's
@@ -4370,11 +4418,6 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
// the body for the same reason: the two are drawn side by side, so
// which one was pressed is a question about x (room.go). A rail row
// is a door into that node's room.
- // AND THE TRAFFIC RAIL BEFORE IT, at the frame's right edge while the
- // manager is in front: a row goes to its member (teamtraffic.go).
- if cmd, took := a.trafficPress(msg.Mouse().X, msg.Mouse().Y); took {
- return a, cmd
- }
if cmd, took := a.railPress(msg.Mouse().X, msg.Mouse().Y); took {
return a, cmd
}
@@ -4494,6 +4537,18 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
a.teamMenuMotion(msg.Mouse().X, msg.Mouse().Y)
return a, nil
}
+ if a.navMore.on {
+ a.navMoreMotion(msg.Mouse().X, msg.Mouse().Y)
+ return a, nil
+ }
+ if a.tmove.on {
+ a.teamMoveMotion(msg.Mouse().X, msg.Mouse().Y)
+ return a, nil
+ }
+ if a.tsheet.on {
+ a.teamSheetMotion(msg.Mouse().X, msg.Mouse().Y)
+ return a, nil
+ }
if a.wall.on {
a.wallMotion(msg.Mouse().X, msg.Mouse().Y)
return a, nil
@@ -4540,12 +4595,20 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Mouse().Button == tea.MouseLeft && a.dragMotion(msg.Mouse().X, msg.Mouse().Y) {
return a, nil
}
- // AND THE TAB BAR IS READ BEFORE EVERY PLACE'S OWN ROWS HERE TOO, for the
- // press's own reason: the bar is the router's row and means the same thing
- // on all seven places, so the word under the pointer lifts wherever a
- // person is standing (placemouse.go's [app.placeTabHover]).
+ // AND THE HEAD IS READ BEFORE EVERY PAGE'S OWN ROWS HERE TOO, for the
+ // press's own reason: the nav and the strip are the router's rows and
+ // mean the same thing on every page, so the button under the pointer
+ // takes its ground wherever a person is standing (topnav.go's
+ // [app.headHover]).
a.hoverDraftSeam(msg.Mouse().X, msg.Mouse().Y)
- if a.placeTabHover(msg.Mouse().X, msg.Mouse().Y) {
+ if a.headHover(msg.Mouse().X, msg.Mouse().Y) {
+ // AND THE HEAD TAKES THE POINTER OFF HOME'S PROJECT NAMES, which
+ // underline under the pointer alone (home.go's [app.homeHover]) and
+ // would otherwise stay lit after it left them for the nav.
+ if a.home.projectHover != -1 {
+ a.home.projectHover = -1
+ a.touch()
+ }
return a, nil
}
if a.at(pageSettings) {
@@ -7047,7 +7110,7 @@ func (a *app) press(x, y int) (cmd tea.Cmd) {
// a place where missing costs you the page.
//
// A TEAM REFERENCE IS THE SAME KIND OF DOOR (teamlink.go): a member opens, or
-// is resumed and opened, and a team's name opens the conversations view on it.
+// is resumed and opened, and a team's name opens the teams page on it.
func (a *app) linkPress(x int, r row) (bool, tea.Cmd) {
if len(r.links) == 0 || a.welcome.open {
return false, nil
@@ -7258,6 +7321,9 @@ func (a *app) slash(line string) tea.Cmd {
// pasted into a shell. /status still prints it whole.
help := helpText(a.hostedPath(tildePath(a.file, a.tilde)), a.chords)
a.noteFacts(help, columnFacts(help, true)...)
+ if n := len(a.entries); n > 0 && a.entries[n-1].kind == entryNote {
+ a.entries[n-1].sheet = true
+ }
return nil
case "budget":
@@ -7466,6 +7532,10 @@ func (a *app) slash(line string) tea.Cmd {
// EVERY OPEN CONVERSATION AT ONCE, as a grid of live tiles (wall.go).
return a.openWall()
+ case "teams":
+ // THE TEAM-LEVEL VIEW, which is a place (place_teams.go).
+ return a.showPage(pageTeams)
+
case "spend":
// AND THE WHOLE MACHINE'S BILL, which is a place and not a note. This word
// was an alias of /cost until this wave, so the one guess a developer makes
diff --git a/internal/tui3/attach_test.go b/internal/tui3/attach_test.go
index 2a333f293c..2834c70500 100644
--- a/internal/tui3/attach_test.go
+++ b/internal/tui3/attach_test.go
@@ -243,7 +243,7 @@ func TestAnImageMessageMarksItsPicturesInTheTranscript(t *testing.T) {
a.pathLinks = true
// Wide enough that the sentence, its two tokens and its two markers land on
// one row: this test is about what is drawn, not about where it wraps.
- a.width = 100
+ a.width = 120
a.attach(filepath.Join(dir, "shot.png"))
a.attach(filepath.Join(dir, "chart.png"))
typeLine(t, a, "what is wrong here")
diff --git a/internal/tui3/background_test.go b/internal/tui3/background_test.go
index ca02b3c8e7..66cadde625 100644
--- a/internal/tui3/background_test.go
+++ b/internal/tui3/background_test.go
@@ -77,8 +77,8 @@ func TestARunningCommandIsSentToTheBackgroundWithOneKey(t *testing.T) {
if got := a.hintWord(); got != wantHint {
t.Fatalf("the full running-turn hint is %q, want %q", got, wantHint)
}
- if door := plain(a.railDoorLine()); strings.Contains(door, "ctrl+g") || !strings.HasSuffix(door, "hide") {
- t.Fatalf("the column footer names a key the command owns: %q", door)
+ if head, _ := a.sideHeadRow(a.railRoom()); strings.Contains(plain(head), "ctrl+g") || !strings.HasSuffix(strings.TrimSpace(plain(head)), sideHideKey) {
+ t.Fatalf("the column header names a key the command owns: %q", plain(head))
}
drive(t, a, key("ctrl+g"))
diff --git a/internal/tui3/barfold_test.go b/internal/tui3/barfold_test.go
index 09fce5687c..17473ece44 100644
--- a/internal/tui3/barfold_test.go
+++ b/internal/tui3/barfold_test.go
@@ -3,54 +3,12 @@ package tui3
import (
"strings"
"testing"
-
- "github.com/Agent-Field/codeaf/internal/tui2/tokens"
- "github.com/charmbracelet/x/ansi"
)
-// A FOLD ON THE PLACE BAR WEARS THE FOLD MARK, LIKE EVERY OTHER FOLD HERE.
-//
-// The bar's remainder said `+3 more` while the command menu's own tail said
-// `▸ 3 more` about the same idea — a navigation list with more items than fit —
-// so a `+3` at the end of a row could not be told from a count, a badge or a
-// door. `▸` is the half that says which, and it is on every rung: the word
-// `more` gives way before the mark does.
-func TestTheBarsRemainderWearsTheSameFoldMarkTheMenusDoes(t *testing.T) {
- // Wide enough for the whole sentence, then narrow enough that only the
- // count survives, then too narrow for either.
- long := foldLine(3, "")
- short := tokens.GlyphCollapsed + " 3"
- for _, c := range []struct {
- room int
- want string
- }{
- {tabPadCols + ansi.StringWidth(long), long},
- {tabPadCols + ansi.StringWidth(short), short},
- {tabPadCols + ansi.StringWidth(short) - 1, ""},
- } {
- got := barMoreWord(3, c.room)
- if got != c.want {
- t.Fatalf("with %d cells of room the bar drew %q, want %q", c.room, got, c.want)
- }
- }
- // AND NO RUNG OF THE LADDER IS A BARE `+`, which is what it used to be at
- // both lengths.
- for _, room := range []int{40, 20, 12, 9, 8} {
- if got := barMoreWord(4, room); got != "" && !strings.HasPrefix(got, tokens.GlyphCollapsed) {
- t.Fatalf("with %d cells of room the bar drew %q, which wears no fold mark", room, got)
- }
- }
- // AND IT IS THE SAME SPELLER THE MENU'S FOLD USES (commands.go draws
- // foldLine), which is the whole of this row: one idea, one sentence.
- if want := foldLine(3, ""); barMoreWord(3, 60) != want {
- t.Fatalf("the bar drew %q and the menu's fold spells it %q", barMoreWord(3, 60), want)
- }
-}
-
// AND THE MAP'S CLAUSE ABOUT `→` SAYS WHAT THE KEY DOES.
//
// It read `→ verbs on this row` on a line where every other clause names an act:
-// `alt+1…8 go to a place`, `alt+enter send it off as a task`, `esc close`.
+// `alt+1…9 go to a place`, `alt+enter send it off as a task`, `esc close`.
// `verbs` is the machinery's name for the strip, not anybody's word for what
// pressing the key gets them.
func TestTheMapNamesWhatTheArrowDoesAndNotWhatItIsCalled(t *testing.T) {
diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go
index ed31984e1f..7fef7b52f8 100644
--- a/internal/tui3/bundle_test.go
+++ b/internal/tui3/bundle_test.go
@@ -3375,7 +3375,7 @@ func TestTheRailStandsWhileWorkIsAliveAndGoesWhenItLands(t *testing.T) {
if !a.railShowing() {
t.Fatal("an empty session drew no rail")
}
- if a.bodyWidth() != 200-railCols {
+ if a.bodyWidth() != 200-sideColsFor(200) {
t.Fatalf("the empty column is not charged against the conversation: body=%d", a.bodyWidth())
}
// AND IT SAYS WHAT IT IS FOR. Thirty blank columns beside a paragraph read as
@@ -3406,6 +3406,7 @@ func TestTheRailStandsWhileWorkIsAliveAndGoesWhenItLands(t *testing.T) {
// whose branch did not come home keeps the branch name.
drive(t, a,
streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskQueued, session.TaskNotice{})},
+ streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskRunning, session.TaskNotice{})},
streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskFailed, session.TaskNotice{
Report: "the tests did not build", Merge: mergeWordAborted, Branch: "task/collect",
})},
@@ -3413,32 +3414,28 @@ func TestTheRailStandsWhileWorkIsAliveAndGoesWhenItLands(t *testing.T) {
Elapsed: 130 * time.Second, Merge: mergeWordConflicted, Branch: "task/fix-nil-map",
})},
)
- // A retained report expands on request, without demanding a merge.
- a.railSetOpen(a.tasks[9], true)
- // A BRANCH NAME IS NEVER ELLIPSIZED: the conflicted sentence wraps inside
- // the rail rather than losing the one handle back to the work, so the
- // assertion is on the two halves and not on one line.
+ // THE QUEUED WORK IS ONE FOLDED ROW UNTIL IT IS OPENED, and the two rows
+ // that need the person are the band's: the conflicted branch in the
+ // needs-you amber, the failure a ✗ in ink (sidecol.go).
+ a.sideToggleGroup(railIdle)
rail = plain(strings.Join(a.railRows(16), "\n"))
- // ONE GLYPH OPENS EVERY ROW AND IT IS THE STATE. The identity ◆ is not on this
- // column: it is the same cell on every task, this column holds nothing but
- // tasks, and the two cells belong to the name here (task.go's [app.railLead]).
for _, want := range []string{
glyphQueued + " Mix audio",
- glyphBad + " Collect sources",
- glyphDone + " Fix the nil-map",
- "conflicted ·", "task/fix-nil-map",
- // A STOPPED NODE DID NOT CRASH. session marks its branch "aborted"; the
- // rail says what that is — it stopped, and the work is still on the branch
- // named beside it.
- // A NODE WHOSE BRANCH NEVER CAME HOME DID NOT CRASH. session marks it
- // "aborted"; the rail says the reading's own sentence about it and names
- // the branch the work is still on beside it.
- "task/collect"} {
+ a.taskStateMark(a.tasks[9]) + " Collect sources",
+ a.taskStateMark(a.tasks[7]) + " Fix the nil-map",
+ } {
if !strings.Contains(rail, want) {
t.Fatalf("the rail is missing %q:\n%s", want, rail)
}
}
- if strings.Contains(rail, mergeWordAborted) {
+ // A BRANCH NAME IS NEVER ELLIPSIZED: the row is one line, and the hint line
+ // under the pointer carries the handle back to the work whole.
+ for id, want := range map[uint64]string{7: "task/fix-nil-map", 9: "task/collect"} {
+ if hint := railHint(a, id); !strings.Contains(hint, want) {
+ t.Fatalf("the hint over node %d does not name %q: %q", id, want, hint)
+ }
+ }
+ if strings.Contains(rail+railHint(a, 9), mergeWordAborted) {
t.Fatalf("the rail read the engine's own word for a stopped node:\n%s", rail)
}
@@ -3448,6 +3445,7 @@ func TestTheRailStandsWhileWorkIsAliveAndGoesWhenItLands(t *testing.T) {
drive(t, a, streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskDone, session.TaskNotice{
Elapsed: 8 * time.Second, Merge: mergeWordMerged,
})})
+ railOpenAll(a)
if !strings.Contains(plain(strings.Join(a.railRows(12), "\n")), "Mix audio") {
t.Fatal("a merged node left the roster's record")
}
@@ -3571,33 +3569,49 @@ func TestTheRailNamesWhatABlockedNodeWaitsOn(t *testing.T) {
DependsOn: []uint64{1},
})},
)
- // A NODE WITH NO FAMILY AROUND IT IS THE FLAT ROW THIS COLUMN ALWAYS DREW, and
- // the sentence under it is the only place the surface says what is in the way
- // (task.go's [app.railSaysMore]).
+ // THE ROW IS ONE LINE UNDER ITS GROUP, and the sentence on the hint line
+ // over it is the only place the surface says what is in the way.
+ railOpenAll(a)
rail := plain(strings.Join(a.railRows(10), "\n"))
if !strings.Contains(rail, "Mix audio") {
t.Fatalf("the blocked node is not on the roster:\n%s", rail)
}
- if !strings.Contains(rail, "waits: Collect sources") {
- t.Fatalf("a blocked node does not say what it waits on:\n%s", rail)
+ if hint := railHint(a, 2); !strings.Contains(hint, "waits: Collect sources") {
+ t.Fatalf("a blocked node does not say what it waits on: %q", hint)
}
// The prerequisite finishing takes the sentence away rather than leaving a
// node waiting on work that is over.
drive(t, a, streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, session.TaskNotice{
Merge: mergeWordMerged,
})})
- if strings.Contains(plain(strings.Join(a.railRows(10), "\n")), "waits:") {
- t.Fatalf("the wait outlived the work it waited on:\n%s", plain(strings.Join(a.railRows(10), "\n")))
+ if hint := railHint(a, 2); strings.Contains(hint, "waits:") {
+ t.Fatalf("the wait outlived the work it waited on: %q", hint)
}
}
-// underWidth is the cells a node's under-block actually gets at a rail width:
-// the column less its seam ([app.railRoom]), less the two-cell indent every
-// under-row is drawn behind ([app.railNodeRows]). The telemetry tests measure
-// against it rather than against the column, because a test that asserted a row
-// at railCols would be asserting four cells the row never had.
+// railHint is what the hint line says with the pointer on node id's row: its
+// whole name and what its row used to say under it (sidecol.go).
+func railHint(a *app, id uint64) string {
+ hot := a.hot
+ a.hot = hoverAt{kind: hoverRail, id: id}
+ words := a.sideHoverWords()
+ a.hot = hot
+ return words
+}
+
+// underWidth is the cells a node's under-block gets at a column width: the
+// column less its seam, less a two-cell indent. The block is the hint line's
+// now (sidecol.go's [app.sideHoverWords] reads [app.railUnder] at a width
+// that never cuts it), and its sentences keep their law at every width, which
+// is what these widths measure: the column's old full, slim and wide tiers.
func underWidth(cols int) int { return cols - ansi.StringWidth(railSeam) - 2 }
+const (
+ underCols = 30
+ underSlimCols = 24
+ underWideCols = 46
+)
+
// THE TELEMETRY ROW IS WHAT A RUNNING NODE COSTS, said under its own name: its
// age, its weight, its price and its worker — richest first, and given up from
// the right until the row fits the column it is in.
@@ -3618,8 +3632,8 @@ func TestARunningRowSaysItsAgeWeightPriceAndModel(t *testing.T) {
width int
want string
}{
- {underWidth(railCols), "42s · 9.9k · $0.31 · gpt-5"},
- {underWidth(railSlimCols), "42s · 9.9k · $0.31"},
+ {underWidth(underCols), "42s · 9.9k · $0.31 · gpt-5"},
+ {underWidth(underSlimCols), "42s · 9.9k · $0.31"},
{17, "42s · 9.9k"},
{9, "42s"},
} {
@@ -3632,7 +3646,7 @@ func TestARunningRowSaysItsAgeWeightPriceAndModel(t *testing.T) {
// the under-block: what the node is doing this second, then what it has spent
// getting there.
node.tool, node.toolBegan = "bash go test ./...", a.now().Add(-24*time.Second)
- rows := a.railUnder(node, underWidth(railCols))
+ rows := a.railUnder(node, underWidth(underCols))
if len(rows) != railUnderRows {
t.Fatalf("a working node drew %d under-rows, want %d:\n%q", len(rows), railUnderRows, rows)
}
@@ -3648,14 +3662,14 @@ func TestARunningRowSaysItsAgeWeightPriceAndModel(t *testing.T) {
// leaves the two figures off the row rather than claiming the node has burned
// nothing and cost nothing.
node.tokens, node.cost = 0, 0
- if got := plain(strings.Join(a.railUnder(node, underWidth(railCols)), "\n")); got != "42s · gpt-5" {
+ if got := plain(strings.Join(a.railUnder(node, underWidth(underCols)), "\n")); got != "42s · gpt-5" {
t.Fatalf("an unmeasured node draws %q, want no figures at all", got)
}
if strings.Contains(rosterText(a, 12), "$0.00") {
t.Fatalf("the roster priced a node nobody has priced:\n%s", rosterText(a, 12))
}
node.model = ""
- if got := plain(strings.Join(a.railUnder(node, underWidth(railCols)), "\n")); got != "42s" {
+ if got := plain(strings.Join(a.railUnder(node, underWidth(underCols)), "\n")); got != "42s" {
t.Fatalf("a node nobody has said anything about draws %q, want its age alone", got)
}
}
@@ -3682,7 +3696,7 @@ func TestTheRosterPricesAMergeAndLeavesTheActionableRowsAlone(t *testing.T) {
Merge: mergeWordMerged, CostUSD: 0.42,
})},
)
- width := underWidth(railCols)
+ width := underWidth(underCols)
// THE MERGE WORD ALWAYS SURVIVES, and the price rides behind it only where
// there is room for both.
@@ -3740,7 +3754,7 @@ func TestTheRailIsChargedAgainstTheConversationOnly(t *testing.T) {
}
want := tc.width
if tc.rail {
- want -= railColsFor(tc.width)
+ want -= sideColsFor(tc.width)
}
if got := a.bodyWidth(); got != want {
t.Fatalf("at %d columns the conversation is %d wide, want %d", tc.width, got, want)
@@ -3754,29 +3768,16 @@ func TestTheRailIsChargedAgainstTheConversationOnly(t *testing.T) {
t.Fatalf("at %d columns frame row %d is %d wide:\n%q", tc.width, i, w, line)
}
}
- // The roster opens on the node itself — there are no headings any more, and
- // a session of one node is one family of one (task.go).
- // The column opens with its section label now (margin.go), so the node is
- // the row under it.
- //
- // EACH WIDTH IS ASSERTED IN ITS OWN SPELLING rather than in the prefix they
- // share. The name reaches this column WHOLE now (taskident.go's
- // [taskTitleOf] stopped cutting to three words on 2026-09-03, so the ROW
- // decides what it can afford), and twenty-one cells do not fit a slim
- // rail's title slot — so the full column draws the name and the slim one
- // draws as much of it as [railTitleFloor] leaves once the id is measured
- // out. Both are the roster naming the node, which is what this row of the
- // test is here to say.
+ // The column opens with its header, then the group the node is in, then
+ // the node, one line, with its whole name at every width that lends a
+ // column (sidecol.go).
name := "Fix the nil-map crash"
- if railColsFor(tc.width) < railCols {
- name = "Fix the nil-ma"
- }
top := a.bodyTop()
- if tc.rail && !strings.Contains(lines[top], railStowHint) {
- t.Fatalf("at %d columns the column does not open with its hide control:\n%q", tc.width, lines[top])
+ if tc.rail && !strings.Contains(lines[top], sideTasksWord+" 1") {
+ t.Fatalf("at %d columns the column does not open with its header:\n%q", tc.width, lines[top])
}
- if tc.rail && !strings.Contains(lines[top+2], name) {
- t.Fatalf("at %d columns the roster's first row is not the node:\n%q", tc.width, lines[top+2])
+ if tc.rail && (!strings.Contains(lines[top+1], railHeadWords[railRunning]) || !strings.Contains(lines[top+2], name)) {
+ t.Fatalf("at %d columns the roster's first rows are not the group and the node:\n%q\n%q", tc.width, lines[top+1], lines[top+2])
}
// AND THE STRIP IS THE ROW ABOVE IT ONLY WHERE THERE IS NO ROSTER: the two
// answer the same question, and the wide frame answers it in the column.
@@ -3819,10 +3820,9 @@ func rosterText(a *app, height int) string {
// frame lent it, every row stays inside the column, and the window follows the
// focus down rather than stopping at whatever fitted first.
-func TestTheRosterKeepsCreationOrderAndCountsTheWhole(t *testing.T) {
+func TestTheRosterGroupsItsWorkByStateOneLineEach(t *testing.T) {
a, _, _ := taskApp(t)
- // Use the wide tier so this aggregate test can see every count.
- a.width, a.railWide = 160, true
+ a.width = 160
drive(t, a,
streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, session.TaskNotice{
Merge: mergeWordMerged,
@@ -3835,70 +3835,69 @@ func TestTheRosterKeepsCreationOrderAndCountsTheWhole(t *testing.T) {
Report: "the merge conflicted", Merge: mergeWordConflicted, Branch: "task/render",
})},
streamEventMsg{gen: a.gen, ev: update(5, "Cut the trailer", session.TaskQueued, session.TaskNotice{})},
- // A failure without a retained branch is counted as finished work.
+ // Seen running first, as live work is: a node whose first news is a
+ // failure is one a reopened conversation replayed, and that is history.
+ streamEventMsg{gen: a.gen, ev: update(6, "Trim silence", session.TaskRunning, session.TaskNotice{})},
streamEventMsg{gen: a.gen, ev: update(6, "Trim silence", session.TaskFailed, session.TaskNotice{
Report: "the tests did not build",
})},
)
a.cost, a.tokens = 1.42, 312_000
- rail := rosterText(a, 24)
-
- // Creation order stays stable across every task state.
- at := -1
- for _, want := range []string{"Collect sources", "Fix the nil-map", "Mix audio", "Render titles",
- "Cut the trailer", "Trim silence"} {
- found := strings.Index(rail, want)
- if found < 0 {
- t.Fatalf("the roster has no %q row:\n%s", want, rail)
- }
- if found < at {
- t.Fatalf("%q is out of order:\n%s", want, rail)
- }
- at = found
- }
- // NO HEADINGS AT ALL. The five words live in the footer now, where they are
- // counts of the whole session rather than sections of the column.
- for g := railGroup(0); g < railGroupCount; g++ {
- if strings.Contains(rail, glyphOpen+" "+railGroupWords[g]) ||
- strings.Contains(rail, glyphShut+" "+railGroupWords[g]) {
- t.Fatalf("the roster still draws the %q heading:\n%s", railGroupWords[g], rail)
+ rows := railText(a, 24)
+ // THE GROUPS STAND IN ONE ORDER, each heading its count: running work open,
+ // everything else one folded row. What needs the person is the band's, above
+ // them, and is not under any of them.
+ want := []string{
+ sideTasksWord + " 6",
+ a.taskStateMark(a.tasks[4]) + " Render titles",
+ a.taskStateMark(a.tasks[6]) + " Trim silence",
+ strings.Repeat(a.linearMark("─", "-"), 8),
+ railHeadWords[railRunning] + " 1",
+ "Fix the nil-map crash",
+ railHeadWords[railIdle] + " 1 " + glyphShut,
+ railHeadWords[railParked] + " 1 " + glyphShut,
+ railHeadWords[railDone] + " 1 " + glyphShut,
+ }
+ for i, w := range want {
+ if i >= len(rows) || !strings.Contains(rows[i], w) {
+ t.Fatalf("row %d is not %q:\n%s", i, w, strings.Join(rows, "\n"))
}
}
- // ONE GLYPH OPENS A ROW WITH NO FAMILY AROUND IT, and it is the state — the
- // same lead a family row has, so the column reads downward as one column of
- // states (task.go's [app.railLead]).
- if !strings.Contains(rail, glyphBad+" Render titles") {
- t.Fatalf("the flat row does not lead with its state:\n%s", rail)
+ rail := strings.Join(rows, "\n")
+ // THE RUNNING WORK'S HEADING WEARS NO MARK: it does not fold, and a mark is
+ // a promise that a press does something.
+ if strings.Contains(rows[4], glyphShut) || strings.Contains(rows[4], glyphOpen) {
+ t.Fatalf("the running heading wears a fold mark: %q", rows[4])
}
- if strings.Contains(rail, plain(a.taskMark(identFor(4)))+" Render titles") {
- t.Fatalf("the identity ◆ is back on the rail, two cells from the name:\n%s", rail)
+ // ONE GLYPH OPENS A TASK ROW, and it is the state; no identity ◆, no id.
+ if lead := railSeam + " " + plain(a.railTreeGlyph(a.tasks[2])) + " Fix the nil-map"; !strings.HasPrefix(rows[5], lead) {
+ t.Fatalf("the task row does not lead with its state: %q, want %q", rows[5], lead)
}
- // THE ID IS META: the title leads the row and the handle trails it, dim.
- if !strings.Contains(rail, "#2") || strings.Contains(rail, "#2 Fix") {
- t.Fatalf("the node's id is not the trailing meta of its row:\n%s", rail)
+ if strings.Contains(rail, "#2") || strings.Contains(rail, plain(a.taskMark(identFor(2)))+" Fix") {
+ t.Fatalf("the row carries an id or the identity mark:\n%s", rail)
}
- // AND THE FOOTER SAYS THE WHOLE, in the group vocabulary.
- for _, want := range []string{"1 running", "1 needs you",
- "1 queued", "1 waiting", "2 done"} {
- if !strings.Contains(rail, want) {
- t.Fatalf("the footer does not say %q:\n%s", want, rail)
- }
- }
- // AND IT COUNTS RATHER THAN SUMS. The `Σ` led the foot's first line while
- // that line was the session's bill; the bill left for the status row and the
- // sign went with it — a mathematician's mark in front of a row of counts is
- // furniture claiming to be structure (task.go's [app.railFootRows]).
- // AND IT COUNTS WHAT THE COLUMN HOLDS AND NOTHING ELSE. The bill and the
- // token total were on this foot until 2026-09-09, and both are the SESSION's
- // — the same two figures the status row two lines down already draws. One
- // number drawn twice on one frame is one of them wrong the moment they
- // disagree, and the second copy cost this column two of its three lines
- // (task.go's [app.railFootRows]).
+ // AND THE COLUMN REPEATS NOTHING THE STATUS ROW SAYS.
for _, gone := range []string{"$1.42", "312k tok", "Σ"} {
if strings.Contains(rail, gone) {
- t.Fatalf("the footer repeats the status row's %q:\n%s", gone, rail)
+ t.Fatalf("the column repeats the status row's %q:\n%s", gone, rail)
}
}
+ // A FOLDED GROUP OPENS ON A PRESS AND STAYS OPEN FOR THE SESSION.
+ for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
+ if e, ok := a.railEntryAt(y); ok && e.head && e.group == railIdle {
+ drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
+ break
+ }
+ }
+ if rail := rosterText(a, 24); !strings.Contains(rail, railHeadWords[railIdle]+" 1 "+glyphOpen) || !strings.Contains(rail, "Cut the trailer") {
+ t.Fatalf("a press on the folded heading did not open it:\n%s", rail)
+ }
+ a.dropTasks()
+ a.taskUpdate(update(9, "Later work", session.TaskQueued, session.TaskNotice{}))
+ if rail := rosterText(a, 24); !strings.Contains(rail, "Later work") {
+ t.Fatalf("the group did not stay open for the session:\n%s", rail)
+ }
}
func TestTheRosterTakesTheKeyboardOnlyWhenItIsHandedIt(t *testing.T) {
@@ -3925,13 +3924,13 @@ func TestTheRosterTakesTheKeyboardOnlyWhenItIsHandedIt(t *testing.T) {
if !strings.Contains(rosterText(a, 16), railMark) {
t.Fatalf("the focused row has no marker:\n%s", rosterText(a, 16))
}
- // The cursor opens on the oldest task; the session began with node 7.
- if a.railWhere.id != 7 {
- t.Fatalf("the cursor opened on %+v, want the first running node", a.railWhere)
+ // The cursor opens on the newest running task, under its heading.
+ if a.railWhere.id != 2 {
+ t.Fatalf("the cursor opened on %+v, want the newest running node", a.railWhere)
}
drive(t, a, key("down"))
- if a.railWhere.id != 1 {
- t.Fatalf("↓ walked to %+v, want the next task created", a.railWhere)
+ if a.railWhere.id != 7 {
+ t.Fatalf("↓ walked to %+v, want the next running task", a.railWhere)
}
// Typing still reaches the box while the roster holds the arrows: only the
@@ -3950,13 +3949,13 @@ func TestTheRosterTakesTheKeyboardOnlyWhenItIsHandedIt(t *testing.T) {
// The pointer's half: a press on a row is that node's door, and it does not
// take the keyboard on its way past.
for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if node := a.railNodeAt(y); node != nil && node.id == 1 {
+ if node := a.railNodeAt(y); node != nil && node.id == 2 {
drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
break
}
}
- if !a.roomOpen() || a.room.id != 1 {
+ if !a.roomOpen() || a.room.id != 2 {
t.Fatalf("a press on a roster row did not open its room: open=%v", a.roomOpen())
}
if a.railHold {
@@ -3970,23 +3969,26 @@ func TestTheRostersCursorFollowsANodeThatChangesUrgency(t *testing.T) {
streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskRunning, session.TaskNotice{})},
streamEventMsg{gen: a.gen, ev: update(2, "Fix the nil-map crash", session.TaskRunning, session.TaskNotice{})},
altT(),
- key("down"), // the newest running node
+ key("down"), // the older running node
)
- if a.railWhere.id != 2 {
+ if a.railWhere.id != 1 {
t.Fatalf("the cursor is on %+v, want the second running node", a.railWhere)
}
- // It finishes with its branch conflicted, which is the one outcome that needs a
- // person — while both its row and the cursor keep their place.
- drive(t, a, streamEventMsg{gen: a.gen, ev: update(2, "Fix the nil-map crash", session.TaskFailed,
- session.TaskNotice{Merge: mergeWordConflicted, Branch: "task/fix-nil-map"})})
+ // It lands in the done group, which is folded: the cursor follows it to the
+ // heading standing for it rather than jumping to the top of the column.
+ drive(t, a, streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone,
+ session.TaskNotice{Merge: mergeWordMerged})})
entries := a.railEntries()
at := a.railFocusIndex(entries)
- if at < 0 || entries[at].node == nil || entries[at].node.id != 2 {
- t.Fatalf("the cursor did not follow the node: %+v", entries)
+ if at < 0 || !entries[at].head || entries[at].group != railDone {
+ t.Fatalf("the cursor did not follow the node to its group: %d in %+v", at, entries)
}
- // Neither the row nor the cursor moves when the task needs input.
- if at != 1 {
- t.Fatalf("the node with a conflicted branch moved to row %d, want its original row", at)
+ // And opened, the cursor is on the node itself.
+ a.sideToggleGroup(railDone)
+ entries = a.railEntries()
+ at = a.railFocusIndex(entries)
+ if at < 0 || entries[at].node == nil || entries[at].node.id != 1 {
+ t.Fatalf("the cursor is not on the landed node: %d in %+v", at, entries)
}
}
@@ -4000,15 +4002,14 @@ func TestTheRosterWindowsHundredsOfNodesAroundItsFocus(t *testing.T) {
t.Fatalf("the roster drew %d rows into a 12-row column", len(rows))
}
for i, line := range rows {
- if w := ansi.StringWidth(plain(line)); w > railCols {
- t.Fatalf("roster row %d is %d cells wide, want at most %d:\n%q", i, w, railCols, line)
+ if w := ansi.StringWidth(plain(line)); w > a.railWidth() {
+ t.Fatalf("roster row %d is %d cells wide, want at most %d:\n%q", i, w, a.railWidth(), line)
}
}
- // Three hundred families of one, all equally urgent, so the column is in the
- // order the session admitted them — under the section label the column opens
- // with (margin.go).
- if !strings.Contains(plain(rows[2]), "node 1") {
- t.Fatalf("the first row is not the first node the session met:\n%q", rows[2])
+ // Three hundred running nodes, newest first under their heading, which is
+ // under the column's header.
+ if !strings.Contains(plain(rows[1]), railHeadWords[railRunning]+" 300") || !strings.Contains(plain(rows[2]), "node 300") {
+ t.Fatalf("the first rows are not the heading and the newest node:\n%q\n%q", rows[1], rows[2])
}
// Twenty rows down is past the window, so the window moves.
@@ -4017,19 +4018,16 @@ func TestTheRosterWindowsHundredsOfNodesAroundItsFocus(t *testing.T) {
drive(t, a, key("down"))
}
rail := rosterText(a, 12)
- if !strings.Contains(rail, "node 21") || !strings.Contains(rail, railMark) {
+ if !strings.Contains(rail, "node 280") || !strings.Contains(rail, railMark) {
t.Fatalf("the window did not follow the cursor down:\n%s", rail)
}
- // The task sequence scrolls together while the hide control stays fixed.
- if strings.Contains(rail, "node 1 ") {
+ // The task sequence scrolls together while the header stays fixed.
+ if strings.Contains(rail, "node 300") {
t.Fatalf("the first task did not scroll with the list:\n%s", rail)
}
- if rows := railText(a, 12); !strings.Contains(rows[0], railStowHint) {
- t.Fatalf("the hide control scrolled away: %v", rows)
- }
- // And the footer still counts the whole roster rather than the window.
- if !strings.Contains(rail, "300 "+railGroupWords[railRunning]) {
- t.Fatalf("the footer counts the window instead of the roster:\n%s", rail)
+ // And the header still counts the whole roster rather than the window.
+ if rows := railText(a, 12); !strings.Contains(rows[0], sideTasksWord+" 300") || !strings.Contains(rows[0], sideHideKey) {
+ t.Fatalf("the header scrolled away or counts the window: %v", rows)
}
}
@@ -4037,6 +4035,11 @@ func TestTheRosterWindowsHundredsOfNodesAroundItsFocus(t *testing.T) {
// remains actionable; retaining a branch alone is not a request to merge it.
func TestAPlainFailureIsFiledAsNewsAndNotAsADemand(t *testing.T) {
a, _, _ := taskApp(t)
+ // Every node is seen running first, as live work is: a failure that is a
+ // node's first news was replayed by a reopened conversation, and is history.
+ for _, id := range []uint64{1, 2, 3, 4, 5, 6} {
+ a.taskUpdate(update(id, "work "+itoa(int(id)), session.TaskRunning, session.TaskNotice{}))
+ }
drive(t, a,
streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, session.TaskNotice{
Merge: mergeWordMerged,
@@ -4091,21 +4094,32 @@ func TestAPlainFailureIsFiledAsNewsAndNotAsADemand(t *testing.T) {
}
}
- // The footer counts two decisions and four finished reports.
+ // THE TWO DECISIONS ARE THE BAND'S, IN AMBER, AND SO ARE THE THREE
+ // FAILURES NOBODY HAS OPENED, IN INK: five items, three rows and `+2 more`,
+ // and none of them is drawn again under Done, which holds the one clean
+ // merge (sidecol.go).
+ band := a.sideBand()
+ var asks, fails []string
+ for _, item := range band {
+ if item.ask {
+ asks = append(asks, item.key)
+ } else {
+ fails = append(fails, item.key)
+ }
+ }
+ if strings.Join(asks, " ") != "task/5 task/4" || strings.Join(fails, " ") != "fail/6 fail/3 fail/2" {
+ t.Fatalf("the band holds asks %v and failures %v", asks, fails)
+ }
rail := rosterText(a, 24)
- for _, want := range []string{"2 needs you", "4 done"} {
+ for _, want := range []string{"+2 more", railHeadWords[railDone] + " 1"} {
if !strings.Contains(rail, want) {
t.Fatalf("the roster is missing %q:\n%s", want, rail)
}
}
- // Demands retain their state without displacing earlier tasks.
- previous := -1
- for _, title := range []string{"Collect sources", "Render titles", "Mix audio", "Port the parser", "Cut the trailer", "Write the auth"} {
- at := strings.Index(rail, title)
- if at <= previous {
- t.Fatalf("%q moved out of creation order:\n%s", title, rail)
- }
- previous = at
+ a.sideToggleGroup(railDone)
+ rail = rosterText(a, 24)
+ if !strings.Contains(rail, "Collect sources") || strings.Count(rail, "Render titles")+strings.Count(rail, "Port the parser") != 1 {
+ t.Fatalf("an item of the band is drawn twice, or the clean merge is missing:\n%s", rail)
}
}
@@ -4310,7 +4324,9 @@ func clickRail(t *testing.T, a *app, node int) {
// rows are the BODY REGION's rows, and the rows the frame pins above it — the
// room's focus header, the task strip — move them down by their own height
// (room.go, taskstrip.go, view.go's [app.topHeight]) — so the rail's first
- // row is not always the frame's first row.
+ // row is not always the frame's first row. Every group is opened first, so
+ // the rows counted are every row the list has.
+ railOpenAll(a)
head := a.bodyTop()
seen, at := 0, -1
for y := head; y < head+a.viewHeight(); y++ {
diff --git a/internal/tui3/c246_tree_test.go b/internal/tui3/c246_tree_test.go
index 9ce2a037b3..e5879ad0ec 100644
--- a/internal/tui3/c246_tree_test.go
+++ b/internal/tui3/c246_tree_test.go
@@ -89,8 +89,13 @@ func TestPlanRoomUnderItDrawsWholeSubtreeWithLiveLines(t *testing.T) {
t.Fatalf("the run's room is missing %q:\n%s", want, text)
}
}
+ // THE ROOM IS READ, NOT THE SIDE COLUMN BESIDE IT: every chat has the column
+ // now, and its Tasks view draws the same parts at its own indent.
var handlerLead, fixtureLead int
for _, line := range strings.Split(text, "\n") {
+ if at := strings.LastIndex(line, railSeam); at >= 0 {
+ line = line[:at]
+ }
if at := strings.Index(line, "Handler"); at >= 0 {
handlerLead = at
}
diff --git a/internal/tui3/c263_rail_plan_test.go b/internal/tui3/c263_rail_plan_test.go
index 3b391a1457..14a0398a46 100644
--- a/internal/tui3/c263_rail_plan_test.go
+++ b/internal/tui3/c263_rail_plan_test.go
@@ -25,9 +25,10 @@ func c263PlanRows() []session.PlanTaskRow {
}
}
-// A RUN'S ROWS ARE THE OLD TASK ROWS. Every part is its own row in the old
-// tree's connectors — the finished ones included, never folded to a count —
-// and the part in flight says its call the way a node row says one.
+// A RUN'S ROWS ARE THE SIDE COLUMN'S TASK ROWS. Every part is its own line,
+// the finished ones included, never folded to a count; what a part is doing is
+// the hint line's, as it is for every task on the column (DESIGN.md, One side
+// column), so no live line and no handle stand on the rows.
func TestRailPlanDrawsEveryPartAsATaskRow(t *testing.T) {
a, _ := planAppWith(t, c263PlanRows(), nil)
a.width, a.height, a.railWide = 120, 30, true
@@ -36,7 +37,7 @@ func TestRailPlanDrawsEveryPartAsATaskRow(t *testing.T) {
}
got := plain(strings.Join(a.railRows(a.viewHeight()), "\n"))
for _, word := range []string{"rewrite the auth", "implement handler", "schema migration",
- "integration tests", "old fixture", "old helper", "bash git grep", "#root", "#live"} {
+ "integration tests", "old fixture", "old helper"} {
if !strings.Contains(got, word) {
t.Fatalf("the rail lacks %q:\n%s", word, got)
}
@@ -88,14 +89,12 @@ func railShape(line string) string {
}
// EVERY TASK LOOKS THE SAME, AND THIS IS THE PROOF OF IT. One rail holds a run
-// from the plan store — its own row and three parts — and a family of this
-// window's own nodes built the same way: a running head, a finished child, a
-// running child and a queued one, with the same clock, the same price and ids
-// of the same width. The two blocks are drawn by one renderer, so with the
-// words taken out they are the same bytes, line for line: the same spinner at
-// the same size, the same `#id` slot, the same `time · cost` line, the same
-// connectors. A run renderer of its own — a still half-circle, no handle, a
-// `✓ N done` fold — fails this on its first line.
+// from the plan store and a running node of this window's own with the same
+// clock and a title of the same width. Both are drawn by one renderer, the
+// side column's one line (DESIGN.md, One side column), so with the words taken
+// out the run's row and the node's row are the same bytes: the same spinner,
+// the same indent, the same time at the right. A run renderer of its own, a
+// still half-circle or a `✓ N done` fold, fails this on its first line.
func TestARunAndANodeFamilyDrawTheSameShapeOnTheRail(t *testing.T) {
now := taskFixtureNow
rows := []session.PlanTaskRow{
@@ -108,10 +107,6 @@ func TestARunAndANodeFamilyDrawTheSameShapeOnTheRail(t *testing.T) {
a.width, a.height = 160, 30
running := session.TaskNotice{Elapsed: 4 * time.Minute, CostUSD: 0.02}
a.taskUpdate(update(1, "Alpha work", session.TaskRunning, running))
- a.taskUpdate(update(2, "Kid one A", session.TaskDone, session.TaskNotice{}))
- a.taskUpdate(update(3, "Kid two A", session.TaskRunning, running))
- a.taskUpdate(update(4, "Kid six A", session.TaskQueued, session.TaskNotice{}))
- railKinship(a, 1, 2, 3, 4)
a.paints = 0
readPlanRows(t, a)
@@ -125,39 +120,23 @@ func TestARunAndANodeFamilyDrawTheSameShapeOnTheRail(t *testing.T) {
t.Fatalf("the rail has no row for %q:\n%s", title, strings.Join(lines, "\n"))
return -1
}
- run, family := at("Bravo work"), at("Alpha work")
- if run > family {
- t.Fatalf("the run is drawn at %d and the family at %d; the run's rows stand ahead of the node rows:\n%s",
- run, family, strings.Join(lines, "\n"))
+ run, node := at("Bravo work"), at("Alpha work")
+ if got, want := railShape(lines[run]), railShape(lines[node]); got != want {
+ t.Fatalf("the run's row is shaped\n%q\nand the node's row\n%q\n\n%s", got, want, strings.Join(lines, "\n"))
}
- runBlock, familyBlock := lines[run:family], lines[family:family+(family-run)]
- for i := range runBlock {
- if got, want := railShape(runBlock[i]), railShape(familyBlock[i]); got != want {
- t.Fatalf("line %d of the run is shaped\n%q\nand the same line of the node family is\n%q\n\nrun:\n%s\n\nfamily:\n%s",
- i, got, want, strings.Join(runBlock, "\n"), strings.Join(familyBlock, "\n"))
- }
- }
- // AND THE SHAPE IS THE OLD ONE: the braille spinner on the rows that are
- // working, a handle at the end of every row, the clock and the price under
- // the running head, and every part its own row.
spinner := tokens.Spinner(0)
- if !strings.HasPrefix(strings.TrimPrefix(runBlock[0], railSeam), spinner+" Bravo work") {
- t.Fatalf("the run's row does not lead with the working spinner %q:\n%s", spinner, runBlock[0])
- }
- if !strings.HasSuffix(strings.TrimRight(runBlock[0], " "), "#9") {
- t.Fatalf("the run's row does not end in its handle:\n%s", runBlock[0])
+ if !strings.Contains(lines[run], spinner+" Bravo work") {
+ t.Fatalf("the run's row does not wear the working spinner %q:\n%s", spinner, lines[run])
}
- if !strings.Contains(runBlock[1], "4m · $0.02") {
- t.Fatalf("the run's under-row is not the clock and the price:\n%s", strings.Join(runBlock, "\n"))
- }
- if strings.Contains(strings.Join(runBlock, "\n"), "done") && !strings.Contains(strings.Join(runBlock, "\n"), "Kid one B") {
- t.Fatalf("the run's finished part was folded into a count:\n%s", strings.Join(runBlock, "\n"))
+ for _, part := range []string{"Kid one B", "Kid two B", "Kid six B"} {
+ if at(part) <= run {
+ t.Fatalf("the part %q is not its own row under the run:\n%s", part, strings.Join(lines, "\n"))
+ }
}
}
// A NODE ROW THAT CARRIES A RUN IS THE RUN'S ROW, and the run's parts hang under
-// it in the tree's own connectors. The row keeps its node — its handle, its
-// telemetry and its door — and is drawn as the head of a family.
+// it a level in. The row keeps its node, and its door, and is drawn once.
func TestARunsPartsHangUnderTheNodeRowThatCarriesIt(t *testing.T) {
root := session.PlanTaskRow{ID: "t-6", Title: "Sweep the issues", Status: "claimed"}
kid := session.PlanTaskRow{ID: "t-k3x9qa", Parent: "t-6", Title: "Check the fix", Status: "claimed",
@@ -185,11 +164,8 @@ func TestARunsPartsHangUnderTheNodeRowThatCarriesIt(t *testing.T) {
if head < 0 || part <= head {
t.Fatalf("the part is at %d and its run at %d, want it under the run:\n%s", part, head, text)
}
- if !strings.HasSuffix(strings.TrimRight(lines[head], " "), "#6") || !strings.HasSuffix(strings.TrimRight(lines[part], " "), "#k3x9qa") {
- t.Fatalf("the run and its part do not wear their handles:\n%s", text)
- }
- if !strings.Contains(lines[part], treeLast) {
- t.Fatalf("the part does not hang from the tree's connector:\n%s", text)
+ if strings.Index(lines[part], "Check the fix") <= strings.Index(lines[head], "Sweep the issues") {
+ t.Fatalf("the part is not a level in under its run:\n%s", text)
}
}
diff --git a/internal/tui3/c266_rail_row_test.go b/internal/tui3/c266_rail_row_test.go
index 2b117ef737..6e64742e74 100644
--- a/internal/tui3/c266_rail_row_test.go
+++ b/internal/tui3/c266_rail_row_test.go
@@ -86,7 +86,8 @@ func TestANarrowRailsHeldRowKeepsItsTitle(t *testing.T) {
func TestTheRailIndentsATaskUnderItsParentTask(t *testing.T) {
rows := c266PlanRows()
rows = append(rows, session.PlanTaskRow{ID: "kid", Parent: "held", Title: "write the fixtures", Status: "pending"})
- _, rail := c266Rail(t, rows, 150, false)
+ // The widened column, where both titles are drawn whole beside the wait.
+ _, rail := c266Rail(t, rows, 160, true)
_, parent := c266RowWith(t, rail, "write the tests")
_, child := c266RowWith(t, rail, "write the fi")
if strings.Index(child, "write") <= strings.Index(parent, "write") {
@@ -94,24 +95,22 @@ func TestTheRailIndentsATaskUnderItsParentTask(t *testing.T) {
}
}
-// A FAMILY'S LINE IS ONE UNBROKEN STROKE. The live line under a running task and
-// the task under a task both stand between two siblings, and each used to leave
-// a blank in the family's column, so the tree read as loose pieces.
+// A TASK UNDER A TASK STANDS A LEVEL IN, AND THE LINE A RUNNING PART WOULD
+// HAVE HAD UNDER IT IS THE HINT'S. The family's connectors are gone from the
+// side column (DESIGN.md, One side column): every task is one line, and a part
+// shows its depth by its indent alone.
func TestTheFamilysLineRunsThroughTheLinesUnderARow(t *testing.T) {
rows := c266PlanRows()
rows = append(rows, session.PlanTaskRow{ID: "kid", Parent: "held", Title: "write the fixtures", Status: "pending"})
rows = append(rows, session.PlanTaskRow{ID: "after", Parent: "root", Title: "update the manual", Status: "pending"})
_, rail := c266Rail(t, rows, 150, false)
at, handler := c266RowWith(t, rail, "write the handler")
- column := strings.Index(handler, "├")
- if column < 0 {
- t.Fatalf("the running row has no connector:\n%s", handler)
- }
- if live := plain(rail[at+1]); !strings.HasPrefix(live[column:], "│") {
- t.Fatalf("the live line breaks the family's stroke:\n%s\n%s", handler, live)
+ if at+1 < len(rail) && strings.Contains(plain(rail[at+1]), "$ git grep") {
+ t.Fatalf("a live line stands under a one-line task:\n%s\n%s", handler, plain(rail[at+1]))
}
+ _, held := c266RowWith(t, rail, "write the tests")
_, kid := c266RowWith(t, rail, "write the fi")
- if !strings.HasPrefix(kid[column:], "│") {
- t.Fatalf("the task under a task breaks its parent's family stroke:\n%s", kid)
+ if strings.Index(kid, "write the fi") <= strings.Index(held, "write the tests") {
+ t.Fatalf("the task under a task is not a level in:\n%s\n%s", held, kid)
}
}
diff --git a/internal/tui3/c295_rail_task_page_test.go b/internal/tui3/c295_rail_task_page_test.go
index 3c031972ef..b3eb9a292c 100644
--- a/internal/tui3/c295_rail_task_page_test.go
+++ b/internal/tui3/c295_rail_task_page_test.go
@@ -181,11 +181,13 @@ func TestARunIsItsOwnRowWithItsPartsUnderItAndEachOpensItsPage(t *testing.T) {
for i, line := range view {
text := plain(line.text)
switch {
- case strings.Contains(text, "land the parser"):
+ // The second part's row names the run it waits on, so the run's own
+ // row is the first that carries its title.
+ case strings.Contains(text, "land the parser") && rootAt < 0:
rootAt = i
case strings.Contains(text, "write the parser"):
firstAt = i
- case strings.Contains(text, "cover the parser"):
+ case strings.Contains(text, "cover the"):
secondAt = i
}
}
diff --git a/internal/tui3/caption.go b/internal/tui3/caption.go
index 30c44a38ea..fb66a95e98 100644
--- a/internal/tui3/caption.go
+++ b/internal/tui3/caption.go
@@ -10,6 +10,7 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/Agent-Field/codeaf/internal/session"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
)
// caption is the title of one discrete step of a turn's work.
@@ -271,6 +272,13 @@ func composeCaption(es []entry, from, to int) string {
if dominant == "reading" || dominant == "editing" {
noun = captionPlural(n, "file")
}
+ // A manager's run of team calls, said as the team work it is.
+ if dominant == "sending" {
+ noun = captionPlural(n, "message")
+ }
+ if dominant == "managing" {
+ return "managing the team"
+ }
if dominant == "running" {
// Never "running N calls" — that is the count twice.
return "running " + strconv.Itoa(n) + " " + captionPlural(n, "command")
@@ -303,6 +311,9 @@ func toolCaptionGloss(e entry) string {
case "gh", "github":
return "asking github"
}
+ if gloss := teamCaptionGloss(e); gloss != "" {
+ return gloss
+ }
// Fall back to the session's own hint with the tool name stripped, so
// "bash gh issue list…" becomes something about the work, not the verb.
_, gloss := toolWords(e.tool, e.text)
@@ -316,6 +327,61 @@ func toolCaptionGloss(e entry) string {
return captionWords(gloss)
}
+// teamCaptionGloss is a team tool's step said as the work it is. A manager's
+// turn is mostly these calls, and with no gloss its fold read `▾ team_send 1
+// call … 1 call`: the tool's own name where the work should be, and the count
+// said twice. "" for any other tool.
+func teamCaptionGloss(e entry) string {
+ args := argsOf(e.detail.Args)
+ at := func(key string) string {
+ var out []string
+ for _, h := range strings.Fields(argString(args, key)) {
+ h = strings.TrimPrefix(h, "@")
+ if h == teamstore.ToEveryone || h == teamstore.ToRoom || h == teamstore.ToManager {
+ out = append(out, h)
+ continue
+ }
+ out = append(out, "@"+h)
+ }
+ return strings.Join(out, " ")
+ }
+ switch e.tool {
+ case "team_send":
+ if to := at("to"); to != "" {
+ return "messaging " + to
+ }
+ return "messaging the team"
+ case "team_post":
+ if to := at("to"); to != "" {
+ return "posting to " + to
+ }
+ return "posting to the team"
+ case "team_start":
+ if h := at("handle"); h != "" {
+ return "starting " + h
+ }
+ case "team_stop":
+ if h := at("handle"); h != "" {
+ return "stopping " + h
+ }
+ case "team_read":
+ if h := at("handle"); h != "" {
+ return "reading " + h
+ }
+ case "team_status":
+ return "checking the team"
+ case "team_raise":
+ return "raising a decision"
+ case "team_decide":
+ return "deciding"
+ case "team_escalate":
+ return "passing a decision up"
+ case "team_close_report":
+ return "writing the closing report"
+ }
+ return ""
+}
+
func bashCaption(command string) string {
command = strings.TrimSpace(command)
if command == "" {
@@ -410,6 +476,10 @@ func captionVerb(tool string) string {
return "looking up"
case "ls":
return "listing"
+ case "team_send", "team_post":
+ return "sending"
+ case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report":
+ return "managing"
default:
return firstNonEmpty(tool, "working")
}
@@ -427,14 +497,24 @@ var captionPastVerbs = map[string]string{
"asking": "asked",
"building": "built",
"checking": "checked",
+ "deciding": "decided",
"editing": "edited",
"fetching": "fetched",
"listing": "listed",
"looking": "looked",
+ "managing": "managed",
+ "messaging": "messaged",
+ "passing": "passed",
+ "posting": "posted",
+ "raising": "raised",
"reading": "read",
"running": "ran",
"searching": "searched",
+ "sending": "sent",
+ "starting": "started",
+ "stopping": "stopped",
"working": "worked",
+ "writing": "wrote",
}
// captionPast is the one door onto that table: a floor caption composed in the
diff --git a/internal/tui3/chatstart_navigation_test.go b/internal/tui3/chatstart_navigation_test.go
index 457fcd9d41..b489d8f308 100644
--- a/internal/tui3/chatstart_navigation_test.go
+++ b/internal/tui3/chatstart_navigation_test.go
@@ -22,7 +22,7 @@ func TestNewChatTabOwnsTheSelectionAndCloseReturnsTheDraft(t *testing.T) {
if plus.span.to == 0 {
t.Fatal("no plus control")
}
- cmd, took := a.tabPress(plus.span.from, placeTabRow)
+ cmd, took := a.tabPress(plus.span.from, tabStripRow)
if !took {
t.Fatal("plus did not take click")
}
@@ -45,7 +45,7 @@ func TestNewChatTabOwnsTheSelectionAndCloseReturnsTheDraft(t *testing.T) {
if active != 1 || close.span.to == 0 {
t.Fatalf("active=%d close=%+v", active, close)
}
- cmd, _ = a.tabPress(close.span.from, placeTabRow)
+ cmd, _ = a.tabPress(close.span.from, tabStripRow)
drain(t, a, cmd)
if a.startingChat() || a.input.String() != "old draft" {
t.Fatalf("close lost old context: %q", a.input.String())
diff --git a/internal/tui3/chattabclose_test.go b/internal/tui3/chattabclose_test.go
index 87c1680660..9cd4d78603 100644
--- a/internal/tui3/chattabclose_test.go
+++ b/internal/tui3/chattabclose_test.go
@@ -180,7 +180,7 @@ func TestTheCloseTargetNeverFallsThroughIntoSelectingTheTab(t *testing.T) {
t.Fatalf("the close target overlaps the label: label=%+v close=%+v", label, span)
}
for x := span.from; x < span.to; x++ {
- hit, ok := a.tabAt(x, placeTabRow)
+ hit, ok := a.tabAt(x, tabStripRow)
if !ok || hit.kind != tabClose {
t.Fatalf("column %d of the close target answers as %+v", x, hit)
}
@@ -199,10 +199,10 @@ func TestTheSeparatorsAreInertAndTheRowIsStillTheStrips(t *testing.T) {
before := a.file
label := tabSpanFor(t, a, "openrouter price scrape")
sep := label.from - 1 // the rule in front of the first tab
- if _, ok := a.tabAt(sep, placeTabRow); ok {
+ if _, ok := a.tabAt(sep, tabStripRow); ok {
t.Fatalf("column %d is a separator and answers as a target", sep)
}
- if _, took := a.tabPress(sep, placeTabRow); !took {
+ if _, took := a.tabPress(sep, tabStripRow); !took {
t.Fatal("a press on the strip's own furniture fell through the row")
}
if a.file != before || a.hop.open {
@@ -405,16 +405,16 @@ func headerBudgeted(t *testing.T, a *app, where string) {
t.Fatalf("%s at %dx%d the header draws %d rows and is charged %d",
where, size.w, size.h, drawn, a.headHeight())
}
- if a.room == nil && drawn != 0 && drawn != placeHeadRows {
- t.Fatalf("%s at %dx%d the head is %d rows, not the places' %d",
- where, size.w, size.h, drawn, placeHeadRows)
+ if a.room == nil && drawn != 0 && drawn != chatHeadRows {
+ t.Fatalf("%s at %dx%d the head is %d rows, not the chat's %d",
+ where, size.w, size.h, drawn, chatHeadRows)
}
// AND THE ROWS CHARGED ARE THE ROWS DRAWN: the pulse on top, the rule
// where the geometry says the seam is.
- if a.room == nil && drawn == placeHeadRows &&
- (!strings.HasPrefix(plain(rows[0]), " "+plain(a.pal.wordmark(a.width))) || strings.Trim(plain(rows[placeTabRow+1]), "─") != "") {
+ if a.room == nil && drawn == chatHeadRows &&
+ (!strings.HasPrefix(plain(rows[0]), " "+plain(a.pal.wordmark(a.width))) || strings.Trim(plain(rows[tabStripRow+1]), "─") != "") {
t.Fatalf("%s at %dx%d the head is charged as the places' and drawn as something else:\n%q\n%q",
- where, size.w, size.h, plain(rows[0]), plain(rows[placeTabRow+1]))
+ where, size.w, size.h, plain(rows[0]), plain(rows[tabStripRow+1]))
}
// A frame with no body region left answers -1 and has nothing to check
// (view.go's [app.bodyTop]); everywhere else the body starts exactly under
@@ -448,7 +448,7 @@ func TestEachHeaderRowAnswersForItselfAndForNoOther(t *testing.T) {
if _, ok := a.tabAt(headLabelAt+1, a.roomHeadRow()); ok {
t.Fatalf("at %d columns the tab row answers on the trail's row", width)
}
- if _, ok := a.crumbAt(headLabelAt+1, placeTabRow); ok {
+ if _, ok := a.crumbAt(headLabelAt+1, tabStripRow); ok {
t.Fatalf("at %d columns the trail answers on the tab row", width)
}
if _, ok := a.crumbAt(headLabelAt+1, a.roomFactsRow()); ok {
diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go
index d97a525717..737781a420 100644
--- a/internal/tui3/chattabs.go
+++ b/internal/tui3/chattabs.go
@@ -82,13 +82,19 @@ const (
tabSep = "│"
tabSepASCII = "|"
// tabCloseMark is the dismissal on a tab, and tabCloseCells is the room kept
- // for it whether or not it is drawn. The CELLS ARE RESERVED ON EVERY TAB
- // because the mark appears under the pointer: a row that grew two cells when
- // a hand crossed it would re-pack every label beside it, and the tab somebody
- // was reaching for would move out from under them.
+ // for it whether or not it is drawn: the mark and the tab's closing inset.
+ // The CELLS ARE RESERVED ON EVERY TAB because the mark appears under the
+ // pointer: a row that grew two cells when a hand crossed it would re-pack
+ // every label beside it, and the tab somebody was reaching for would move
+ // out from under them.
+ //
+ // THE AIR BEFORE THE MARK IS THE LABEL'S OWN PAD, so a tab is symmetric: the
+ // inset, the status slot and the pad lead the name, and the pad, the mark
+ // and the inset close it (` ◐ name × `). It used to carry a blank of its own
+ // as well, which left every tab one cell wider on the right than the left.
tabCloseMark = "×"
tabCloseASCII = "x"
- tabCloseCells = 3
+ tabCloseCells = 2
// The filled tab includes an inset before its status mark and after its close.
tabInsetCells = 1
// tabWordCap is the widest a tab's label is drawn on a frame with room to
@@ -186,7 +192,6 @@ const (
// second door onto a card the legend already names a key for.
tabFold
tabNew
- tabHome
tabScrollLeft
tabScrollRight
// tabTeam is the chip at the row's left end naming the team the strip is
@@ -221,7 +226,7 @@ func (h tabHit) door(a *app) bool {
switch h.kind {
case tabHere:
return a.roomOpen() || a.startingChat()
- case tabOther, tabClose, tabNew, tabHome, tabScrollLeft, tabScrollRight, tabTeam, tabWall, tabManager:
+ case tabOther, tabClose, tabNew, tabScrollLeft, tabScrollRight, tabTeam, tabWall, tabManager:
return true
}
return false
@@ -271,7 +276,6 @@ type tabBar struct {
hot int
more bool
newChat bool
- home bool
tabs []chatTab
line string
hits []tabHit
@@ -483,8 +487,9 @@ func tabsCapped(tabs []chatTab, prev []string) []chatTab {
// those numbers.
// tabsHeight is what the head costs the body region DOWN TO AND INCLUDING THE
-// STRIP: the pulse, and the strip under it on the bar's own row
-// ([placeTabRow]).
+// STRIP when a conversation is in front: the pulse, and the strip under it
+// ([tabStripRow]). On a place the strip is not drawn, so this is the nav's
+// one row and the page owns the row under it (head.go's [app.stripInHead]).
//
// IT STANDS DOWN ON THE TWO FLOORS THE CONVERSATION'S BAR STOOD DOWN ON. A frame
// too narrow for a name and a way out is too narrow for this, and a terminal too
@@ -503,11 +508,15 @@ func (a *app) tabsHeight(width int) int {
if width < roomHeadFloor || a.breathingRows() < 2 {
return 0
}
- return placeTabRow + 1
+ if !a.stripInHead() {
+ return navRow + 1
+ }
+ return tabStripRow + 1
}
-// headSealHeight is the rule and the blank under the strip — the rest of the
-// head's [placeHeadRows] — in the conversation and in every room inside it.
+// headSealHeight is the rule and the blank under the strip, or under the nav
+// on a place. It is two rows wherever the head is drawn, so a chat's head is
+// [chatHeadRows] and a place's is [placeHeadRows].
//
// IT IS WHAT SEPARATES THE HEAD FROM THE TRANSCRIPT AND FROM THE ROSTER BESIDE
// IT, and it is a drawn rule rather than a blank because a blank separates
@@ -524,7 +533,7 @@ func (a *app) headSealHeight(width int) int {
if a.tabsHeight(width) == 0 {
return 0
}
- return placeHeadRows - a.tabsHeight(width)
+ return chatHeadRows - (tabStripRow + 1)
}
// roomHeadRow is the frame row a room's TRAIL is drawn on — the breadcrumbs and
@@ -582,43 +591,47 @@ func (a *app) tabsRow(width int) string {
more := a.hopAvailable()
chipWord := a.tabTeamWord()
room := max(width-headLabelAt, 0)
- // THE ORDER IS HOME, THE TEAM CHIP, THEN WHAT IT FILTERS: `Home` is a fixed
- // door and stands first; the chip narrows the tabs, so it sits right before
- // them, with the manager's place after it. The chip's cells are reserved
- // first, so a tab is never drawn under it; Home is the first to go when the
- // row runs short, then the chip, and never the tab in front.
+ // THE ORDER IS THE TEAM CHIP, THEN WHAT IT FILTERS: the chip narrows the
+ // tabs, so it sits right before them, with the manager's place after it.
+ // The chip's cells are reserved first, so a tab is never drawn under it;
+ // the chip goes when the row runs short, and never the tab in front.
+ //
+ // THERE IS NO `home` PIECE ON THIS ROW ANY MORE. It was the strip's way to
+ // home while the places were drawn only on a place; home is the nav's
+ // first word now, on the row over this one on every page (topnav.go).
+ //
+ // THE GAP IS ONE CELL BETWEEN ANY TWO PIECES, the chip included: every piece
+ // carries its own pad, so one blank between two grounds is the whole of the
+ // air, and it is the same between the chip and a tab, two tabs, a tab and
+ // `+`, and `+` and `▦ All`. The row's first ground is at [headLabelAt]
+ // whichever piece it is, so the strip does not step sideways when a team is
+ // shown or put away.
chipW, chipNeed := 0, 0
if chipWord != "" {
chipNeed = 2*ansi.StringWidth(chipWord) + tabWordFloor + tabCloseCells + tabInsetCells + 8
if room >= chipNeed {
- chipW = ansi.StringWidth(chipWord) + 1
+ chipW = ansi.StringWidth(chipWord)
}
}
- // HOME WEARS THE PLACES BAR'S OWN WORD AND PADDING, so crossing between the
- // dashboard and a conversation never changes its label or its target; the
- // two rows already share this row's lead ([placeBarLead]).
- homeChip := tabPad + pageHome.word() + tabPad
- homeCols := ansi.StringWidth(homeChip)
- homeWidth := homeCols + 2
- home := a.homeDoorOpen() && room-homeWidth >= max(chipNeed, tabWordFloor+tabCloseCells+tabInsetCells)
- if !home {
- homeWidth = 0
- }
- if memo := a.chatTabBar; memo.home == home && memo.newChat == a.canStart() && memo.team == chipWord && memo.wallOn == a.wall.on && memo.menuOn == a.teamMenu.on && memo.same(width, a.inkState, hot, more, tabs) {
+ if memo := a.chatTabBar; memo.newChat == a.canStart() && memo.team == chipWord && memo.wallOn == a.wall.on && memo.menuOn == a.teamMenu.on && memo.same(width, a.inkState, hot, more, tabs) {
a.chatTabHits = memo.hits
a.wall.chip, a.wall.door = memo.chip, memo.door
return memo.line
}
- room -= chipW
- pieces, hits := a.tabsFit(tabs, room-homeWidth, tabWallCellsAt(width))
- if len(pieces) == 0 && !home && chipW == 0 {
+ // The pieces lead with a gap cell of their own ([app.tabsFit]); with no chip
+ // in front of them that cell is taken out of the row's inset instead, so
+ // the first ground stands where the chip's would.
+ base := headLabelAt + chipW
+ if chipW == 0 {
+ base = headLabelAt - ansi.StringWidth(a.tabSepWord())
+ }
+ room = max(width-base, 0)
+ pieces, hits := a.tabsFit(tabs, room, tabWallCellsAt(width))
+ if len(pieces) == 0 && chipW == 0 {
// An empty strip still occupies the header row charged to the layout.
return strings.Repeat(" ", max(width, 0))
}
- a.chatTabHits = tabsAt(hits, headLabelAt+homeWidth+chipW)
- if home {
- a.chatTabHits = append([]tabHit{{span: hudSpan{from: headLabelAt, to: headLabelAt + homeCols}, kind: tabHome}}, a.chatTabHits...)
- }
+ a.chatTabHits = tabsAt(hits, base)
// The door is laid out with the tabs, so it follows the new-chat `+`
// wherever that lands, and is then kept apart from them.
kept := a.chatTabHits[:0]
@@ -630,31 +643,33 @@ func (a *app) tabsRow(width int) string {
kept = append(kept, hit)
}
a.chatTabHits = kept
- line := strings.Repeat(" ", headLabelAt)
- if home {
- line += a.tabsPaint([]tabPiece{{word: homeChip, kind: tabHome}, {word: " ", quiet: true}})
- }
+ line := strings.Repeat(" ", min(base, headLabelAt))
if chipW > 0 {
- line += a.tabTeamPaint(chipWord, headLabelAt+homeWidth) + " "
+ line += a.tabTeamPaint(chipWord, headLabelAt)
}
line += a.tabsPaint(pieces)
// THE ROW FILLS THE FRAME, as the pulse over it does, so a door that went
// narrower leaves no cells behind it for the last frame's words.
line += strings.Repeat(" ", max(width-ansi.StringWidth(line), 0))
- a.chatTabBar = tabBar{width: width, ink: a.inkState, hot: hot, more: more, newChat: a.canStart(), home: home, line: line, hits: a.chatTabHits,
+ a.chatTabBar = tabBar{width: width, ink: a.inkState, hot: hot, more: more, newChat: a.canStart(), line: line, hits: a.chatTabHits,
tabs: append([]chatTab(nil), tabs...), team: chipWord, chip: a.wall.chip, wallOn: a.wall.on, menuOn: a.teamMenu.on, door: a.wall.door}
return line
}
// The strip's door to the conversations view. It is ` ▦ All `, the glyph and
-// the word, on a row at least tabWallWordFrom wide; the glyph alone, ` ▦ `, on
-// one at least tabWallFrom wide; and not drawn on a narrower one, where every
-// cell is a tab's and the view is still alt+v and the dock's `▦`. Its cells
-// depend on the width alone, never on the pointer, so nothing re-packs under
-// the hand.
+// the word, on a row at least tabWallFrom wide, and not drawn on a narrower
+// one, where every cell is a tab's and the view is still alt+v and the dock's
+// `▦ All`. Its cells depend on the width alone, never on the pointer, so
+// nothing re-packs under the hand.
+//
+// THERE IS NO GLYPH-ALONE RUNG ANY MORE. Between sixty and eighty columns the
+// door used to be a bare ` ▦ `, a lone mark a person had to press to learn
+// about; four cells taken from the tabs is the price of it saying what it is,
+// and the owner's bar is word buttons, never lone glyphs. tabWallWordFrom is
+// kept equal to tabWallFrom so the two rungs cannot drift apart again.
const (
tabWallFrom = 60
- tabWallWordFrom = 80
+ tabWallWordFrom = tabWallFrom
tabWallWord = "All"
)
@@ -696,7 +711,7 @@ func (a *app) tabWallPaint(word string, hot bool) string {
}
// tabTeamWord is the team chip's words, ` ● harbor ▾ `, or with no team
-// narrowing the strip a quiet ` Teams ▾ ` while there are teams to switch to,
+// narrowing the strip a quiet ` teams ▾ ` while there are teams to switch to,
// and "" when there are none (teammenu.go says why). The dot is the team's
// colour where there is one, and its initial where there is not.
func (a *app) tabTeamWord() string {
@@ -709,7 +724,7 @@ func (a *app) tabTeamWord() string {
if len(a.wall.teams) == 0 {
return ""
}
- return " Teams " + caret + " "
+ return " teams " + caret + " "
}
name := sp.Name
if ansi.StringWidth(name) > teamNameCells {
@@ -778,9 +793,10 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
tabs = tabs[1:]
}
}
+ sepW := ansi.StringWidth(a.tabSepWord())
if len(tabs) == 0 {
- if a.canStart() && room >= 3 {
- return []tabPiece{{word: " + ", kind: tabNew}}, []tabHit{{span: hudSpan{from: 0, to: 3}, kind: tabNew}}
+ if a.canStart() && room >= sepW+3 {
+ return []tabPiece{{word: a.tabSepWord(), quiet: true}, {word: " + ", kind: tabNew}}, []tabHit{{span: hudSpan{from: sepW, to: sepW + 3}, kind: tabNew}}
}
return nil, nil
}
@@ -789,17 +805,23 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
if showNew {
room -= 4
}
- if door > 0 && room-door < tabWordFloor+tabCloseCells+tabInsetCells+7 {
+ // THE DOOR STANDS ONE GAP AFTER THE `+` OR THE RIGHT ARROW, as every piece
+ // stands one gap after the one before it; the cell is held before the
+ // fitting knows whether the strip scrolls, and after a plain last tab its
+ // own gap is the door's.
+ doorGap := sepW
+ if door > 0 && room-door-doorGap < tabWordFloor+tabCloseCells+tabInsetCells+7 {
door = 0
}
- room -= door
+ if door > 0 {
+ room -= door + doorGap
+ }
active := 0
for at, tab := range tabs {
if tab.here {
active = at
}
}
- sepW := ansi.StringWidth(a.tabSepWord())
// The count is reserved BEFORE the fitting, because a figure squeezed in
// afterwards would be a figure drawn over the last tab's own cells. What it
// asks for is the widest it could want — every tab but the one in front
@@ -821,19 +843,37 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
cell := budget
if len(tabs) > 1 {
cell = min(tabWordCap, budget-2*sepW)
+ // THE NAMES SHRINK BEFORE THE STRIP SCROLLS, as a browser's tabs do: the
+ // widest share every tab can have and all of them still fit is taken,
+ // down to [tabReadableCells]. Only a row that cannot show every tab at
+ // that floor scrolls. A strip that scrolled at its widest names drew
+ // one tab, a hole the width of the tab it hid, and an arrow.
+ //
+ // A ROW THAT SPELLS EVERY TAB DRAWS NO COUNT, so the cells held for one
+ // are the names' too when every name fits in them.
+ fits := func(limit int) (int, bool) {
+ for c := min(tabWordCap, limit-2*sepW); c >= tabReadableCells; c-- {
+ used := 1
+ for _, tab := range tabs {
+ _, w := a.tabCell(tab, c)
+ used += w + sepW
+ }
+ if used <= limit {
+ return c, true
+ }
+ }
+ return 0, false
+ }
+ if c, ok := fits(budget + reserve); ok && reserve > 0 {
+ cell, budget = c, budget+reserve
+ } else if c, ok := fits(budget); ok {
+ cell = c
+ }
}
words := make([]string, len(tabs))
widths := make([]int, len(tabs))
for at, tab := range tabs {
- if tab.slot {
- // The manager's empty place is a word button: its word whole where
- // it fits, and no close cells, since nothing is behind it.
- words[at] = " " + fitConversationTitle(tab.word, max(cell-tabInsetCells-3, 1)) + " "
- widths[at] = ansi.StringWidth(words[at]) + tabInsetCells
- continue
- }
- words[at] = a.tabName(tab, cell-tabCloseCells-tabInsetCells)
- widths[at] = ansi.StringWidth(words[at]) + tabInsetCells + tabCloseCells
+ words[at], widths[at] = a.tabCell(tab, cell)
}
// THE MANAGER'S PLACE IS PINNED, as a browser pins a tab: it is drawn
// first, outside the window, and the window scrolls over the rest.
@@ -845,7 +885,7 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
from, to = from+pin, to+pin
windowBudget := budget - pinW
if scroll {
- windowBudget -= 2 * tabArrowCells
+ windowBudget -= tabArrowsCells(sepW)
}
pieces := make([]tabPiece, 0, 3*(to-from+pin)+3)
hits := make([]tabHit, 0, 2*(to-from+pin)+1)
@@ -890,6 +930,10 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
place(0, false)
}
if scroll {
+ // THE LEFT ARROW STANDS ONE GAP AFTER WHAT IS IN FRONT OF IT, as a tab
+ // does; the right one is pinned to the window's end.
+ pieces = append(pieces, tabPiece{word: a.tabSepWord(), quiet: true})
+ at += sepW
piece, hit := a.tabArrowPiece(false, from > pin, at)
pieces = append(pieces, piece)
if hit != nil {
@@ -923,10 +967,13 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
hits = append(hits, tabHit{span: hudSpan{from: at, to: at + 3}, kind: tabNew})
at += 3
}
- // The conversations view's door stands right after it, touching it as the
- // `+` touches the separator before it, each wearing its own blank either
- // side.
+ // The conversations view's door stands one gap after it, each wearing its
+ // own blank either side, as every piece of the row does.
if door > 0 {
+ if showNew || scroll {
+ pieces = append(pieces, tabPiece{word: a.tabSepWord(), quiet: true})
+ at += sepW
+ }
word := " " + a.dockWallGlyph() + " "
if door > 3 {
word += tabWallWord + " "
@@ -952,6 +999,19 @@ func (a *app) tabsFit(tabs []chatTab, room, door int) ([]tabPiece, []tabHit) {
return pieces, hits
}
+// tabCell is one tab's word and the cells it takes at a share of cell cells.
+func (a *app) tabCell(tab chatTab, cell int) (string, int) {
+ if tab.slot {
+ // The manager's empty place is a word button: its word whole where
+ // it fits, one pad either side as every word button has, and no
+ // close cells, since nothing is behind it.
+ word := fitTabTitle(tab.word, max(cell-2*tabInsetCells, 1)) + strings.Repeat(" ", tabInsetCells)
+ return word, ansi.StringWidth(word) + tabInsetCells
+ }
+ word := a.tabName(tab, cell-tabCloseCells-tabInsetCells)
+ return word, ansi.StringWidth(word) + tabInsetCells + tabCloseCells
+}
+
// tabsMoreGap is the least space between the last tab and the count, so the
// figure never reads as the next tab along.
const tabsMoreGap = 2
@@ -983,15 +1043,35 @@ func (a *app) tabHoverPaint(word string) string {
// conversation even with NO_COLOR, where both tint and underline are absent.
func tabLabel(tab chatTab, width int) string {
if width < 2 {
- return fitConversationTitle(tab.word, width)
+ return fitTabTitle(tab.word, width)
}
- word := fitConversationTitle(tab.word, width-2)
+ word := fitTabTitle(tab.word, width-2)
if tab.here {
return "[" + word + "]"
}
return " " + word + " "
}
+// fitTabTitle is a conversation's name cut to width for a tab, AT A WORD
+// BOUNDARY where one is near: `Refactor the rail...` rather than `Refactor the
+// rail scop...`, because a tab is recognised by its words and half a word is
+// a word nobody said. A name with no space late enough is cut where it
+// lands, as [fitConversationTitle] cuts it; the suffix is that function's.
+func fitTabTitle(s string, width int) string {
+ cut := fitConversationTitle(s, width)
+ if cut == s || width < 12 {
+ return cut
+ }
+ dots := strings.Repeat(".", 3)
+ head := strings.TrimSuffix(cut, dots)
+ // A SPACE IN THE LATER HALF of what survived is a word boundary worth
+ // cutting at; one earlier throws away more of the name than it saves.
+ if at := strings.LastIndex(head, " "); at > 0 && ansi.StringWidth(head[:at]) >= width/2 && s[len(head)] != ' ' {
+ return strings.TrimRight(head[:at], " ,;:-") + dots
+ }
+ return cut
+}
+
// A reserved status slot keeps labels stable across work transitions. The start
// page has no agent and makes no status claim.
func (a *app) tabName(tab chatTab, width int) string {
@@ -1019,7 +1099,7 @@ func (a *app) tabsPaint(pieces []tabPiece) string {
hot, lit := a.hotTab()
line := ""
for _, piece := range pieces {
- on := lit && hot.tab.key == piece.tab.key && hot.tab.start == piece.tab.start && hot.kind != tabFold && hot.kind != tabNew && hot.kind != tabHome && hot.kind != tabScrollLeft && hot.kind != tabScrollRight && hot.kind != tabTeam && hot.kind != tabWall && hot.kind != tabManager
+ on := lit && hot.tab.key == piece.tab.key && hot.tab.start == piece.tab.start && hot.kind != tabFold && hot.kind != tabNew && hot.kind != tabScrollLeft && hot.kind != tabScrollRight && hot.kind != tabTeam && hot.kind != tabWall && hot.kind != tabManager
switch {
case piece.quiet:
line += a.pal.dim(piece.word)
@@ -1028,18 +1108,16 @@ func (a *app) tabsPaint(pieces []tabPiece) string {
case piece.kind == tabClose:
line += a.tabClosePaint(piece, hot, lit, on)
case piece.kind == tabHere:
- word := a.tabActivePaint(a.tabWordPaint(piece, a.pal.ink))
+ word := a.tabActivePaint(a.tabWordPaintLead(piece, a.pal.ink, a.tabPointerLead(on)))
if on {
word = a.pal.underline(word)
}
line += word
- case piece.kind == tabFold || piece.kind == tabNew || piece.kind == tabHome || piece.kind == tabScrollLeft || piece.kind == tabScrollRight || piece.kind == tabManager:
+ case piece.kind == tabFold || piece.kind == tabNew || piece.kind == tabScrollLeft || piece.kind == tabScrollRight || piece.kind == tabManager:
if lit && hot.kind == piece.kind {
word := piece.word
if a.pal.profile < tokens.ANSI256 {
switch piece.kind {
- case tabHome:
- word = a.linearMark("·", ".") + pageHome.word() + tabPad
case tabNew:
word = a.linearMark("·", ".") + "+ "
case tabScrollLeft, tabScrollRight:
@@ -1051,7 +1129,7 @@ func (a *app) tabsPaint(pieces []tabPiece) string {
}
line += a.pal.dim(piece.word)
case on:
- line += a.pal.background(a.tabWordPaint(piece, a.pal.ink), 0, a.pal.ramp.mark)
+ line += a.pal.background(a.tabWordPaintLead(piece, a.pal.ink, a.tabPointerLead(on)), 0, a.pal.ramp.mark)
default:
line += a.pal.selected(a.tabWordPaint(piece, a.pal.muted), 0)
}
@@ -1059,9 +1137,27 @@ func (a *app) tabsPaint(pieces []tabPiece) string {
return line
}
+// tabPointerLead is a tab's leading inset: a blank, or WHERE COLOUR CANNOT
+// SHOW A GROUND the linear mark on the tab under the pointer, exactly where
+// and when a nav word wears it (topnav.go's [app.navHoverPaint]), beside the
+// `×` that appears with it.
+func (a *app) tabPointerLead(on bool) string {
+ if !on || a.pal.profile >= tokens.ANSI256 {
+ return strings.Repeat(" ", tabInsetCells)
+ }
+ if a.pal.ascii || a.linear {
+ return "."
+ }
+ return "·"
+}
+
// Navigation tint belongs to the name; the small status mark keeps its meaning.
func (a *app) tabWordPaint(piece tabPiece, ink func(string) string) string {
- pad := strings.Repeat(" ", tabInsetCells)
+ return a.tabWordPaintLead(piece, ink, strings.Repeat(" ", tabInsetCells))
+}
+
+// tabWordPaintLead is [app.tabWordPaint] with pad as the leading inset.
+func (a *app) tabWordPaintLead(piece tabPiece, ink func(string) string, pad string) string {
// Color carries selection as a filled tab; plain terminals keep brackets.
// Replace the two furniture cells only, preserving every hit coordinate.
if piece.tab.here && a.pal.profile >= tokens.ANSI256 {
@@ -1097,14 +1193,7 @@ func (a *app) tabClosePaint(piece tabPiece, hot tabHit, lit, on bool) string {
if !piece.tab.here && !on {
return a.pal.selected(a.pal.dim(piece.word), 0)
}
- mark := " " + a.tabCloseWord() + strings.Repeat(" ", tabInsetCells)
- if on && a.pal.profile == tokens.NoColor {
- pointer := "·"
- if a.pal.ascii {
- pointer = "."
- }
- mark = pointer + a.tabCloseWord() + strings.Repeat(" ", tabInsetCells)
- }
+ mark := a.tabCloseWord() + strings.Repeat(" ", tabInsetCells)
if piece.tab.here {
painted := a.tabActivePaint(mark)
if on && hot.kind == tabClose {
@@ -1162,12 +1251,12 @@ func tabsAt(hits []tabHit, from int) []tabHit {
// ── THE POINTER ─────────────────────────────────────────────────────────────
// tabAt is the piece under a pointer, and whether there is one. It answers only
-// for the strip's own row, which is the bar's row ([placeTabRow]) wherever the
+// for the strip's own row, which is the bar's row ([tabStripRow]) wherever the
// strip is drawn at all — [app.view] draws it under the pulse and
// [app.headHeight] charges for both.
func (a *app) tabAt(x, y int) (tabHit, bool) {
width, _ := a.size()
- if y != placeTabRow || a.tabsHeight(width) == 0 {
+ if y != tabStripRow || a.tabsHeight(width) == 0 {
return tabHit{}, false
}
for _, hit := range a.chatTabHits {
@@ -1209,7 +1298,7 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) {
if y < 0 || y >= a.tabsHeight(width) {
return nil, false
}
- if y != placeTabRow {
+ if y != tabStripRow {
return nil, true
}
hit, ok := a.tabAt(x, y)
@@ -1223,8 +1312,6 @@ func (a *app) tabPress(x, y int) (tea.Cmd, bool) {
case tabScrollRight:
a.tabScroll(1)
return nil, true
- case tabHome:
- return a.openHome(), true
case tabNew:
return a.openChatStart(), true
case tabManager:
diff --git a/internal/tui3/chattabs_test.go b/internal/tui3/chattabs_test.go
index cb1787928e..a2cf845691 100644
--- a/internal/tui3/chattabs_test.go
+++ b/internal/tui3/chattabs_test.go
@@ -35,7 +35,7 @@ func tabWords(a *app) []string {
for _, hit := range a.chatTabHits {
// The close cells are a target of their own on every tab (chattabs.go),
// so a walk of the hit map that counted them would count every tab twice.
- if hit.kind == tabFold || hit.kind == tabClose || hit.kind == tabNew || hit.kind == tabHome || hit.kind == tabScrollLeft || hit.kind == tabScrollRight {
+ if hit.kind == tabFold || hit.kind == tabClose || hit.kind == tabNew || hit.kind == tabScrollLeft || hit.kind == tabScrollRight {
continue
}
words = append(words, hit.tab.word)
@@ -46,7 +46,7 @@ func tabWords(a *app) []string {
// clickTab presses one column of the frame's first row, which is the strip's.
func clickTab(t *testing.T, a *app, x int) {
t.Helper()
- if cmd, took := a.tabPress(x, placeTabRow); took {
+ if cmd, took := a.tabPress(x, tabStripRow); took {
_ = cmd
return
}
@@ -74,8 +74,8 @@ func TestTheStripNamesEveryConversationAndKeepsItsOrderAcrossASwitch(t *testing.
a, _, _ := tabApp(t)
strip := plain(a.tabsRow(a.width))
// The names as a tab spells them: a tab is a label somebody recognises, so a
- // long one is cut to [tabWordCap] rather than given the whole row.
- for _, want := range []string{"openrouter price scrape", "Refactor the rail scop...", "Shipping the parser"} {
+ // long one is cut to [tabWordCap], at a word, rather than given the whole row.
+ for _, want := range []string{"openrouter price scrape", "Refactor the rail scope...", "Shipping the parser"} {
if !strings.Contains(strip, want) {
t.Fatalf("the strip is missing %q:\n%q", want, strip)
}
@@ -289,10 +289,10 @@ func TestEveryTabIsRecordedOnTheCellsItWasDrawnOn(t *testing.T) {
t.Fatalf("at %d columns a tab was recorded past the end of the row: %+v\n%q",
width, hit.span, line)
}
- if _, ok := a.tabAt(hit.span.from, placeTabRow); !ok {
+ if _, ok := a.tabAt(hit.span.from, tabStripRow); !ok {
t.Fatalf("at %d columns the strip does not answer for its own cell %d", width, hit.span.from)
}
- if _, ok := a.tabAt(hit.span.from, placeTabRow+1); ok {
+ if _, ok := a.tabAt(hit.span.from, tabStripRow+1); ok {
t.Fatalf("at %d columns the strip answers for the row under it", width)
}
}
@@ -309,13 +309,13 @@ func TestTheStripIsChargedToTheBodyRegionAndMovesTheHeaderUnderIt(t *testing.T)
if got := plain(rows[0]); !strings.HasPrefix(got, " "+plain(a.pal.wordmark(a.width))) {
t.Fatalf("the frame's first row is not the pulse: %q", got)
}
- if got := plain(rows[placeTabRow]); !strings.Contains(got, a.chatDisplayName()) {
+ if got := plain(rows[tabStripRow]); !strings.Contains(got, a.chatDisplayName()) {
t.Fatalf("the row under the pulse is not the strip: %q", got)
}
// THE ROOM'S TRAIL IS THE FIRST ROW UNDER THE WHOLE HEAD — the rule and the
// blank under the strip are drawn in a room too (head.go).
- if a.roomHeadRow() != placeHeadRows {
- t.Fatalf("the room's header is on row %d, not under the %d-row head", a.roomHeadRow(), placeHeadRows)
+ if a.roomHeadRow() != chatHeadRows {
+ t.Fatalf("the room's header is on row %d, not under the %d-row head", a.roomHeadRow(), chatHeadRows)
}
if got := plain(rows[a.roomHeadRow()]); !strings.Contains(got, "Write the tree") {
t.Fatalf("the trail is not on the row under the head: %q", got)
@@ -396,3 +396,162 @@ func TestASharedSwitchKeepsTheUnsentSentenceOfTheConversationItLeaves(t *testing
t.Fatalf("the composer left no record under its own identity: how=%v keep=%+v", how, keep)
}
}
+
+// THE STRIP'S PIECES STAND ONE GAP APART, AND A TAB IS AS WIDE ON ITS RIGHT AS
+// ON ITS LEFT. Every button on the row carries its own pad, so the air between
+// two of them is exactly one blank cell: the chip and the first tab, two tabs,
+// a tab and `+`, `+` and `▦ All`, and the left arrow and what stands before it.
+// The row's first ground is at [headLabelAt] with a chip or without one. And a
+// tab at rest leads its name with as many blank cells as it closes it with.
+func TestTheStripsPiecesStandOneGapApart(t *testing.T) {
+ for _, team := range []bool{false, true} {
+ for _, width := range []int{80, 110, 160} {
+ var a *app
+ if team {
+ a, _, _, _ = trafficApp(t)
+ } else {
+ a, _, _ = tabApp(t)
+ }
+ a.width = width
+ a.touch()
+ row := plain(a.tabsRow(width))
+ type piece struct {
+ from, to int
+ pinned bool
+ }
+ var pieces []piece
+ if a.wall.chip.pressable() {
+ pieces = append(pieces, piece{from: a.wall.chip.from, to: a.wall.chip.to})
+ }
+ for _, hit := range a.chatTabHits {
+ switch hit.kind {
+ case tabClose:
+ // The close cells are the tab's own closing cells.
+ pieces[len(pieces)-1].to = hit.span.to
+ case tabFold:
+ // The count is a fact at the row's end, not a packed piece.
+ case tabScrollRight:
+ // The right arrow is pinned to the window's end, so the air in
+ // front of it is the window's; what follows it is packed.
+ pieces = append(pieces, piece{hit.span.from, hit.span.to, true})
+ default:
+ pieces = append(pieces, piece{from: hit.span.from, to: hit.span.to})
+ }
+ }
+ if a.wall.door.pressable() {
+ pieces = append(pieces, piece{from: a.wall.door.from, to: a.wall.door.to})
+ }
+ if len(pieces) < 3 {
+ t.Fatalf("team=%v at %d the strip drew %d pieces: %q", team, width, len(pieces), row)
+ }
+ if pieces[0].from != headLabelAt {
+ t.Fatalf("team=%v at %d the first ground is at %d, want %d: %q", team, width, pieces[0].from, headLabelAt, row)
+ }
+ for i := 1; i < len(pieces); i++ {
+ // A right arrow with nowhere to go is drawn dim and answers nothing,
+ // so it has no hit; it is still the window's pinned end.
+ between := ansi.Cut(row, pieces[i-1].to, pieces[i].from)
+ if strings.Contains(between, "›") || strings.Contains(between, "‹") {
+ continue
+ }
+ if gap := pieces[i].from - pieces[i-1].to; gap != 1 && !pieces[i].pinned {
+ t.Fatalf("team=%v at %d pieces %d and %d are %d cells apart, want 1:\n%q\n%+v", team, width, i-1, i, gap, row, pieces)
+ }
+ }
+ for _, hit := range a.chatTabHits {
+ if hit.kind != tabOther || hit.tab.signal != tabIdle {
+ continue
+ }
+ var close hudSpan
+ for _, c := range a.chatTabHits {
+ if c.kind == tabClose && c.tab.key == hit.tab.key {
+ close = c.span
+ }
+ }
+ tab := ansi.Cut(row, hit.span.from, close.to)
+ lead := len(tab) - len(strings.TrimLeft(tab, " "))
+ tail := len(tab) - len(strings.TrimRight(tab, " "))
+ if lead != tail {
+ t.Fatalf("team=%v at %d the tab %q leads with %d blanks and closes with %d", team, width, tab, lead, tail)
+ }
+ }
+ }
+ }
+}
+
+// A LONG NAME IS CUT AT A WORD, not through one, where a word boundary is near.
+func TestATabNameIsCutAtAWord(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ width int
+ want string
+ }{
+ {"Refactor the rail scope model", 25, "Refactor the rail..."},
+ {"Refactor the rail scope model", 29, "Refactor the rail scope model"},
+ {"Refactor the rail scope model", 27, "Refactor the rail scope..."},
+ {"abcdefghijklmnopqrstuvwxyz", 12, "abcdefghi..."},
+ {"Refactor the rail scope model", 14, "Refactor..."},
+ {"openrouter price scrape", 13, "openrouter..."},
+ } {
+ if got := fitTabTitle(c.name, c.width); got != c.want || ansi.StringWidth(got) > c.width {
+ t.Fatalf("%q at %d is %q, want %q", c.name, c.width, got, c.want)
+ }
+ }
+}
+
+// EVERY DOOR ON THE STRIP SAYS WHAT IT DOES while the pointer rests on it: a
+// tab its dock square's sentence, `×` that the work keeps running, `+` its key,
+// the arrows which way. A SCROLLING STRIP DRAWS BOTH ARROWS, the one with
+// nowhere to go dim and answering nothing, so the cells it holds never read as
+// a gap nobody meant.
+func TestEveryDoorOnTheStripSaysWhatItDoes(t *testing.T) {
+ a := manyTabApp(t)
+ a.start = nil
+ said := map[tabKind]bool{}
+ for _, width := range []int{80, 120, 160} {
+ a.width = width
+ a.hot = hoverAt{}
+ a.chatTabBar = tabBar{}
+ row := plain(a.tabsRow(width))
+ hits := append([]tabHit(nil), a.chatTabHits...)
+ scrolls := false
+ for _, hit := range hits {
+ if hit.kind == tabScrollLeft || hit.kind == tabScrollRight {
+ scrolls = true
+ }
+ }
+ if scrolls && (!strings.Contains(row, "‹") || !strings.Contains(row, "›")) {
+ t.Fatalf("at %d a scrolling strip does not draw both arrows: %q", width, row)
+ }
+ for _, hit := range hits {
+ a.hot = hoverAt{kind: hoverTab, index: hit.span.from}
+ a.chatTabBar = tabBar{}
+ a.tabsRow(width)
+ words := a.dockHoverWords()
+ want := ""
+ switch hit.kind {
+ case tabOther:
+ want = dockCellHint(hit.tab)
+ case tabClose:
+ want = "Close this tab" + hintSegment + "the work keeps running"
+ case tabNew:
+ want = "New chat" + hintSegment
+ case tabScrollLeft:
+ want = "More tabs to the left"
+ case tabScrollRight:
+ want = "More tabs to the right"
+ default:
+ continue
+ }
+ if !strings.HasPrefix(words, want) {
+ t.Fatalf("at %d the pointer on %v says %q, want %q", width, hit.kind, words, want)
+ }
+ said[hit.kind] = true
+ }
+ }
+ for _, kind := range []tabKind{tabOther, tabClose, tabScrollRight} {
+ if !said[kind] {
+ t.Fatalf("no frame drew a %v to point at", kind)
+ }
+ }
+}
diff --git a/internal/tui3/chattip_test.go b/internal/tui3/chattip_test.go
index 110f8aec5b..503862e795 100644
--- a/internal/tui3/chattip_test.go
+++ b/internal/tui3/chattip_test.go
@@ -125,8 +125,8 @@ func TestTheConversationsTipStandsWithTheColumnOpenOrAway(t *testing.T) {
check("column open")
drive(t, a, ctrlG())
a.tipQuietFrom = time.Time{}
- if got := plain(a.footHint(a.width)); got != railBackHint {
- t.Fatalf("the keys row of a put-away column reads %q, want %q", got, railBackHint)
+ if got := plain(a.footHint(a.width)); got != a.sideBackHint() {
+ t.Fatalf("the keys row of a put-away column reads %q, want %q", got, a.sideBackHint())
}
check("column put away")
}
diff --git a/internal/tui3/chords.go b/internal/tui3/chords.go
index e92f25557c..0a4b96ce86 100644
--- a/internal/tui3/chords.go
+++ b/internal/tui3/chords.go
@@ -225,12 +225,12 @@ func (a *app) placeHint() string { return a.chords.say(a.placeHintSaid()) }
// chordCtrlJumpWords is the alias clause the map grows where the terminal can
// send it, and [chordSpelling.mapLine] is the only thing that adds it.
-const chordCtrlJumpWords = " or " + chordCtrlWord + "1…8"
+const chordCtrlJumpWords = " or " + chordCtrlWord + "1…9"
// chordJumpWords is the map's own name for the jump class, and it is spelled
// here so [chordSpelling.mapLine] and [placeMapWords] cannot drift apart about
// where the alias clause goes.
-const chordJumpWords = chordAltWord + "1…8"
+const chordJumpWords = chordAltWord + "1…9"
// chordMapAlias is `alt+.`'s second encoding, on the same terms as the digits:
// live only where the terminal answered the keyboard query.
@@ -291,7 +291,7 @@ func chordCtrlDigit(key string) (page, bool) { return placeDigitAt(chordCtrlWord
// a place's composer draws one dim line, once, that the next real chord retires.
var chordDeadKeys = map[rune]string{
'¡': "alt+1", '™': "alt+2", '£': "alt+3", '¢': "alt+4",
- '∞': "alt+5", '§': "alt+6", '¶': "alt+7", '•': "alt+8",
+ '∞': "alt+5", '§': "alt+6", '¶': "alt+7", '•': "alt+8", 'ª': "alt+9",
'≥': "alt+.",
'©': "alt+g", 'œ': "alt+q", 'ß': "alt+s",
'π': "alt+p", '¥': "alt+y",
@@ -448,5 +448,5 @@ func (c chordSpelling) chordSetupWords() string {
return ""
}
return "the places answer " + chordMetaWord + "1…" + chordMetaWord +
- "8 · if " + chordMetaBare + " types a character instead, " + c.chordFixWords()
+ "9 · if " + chordMetaBare + " types a character instead, " + c.chordFixWords()
}
diff --git a/internal/tui3/chords_test.go b/internal/tui3/chords_test.go
index 0ca3856d82..30504665a6 100644
--- a/internal/tui3/chords_test.go
+++ b/internal/tui3/chords_test.go
@@ -166,16 +166,16 @@ func TestTheCtrlDigitAliasIsClaimedOnlyWhereTheTerminalSaidItCan(t *testing.T) {
}
a.keysDisambiguated = true
- a.placeKeyPress(ctrlKey('3'))
- if want := placeOrder[2]; a.page != want {
- t.Fatalf("ctrl+3 went to %s and the third place is %s", a.page.word(), want.word())
+ a.placeKeyPress(ctrlKey('4'))
+ if want := placeOrder[3]; a.page != want {
+ t.Fatalf("ctrl+4 went to %s and the fourth place is %s", a.page.word(), want.word())
}
// AND IT IS THE SAME PLACE THE alt SPELLING REACHES. One digit reading behind
// both encodings is what makes that true by construction.
a.showPage(pageHome)
- a.placeKeyPress(tea.KeyPressMsg{Code: '3', Mod: tea.ModAlt})
- if want := placeOrder[2]; a.page != want {
- t.Fatalf("alt+3 went to %s and the third place is %s", a.page.word(), want.word())
+ a.placeKeyPress(tea.KeyPressMsg{Code: '4', Mod: tea.ModAlt})
+ if want := placeOrder[3]; a.page != want {
+ t.Fatalf("alt+4 went to %s and the fourth place is %s", a.page.word(), want.word())
}
a.showPage(pageHome)
a.placeKeyPress(ctrlKey('.'))
@@ -199,18 +199,18 @@ func TestTheMapNamesTheCtrlAliasExactlyWhenItIsBound(t *testing.T) {
a.mapShowing = true
a.keysDisambiguated = false
- if line := a.placeHint(); strings.Contains(line, "ctrl+1…8") {
+ if line := a.placeHint(); strings.Contains(line, "ctrl+1…9") {
t.Fatalf("the map offers a chord this terminal cannot send:\n%s", line)
}
a.keysDisambiguated = true
line := a.placeHint()
- if !strings.Contains(line, "alt+1…8 or ctrl+1…8 go to a place") {
+ if !strings.Contains(line, "alt+1…9 or ctrl+1…9 go to a place") {
t.Fatalf("the map hides an alias that is bound:\n%s", line)
}
// AND ON A MAC IT IS THE MAC'S SPELLING OF THE FIRST AND THE PLAIN ONE OF THE
// SECOND: `ctrl` is `ctrl` on every keyboard there is.
a.chords = detectChords("darwin", envOf(map[string]string{"TERM_PROGRAM": "kitty"}))
- if mac := a.placeHint(); !strings.Contains(mac, "opt+1…8 or ctrl+1…8 go to a place") {
+ if mac := a.placeHint(); !strings.Contains(mac, "opt+1…9 or ctrl+1…9 go to a place") {
t.Fatalf("the mac map reads wrong:\n%s", mac)
}
}
@@ -280,7 +280,7 @@ func TestTheOptionAsMetaNoteIsNeitherDrawnOffAMacNorInAConversation(t *testing.T
func TestTheFirstRunLineNamesTheChordsAndTheSettingOnAMacOnly(t *testing.T) {
mac := detectChords("darwin", envOf(map[string]string{"TERM_PROGRAM": "Apple_Terminal"}))
words := mac.chordSetupWords()
- for _, want := range []string{"opt+1…opt+8", "if opt types a character instead", "use option as meta", "Terminal: Profiles › Keyboard › Use Option as Meta key"} {
+ for _, want := range []string{"opt+1…opt+9", "if opt types a character instead", "use option as meta", "Terminal: Profiles › Keyboard › Use Option as Meta key"} {
if !strings.Contains(words, want) {
t.Fatalf("the first-run line lost %q:\n%s", want, words)
}
@@ -303,9 +303,9 @@ func TestTheOptionCharacterTableIsExactlyTheBoundChords(t *testing.T) {
t.Fatalf("%q is filed under %q, which is not a chord this surface binds", r, chord)
}
}
- // The seven places, the map, and the letters the composer and the two places
+ // The nine places, the map, and the letters the composer and the two places
// that have a view actually take.
- for _, chord := range []string{"alt+1", "alt+7", "alt+.", "alt+g", "alt+q", "alt+s", "alt+w", "alt+p", "alt+y", "alt+o", "alt+b", "alt+f"} {
+ for _, chord := range []string{"alt+1", "alt+7", "alt+9", "alt+.", "alt+g", "alt+q", "alt+s", "alt+w", "alt+p", "alt+y", "alt+o", "alt+b", "alt+f"} {
found := false
for _, have := range chordDeadKeys {
if have == chord {
diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go
index 0c9098bdda..6858f72fb5 100644
--- a/internal/tui3/commands.go
+++ b/internal/tui3/commands.go
@@ -147,6 +147,10 @@ var commands = []command{
// lands nowhere.
{name: "search", desc: "everything said on this machine · " + placeChord(pageSearch)},
{name: "wall", desc: "every open conversation, live, and your teams · alt+v or ▦ below the box"},
+ // THE TEAMS PAGE, beside the wall it opens onto: the wall is the open
+ // conversations big, and this is the team-level view, every member open or
+ // not, the manager's conversation and what waits on you (place_teams.go).
+ {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)},
{name: "spend", desc: "what this machine has cost, by the day · " + placeChord(pageSpend)},
// It sits AFTER /compact and before /help because those two are the pair a
// person reads together when a conversation has gone wrong: compacting is
@@ -1081,7 +1085,7 @@ func helpText(file string, chords chordSpelling) string {
helpKeyRow(chords.say(trafficKey), "with a team's manager in front: show or hide its Traffic"),
helpKeyRow(chords.say(teamManagerKey), "in a team with a manager: go to the manager"),
helpKeyRow(reopenTabChord, "reopen the last closed tab · when the terminal sends this distinct chord"),
- helpKeyRow(chords.say(railHoldChord), "the task roster · ↑↓ move · →← fold · enter opens · esc back"),
+ helpKeyRow(chords.say(railHoldChord), "the task roster · ↑↓ move · ←→ tasks/traffic · enter opens · esc back"),
"ctrl+. every task this project has run · /history · type to filter",
"ctrl+g close the roster's column, or bring it back · remembered",
"ctrl+l back to the latest · the chip above the box says so too",
@@ -1113,6 +1117,11 @@ func helpText(file string, chords chordSpelling) string {
"ctrl+, open settings",
"d in /permissions: drop the line under the cursor · press it twice",
"p s n in /standing: pause one · stop it · keep it out of here",
+ // THE TEAMS PAGE'S LETTERS, each the button of the same word on the
+ // selected team, and the chord that puts the keyboard on those buttons
+ // while the manager's conversation has the box (teamspagehost.go).
+ "s c w n o in /teams: settings · close · open on the wall · new team · organize",
+ helpKeyRow(chords.say("alt+↑↓"), "in /teams: onto the page's buttons while the manager has the box · esc back"),
"ctrl+r ctrl+y in /files: reveal the folder it is in · copy it somewhere",
)
if file != "" {
diff --git a/internal/tui3/depthfade_test.go b/internal/tui3/depthfade_test.go
index 2b8b5a8383..40fda2d7f9 100644
--- a/internal/tui3/depthfade_test.go
+++ b/internal/tui3/depthfade_test.go
@@ -268,6 +268,8 @@ func fadeColumnApp(t *testing.T, count int) *app {
for i := 0; i < count; i++ {
a.taskUpdate(update(uint64(i+1), "The "+fadeWord(i)+" errand", session.TaskDone, session.TaskNotice{}))
}
+ // The finished work opened, so it is longer than the frame.
+ railOpenAll(a)
a.paints = 0
return a
}
diff --git a/internal/tui3/eschint_test.go b/internal/tui3/eschint_test.go
new file mode 100644
index 0000000000..3c95c8c16c
--- /dev/null
+++ b/internal/tui3/eschint_test.go
@@ -0,0 +1,135 @@
+package tui3
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// HINT LINES SPELL esc ONE WAY. The majority of them say `esc cancel`, the
+// imperative, and a few said `esc cancels`. The same split on any other verb
+// is the same defect: one key reading as two gestures. This walks the hint
+// word constants, and the hint functions that inline the same words, and
+// fails on the form the majority does not use.
+func TestHintWordsSpellEscTheWayTheMajorityDoes(t *testing.T) {
+ fset := token.NewFileSet()
+ pkgs, err := parser.ParseDir(fset, ".", nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var lits []string
+ for _, pkg := range pkgs {
+ for name, file := range pkg.Files {
+ if strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ ast.Inspect(file, func(n ast.Node) bool {
+ switch n := n.(type) {
+ case *ast.ValueSpec:
+ if n.Names == nil {
+ return true
+ }
+ hint := false
+ for _, id := range n.Names {
+ if hintWordName(id.Name) {
+ hint = true
+ }
+ }
+ if !hint {
+ return true
+ }
+ for _, v := range n.Values {
+ if s, ok := stringLit(v); ok && strings.Contains(s, "esc ") {
+ lits = append(lits, s)
+ }
+ }
+ case *ast.FuncDecl:
+ if n.Name == nil || !hintWordName(n.Name.Name) || n.Body == nil {
+ return true
+ }
+ ast.Inspect(n.Body, func(m ast.Node) bool {
+ if s, ok := stringLit(m); ok && strings.Contains(s, "esc ") {
+ lits = append(lits, s)
+ }
+ return true
+ })
+ return false
+ }
+ return true
+ })
+ }
+ }
+ if len(lits) == 0 {
+ t.Fatal("no hint word mentioned esc, so the scan is looking in the wrong place")
+ }
+ count := map[string]int{}
+ for _, lit := range lits {
+ for _, verb := range escVerbs(lit) {
+ count[verb]++
+ }
+ }
+ odd := map[string]string{}
+ for verb, n := range count {
+ if !strings.HasSuffix(verb, "s") || len(verb) < 2 {
+ continue
+ }
+ bare := strings.TrimSuffix(verb, "s")
+ if count[bare] > n {
+ odd[verb] = bare
+ }
+ }
+ if len(odd) == 0 {
+ return
+ }
+ for _, lit := range lits {
+ for _, verb := range escVerbs(lit) {
+ bare, bad := odd[verb]
+ if !bad {
+ continue
+ }
+ t.Errorf("a hint says %q, and the majority of hint lines say %q:\n %s",
+ "esc "+verb, "esc "+bare, lit)
+ }
+ }
+}
+
+// hintWordName reports whether a Go name is one of the hint-word constants or
+// the functions that return a hint line.
+func hintWordName(name string) bool {
+ return strings.Contains(name, "Hint") || strings.Contains(name, "Keys")
+}
+
+func stringLit(n ast.Node) (string, bool) {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return "", false
+ }
+ s, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ return "", false
+ }
+ return s, true
+}
+
+// escVerbs is every word standing immediately after `esc ` in s.
+func escVerbs(s string) []string {
+ var out []string
+ rest := s
+ for {
+ i := strings.Index(rest, "esc ")
+ if i < 0 {
+ return out
+ }
+ rest = rest[i+len("esc "):]
+ word := rest
+ if cut := strings.IndexAny(word, " ·\t"); cut >= 0 {
+ word = word[:cut]
+ }
+ if word != "" {
+ out = append(out, word)
+ }
+ }
+}
diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go
index f28ce5156e..28c02d97ba 100644
--- a/internal/tui3/firstrun.go
+++ b/internal/tui3/firstrun.go
@@ -951,7 +951,7 @@ func (a *app) setupKeysWord() string {
return a.setupControlsKeys(max(width-2*setupMargin, 1))
}
if s.authStarting || s.authFlow != nil {
- return "esc cancels"
+ return "esc cancel"
}
if strings.TrimSpace(s.text) == "" {
if a.routerConnect != nil {
diff --git a/internal/tui3/focus_test.go b/internal/tui3/focus_test.go
index f16fcf95e4..0d9eaa0bd9 100644
--- a/internal/tui3/focus_test.go
+++ b/internal/tui3/focus_test.go
@@ -40,7 +40,7 @@ func headPanel(a *app) string { return headRow(a) + "\n" + headFactsRow(a) }
// tabsRowOf reads the tab labels inside the header's optional vertical padding.
func tabsRowOf(a *app) string {
rows := strings.Split(frame(a), "\n")
- at := placeTabRow
+ at := tabStripRow
if at >= len(rows) {
return ""
}
@@ -91,7 +91,7 @@ func TestARoomPinsAFocusHeader(t *testing.T) {
// What is left over a conversation is the places' head — the pulse, the
// strip, the rule and the blank — which is the seam between the head and the
// transcript (head.go).
- if a.headHeight() != placeHeadRows || !strings.Contains(head, a.chatDisplayName()) {
+ if a.headHeight() != chatHeadRows || !strings.Contains(head, a.chatDisplayName()) {
t.Fatalf("the conversation's strip is %d rows and reads:\n%q", a.headHeight(), head)
}
for _, gone := range []string{"Fix the nil-map", roomBackWord, roomCrumbSep} {
@@ -163,20 +163,20 @@ func TestTheRailIsStillTheDoorUnderTheHeader(t *testing.T) {
a, _, _ := roomApp(t)
drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "Mix the audio",
session.TaskRunning, session.TaskNotice{})})
- // TWO FAMILIES OF ONE, EQUALLY URGENT, so the column is in the order the
- // session admitted them (task.go): node 7 first, node 9 under it. Which is
- // which is not what this test owns — it owns the OFFSET — but naming them in
- // the roster's own order is what keeps it about that.
+ // TWO RUNNING TASKS, so the column is newest first under one heading:
+ // node 9, then node 7. Which is which is not what this test owns (it owns
+ // the OFFSET), but naming them in the roster's own order is what keeps it
+ // about that.
clickRail(t, a, 0)
- if a.room == nil || a.room.id != 7 {
- t.Fatalf("the first rail row did not open node 7: %+v", a.room)
+ if a.room == nil || a.room.id != 9 {
+ t.Fatalf("the first rail row did not open node 9: %+v", a.room)
}
- // Node 9 is drawn under node 7, and the header is above both: a click on the
+ // Node 7 is drawn under node 9, and the header is above both: a click on the
// rail's second row has to land a row further down the screen than it did
// before the room opened.
clickRail(t, a, 1)
- if a.room == nil || a.room.id != 9 {
- t.Fatalf("the second rail row did not open node 9 through the header: %+v", a.room)
+ if a.room == nil || a.room.id != 7 {
+ t.Fatalf("the second rail row did not open node 7 through the header: %+v", a.room)
}
}
diff --git a/internal/tui3/framedisk_law_test.go b/internal/tui3/framedisk_law_test.go
index 6a41ce31c3..b4152b28ca 100644
--- a/internal/tui3/framedisk_law_test.go
+++ b/internal/tui3/framedisk_law_test.go
@@ -169,6 +169,11 @@ var frameForbidden = map[string]map[string]bool{
"teamstore": {
"Load": true, "LoadHued": true, "Save": true, "Update": true, "SetAside": true,
"AppendTraffic": true, "ReadTraffic": true,
+ // The delegation store's doors (DESIGN.md section 8), every one a read
+ // or a write of a file.
+ "DefaultsAt": true, "Raise": true, "Decide": true, "Escalate": true,
+ "OpenPackets": true, "Packets": true, "PacketByID": true, "PacketsStamp": true,
+ "TeamSpend": true, "TeamSpendIn": true, "TeamSpendStamp": true, "Delete": true,
},
}
diff --git a/internal/tui3/head.go b/internal/tui3/head.go
index 5a8d7894f4..436e1a7dc4 100644
--- a/internal/tui3/head.go
+++ b/internal/tui3/head.go
@@ -2,49 +2,84 @@ package tui3
// THE HEAD: ONE SET OF ROWS OVER EVERY FRAME.
//
-// codeaf 2 want you · 1 moving · $0.14 / $20.00 · thu 9:49am
-// home Parsing the logs Porting the picker
-// ──────────────────────────────────────────────────────────────────────────────
+// >● codeaf home teams chats sessions spend settings 2 want you · $1.20 thu 10:31pm
+// ● harbor ▾ ◆ Manager × Refactor the rail… × + ▦ All
+// ───────────────────────────────────────────────────────────────────────────────────────────
//
-// The pulse, then the row that says where you are — the bar of places on a
-// place, the strip of chats in a conversation — then the rule, then a blank.
-// Four rows ([placeHeadRows]), the bar and the strip on the same one
-// ([placeTabRow]).
+// The wordmark with the places after it and the machine's pulse on the far
+// end (topnav.go) is row zero on every page, and it never moves. The strip of
+// chats (chattabs.go) is the next row ONLY while a conversation is in front:
+// a chat, a room inside one, the grid of open tabs, the run's work tab. On a
+// place the strip is not drawn, so the head is the nav, the rule and a blank
+// ([placeHeadRows]) and the body starts one row higher. In a chat the strip
+// sits between the nav and the rule ([chatHeadRows]).
+//
+// THE STRIP USED TO BE DRAWN ON EVERY PAGE, including places, where it repeated
+// the teams rail and home's sessions and offered a jump from a place to one
+// chat that is not a journey anyone takes. `chats` on row zero, `alt+k` and
+// home's sessions list are the ways between them (owner, 2026-09-25).
+//
+// THE PLACES AND THE STRIP USED TO SHARE ROW ONE AND TAKE TURNS ON IT. A place
+// drew its bar there and a conversation drew its strip there, so the words a
+// hand was reaching for changed under it on every walk (owner, 2026-09-24).
+// Row zero is the places on every page. The strip, when it is drawn, is only
+// the chats.
//
// THE TWO FRAMES SHARE ONE HEAD BECAUSE A PERSON WALKS BETWEEN THEM ALL DAY.
// The conversation's head used to be two to four rows of its own with no pulse,
// padded by two ladders of floors, so `esc` from home to a chat moved the strip
-// up a row and took the machine's vital signs off the screen — the one line
-// that says two things are waiting on you went away exactly where you were
-// least likely to go and look for them (DESIGN.md's law 11). One function now
-// draws the head for both, and the middle row is the only thing a frame hands
-// it.
+// up a row and took the machine's vital signs off the screen (DESIGN.md's law
+// 11). One function draws the head for every frame.
//
// A ROOM WEARS THE WHOLE HEAD TOO, and its trail and facts are the first rows
-// under it — the room's own heading, where a place's heading is. Inside a
-// node's page they used to take the place of the rule and the blank, which put
-// the rule a row lower in a room than in the conversation it opened from, and
-// two rows lower in the roomy layout: walking into a task moved the one line a
-// person's eye uses to find where the head ends (PLACES-AUDIT.md, lane K).
+// under it, where a place's heading is (PLACES-AUDIT.md, lane K).
-// headRows is the head, drawn at `width` in `pal`, with `middle` on its second
-// row. It is always [placeHeadRows] rows; a frame that draws fewer — a terminal
-// under the strip's floors, which draws none of it — takes a prefix of it, so
-// the rows a frame draws and the rows it charges are one count.
-func (a *app) headRows(width int, middle string, pal palette) []string {
- // HOME'S LINE LEAVES ITS COUNTS TO THE PANELS UNDER IT, and every other
- // frame's carries them (pulse.go's [pulseBudget]).
- mode := pulseWhole
- if a.at(pageHome) {
- mode = pulseBudget
- }
+// headRows is the head, drawn at `width` in `pal`. While a conversation is in
+// front, `strip` (the frame's [app.tabsRow]) is the second row and the head is
+// [chatHeadRows] rows. On a place `strip` is ignored and the head is
+// [placeHeadRows]: the nav, the rule, a blank. A frame that draws fewer, a
+// terminal under the strip's floors, takes a prefix of it, so the rows a frame
+// draws and the rows it charges are one count.
+//
+// THE STRIP IS HANDED IN RATHER THAN LAID OUT HERE because the conversation
+// lays it out first to learn whether it has a head at all, and laying the
+// strip out twice a frame would spend the scroll's allocation budget on a row
+// that did not change (PERF.md's scroll law). A place does not lay it out at
+// all: an undrawn strip that still owned hit spans would take a click meant
+// for the page.
+func (a *app) headRows(width int, strip string, pal palette) []string {
// WHILE A TEAM IS SHOWN THE RULE IS DRAWN IN ITS COLOUR, so every frame
- // says the strip above it is narrowed (teams.go).
+ // says the strip above it is narrowed (teams.go). On a place there is no
+ // strip, and the rule stays dim.
ruleInk := pal.dim
- if sp, ok := a.teamActive(); ok {
- if ink := pal.teamInk(sp.HueSpec()); ink != nil {
- ruleInk = ink
+ if a.stripInHead() {
+ if sp, ok := a.teamActive(); ok {
+ if ink := pal.teamInk(sp.HueSpec()); ink != nil {
+ ruleInk = ink
+ }
}
}
- return []string{a.pulseLine(width, pal, mode), middle, ruleInk(rule(width)), ""}
+ // THE NAV IS ON ROW ZERO AND THE POINTER IS TOLD SO HERE, by the one
+ // function every frame's head goes through: a press arrives as a row of
+ // the terminal, and the only honest way to know the nav is on it is to
+ // record it where it was drawn ([app.navPress]).
+ a.tabRow = navRow
+ nav := a.navLine(width, pal)
+ line := ruleInk(rule(width))
+ if !a.stripInHead() {
+ a.chatTabHits = nil
+ a.wall.chip, a.wall.door = hudSpan{}, hudSpan{}
+ return []string{nav, line, ""}
+ }
+ return []string{nav, strip, line, ""}
+}
+
+// stripInHead reports whether this frame draws the chat strip. A conversation
+// in front does, a room inside one does, and so do the grid and the work tab,
+// which are the chats with something drawn over them. A place does not, and
+// neither does the teams page's hosted pane: that pane is drawn as a chat so
+// its rows answer, then the place's own head is put over it (teamspagehost.go),
+// and a strip in the pane would be a second strip the place does not show.
+func (a *app) stripInHead() bool {
+ return !a.pageShowing() && !a.tp.forwarding
}
diff --git a/internal/tui3/head_test.go b/internal/tui3/head_test.go
index edc1486a0e..c88d49564d 100644
--- a/internal/tui3/head_test.go
+++ b/internal/tui3/head_test.go
@@ -10,10 +10,10 @@ import (
// ── THE ONE HEAD ────────────────────────────────────────────────────────────
//
-// A conversation and every place draw the same four rows at the top of the
-// frame — the pulse, the strip or the bar, the rule, a blank (head.go) — so a
-// person walking from home into a chat and back sees the head stay still and
-// only its middle row change.
+// Row zero is the same on a conversation and on every place. A conversation
+// then draws the strip, the rule and a blank ([chatHeadRows]). A place draws
+// the rule and a blank and no strip ([placeHeadRows]), so its body starts one
+// row higher.
// headSizes are the three frames the one head is pinned at: the classic
// terminal, a tall one, and a wide one.
@@ -40,14 +40,19 @@ func seedHeadFacts(a *app) {
a.machine = machineFacts{wants: 2, hands: 1, spent: 0.14, ceiling: 20}
}
-// headOf is the first [placeHeadRows] rows of the frame, as a reader sees them.
+// headOf is the first rows of the frame, as a reader sees them: [chatHeadRows]
+// in a conversation, [placeHeadRows] on a place.
func headOf(t *testing.T, a *app) []string {
t.Helper()
+ n := placeHeadRows
+ if !a.pageShowing() {
+ n = chatHeadRows
+ }
rows := strings.Split(frame(a), "\n")
- if len(rows) < placeHeadRows {
+ if len(rows) < n {
t.Fatalf("a %d-row frame has no room for the head", len(rows))
}
- head := make([]string, placeHeadRows)
+ head := make([]string, n)
for i := range head {
head[i] = strings.TrimRight(plain(rows[i]), " ")
}
@@ -60,27 +65,36 @@ func headPulseWhole() string {
return "2 want you · 1 moving · $0.14 / " + railFigure(20) + " · thu 9:49am"
}
-// THE CONVERSATION WEARS THE PLACES' HEAD, AT EVERY SIZE THE STRIP IS DRAWN.
-// The pulse on row 0, the strip on the bar's row, the rule, a blank — and the
-// body starts under exactly those four rows.
+// THE CONVERSATION'S HEAD IS THE NAV, THE STRIP, THE RULE AND A BLANK.
+// The body starts under exactly those four rows. At eighty columns the pulse
+// has given up the clock, the moving count and the words of `2 want you`, whose
+// count stays as `2 ?`, to keep every place on the row (topnav.go's ladder);
+// wider, it says everything.
func TestTheConversationWearsThePlacesHead(t *testing.T) {
a := headLab(t)
for _, size := range headSizes {
a.width, a.height = size.w, size.h
a.touch()
head := headOf(t, a)
- if !strings.HasPrefix(head[0], " "+plain(a.pal.wordmark(a.width))) || !strings.HasSuffix(head[0], headPulseWhole()) {
- t.Fatalf("at %dx%d the chat's first row is not the pulse with its counts and budget:\n%q", size.w, size.h, head[0])
+ pulse := headPulseWhole()
+ if size.w == 80 {
+ pulse = "2 ? · $0.14 / " + railFigure(20)
+ }
+ if !strings.HasPrefix(head[0], " "+plain(a.pal.wordmark(a.width))) || !strings.HasSuffix(head[0], pulse) {
+ t.Fatalf("at %dx%d the chat's first row is not the nav with the pulse at its end:\n%q", size.w, size.h, head[0])
+ }
+ if !placeWordsInOrder(head[0], "home", "teams", "chats", "sessions", "spend", "settings") {
+ t.Fatalf("at %dx%d the chat's first row does not carry the places:\n%q", size.w, size.h, head[0])
}
- if !strings.Contains(head[placeTabRow], "home") || !strings.Contains(head[placeTabRow], a.chatDisplayName()) {
- t.Fatalf("at %dx%d the strip is not under the pulse:\n%q", size.w, size.h, head[placeTabRow])
+ if strings.Contains(head[tabStripRow], " home ") || !strings.Contains(head[tabStripRow], a.chatDisplayName()) {
+ t.Fatalf("at %dx%d the strip is not under the nav, on its own:\n%q", size.w, size.h, head[tabStripRow])
}
if head[2] != strings.Repeat("─", size.w) || head[3] != "" {
t.Fatalf("at %dx%d the head does not close with a rule and a blank:\n%q\n%q", size.w, size.h, head[2], head[3])
}
- if a.headHeight() != placeHeadRows || a.bodyTop() != placeHeadRows+a.stripHeight() {
+ if a.headHeight() != chatHeadRows || a.bodyTop() != chatHeadRows+a.stripHeight() {
t.Fatalf("at %dx%d the head draws %d rows and is charged %d, body at %d",
- size.w, size.h, placeHeadRows, a.headHeight(), a.bodyTop())
+ size.w, size.h, chatHeadRows, a.headHeight(), a.bodyTop())
}
if size.w == 80 && size.h == 24 || size.w == 120 {
t.Logf("the chat head at %dx%d:\n%s", size.w, size.h, strings.Join(head, "\n"))
@@ -94,12 +108,11 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) {
// (roompanel.go's [app.roomOrganized]).
var headFrameSizes = []struct{ w, h int }{{80, 24}, {120, 45}, {180, 45}}
-// A TASK ROOM SPENDS THE PLACES' HEAD ABOVE ITS BODY, as the conversation it
-// opened from does and as every place does: the pulse, the strip, the rule and
-// a blank, and the room's own trail on the first row under them — where a
-// place's heading is. It used to lay the trail where the rule stands and its
-// facts where the blank does, so walking into a task moved the rule down a row,
-// and two in the roomy layout (PLACES-AUDIT.md, lane K).
+// A TASK ROOM SPENDS THE CONVERSATION'S HEAD ABOVE ITS BODY: the nav, the
+// strip, the rule and a blank, and the room's own trail on the first row under
+// them. A place spends three rows and no strip, so its body starts where the
+// chat's strip is. The room used to lay the trail where the rule stands
+// (PLACES-AUDIT.md, lane K).
func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) {
room, chat := crumbApp(t), headLab(t)
for _, size := range headFrameSizes {
@@ -114,9 +127,13 @@ func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) {
}
f.a.touch()
head := headOf(t, f.a)
- if !strings.HasPrefix(head[0], " "+plain(f.a.pal.wordmark(f.a.width))) || head[2] != strings.Repeat("─", size.w) || head[3] != "" {
- t.Fatalf("at %dx%d %s's head is not the pulse, a row, the rule and a blank:\n%s",
- size.w, size.h, f.where, strings.Join(head, "\n"))
+ ruleAt := 2
+ if f.to != pageNone {
+ ruleAt = 1
+ }
+ if !strings.HasPrefix(head[0], " "+plain(f.a.pal.wordmark(f.a.width))) || head[ruleAt] != strings.Repeat("─", size.w) || strings.TrimSpace(head[ruleAt+1]) != "" {
+ t.Fatalf("at %dx%d %s's head is not the nav, then %s:\n%s",
+ size.w, size.h, f.where, map[bool]string{true: "the rule and a blank", false: "the strip, the rule and a blank"}[f.to != pageNone], strings.Join(head, "\n"))
}
if f.to != pageNone {
f.a.showPage(pageNone)
@@ -125,11 +142,11 @@ func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) {
// The room's heading is the first row under the head, and the rows the
// frame drew there are the rows the geometry charged for.
rows := strings.Split(plain(frame(room)), "\n")
- if room.roomHeadRow() != placeHeadRows || !strings.Contains(rows[placeHeadRows], "Write the tree") {
+ if room.roomHeadRow() != chatHeadRows || !strings.Contains(rows[chatHeadRows], "Write the tree") {
t.Fatalf("at %dx%d the room's trail is on row %d, not under the %d-row head:\n%q",
- size.w, size.h, room.roomHeadRow(), placeHeadRows, rows[placeHeadRows])
+ size.w, size.h, room.roomHeadRow(), chatHeadRows, rows[chatHeadRows])
}
- if room.bodyTop() != room.headHeight()+room.stripHeight() || room.headHeight() < placeHeadRows+room.roomHeadCount() {
+ if room.bodyTop() != room.headHeight()+room.stripHeight() || room.headHeight() < chatHeadRows+room.roomHeadCount() {
t.Fatalf("at %dx%d the room's head is charged %d rows, body at %d",
size.w, size.h, room.headHeight(), room.bodyTop())
}
@@ -222,8 +239,16 @@ func TestThePulseOverAChatIsThePulseOverAPlace(t *testing.T) {
if place[0] != chat {
t.Fatalf("the pulse over the tasks place is not the pulse over the chat:\n%q\n%q", place[0], chat)
}
- if !strings.Contains(place[placeTabRow], "home") || !strings.Contains(place[placeTabRow], "sessions") {
- t.Fatalf("the bar is not on the strip's row: %q", place[placeTabRow])
+ if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "sessions") {
+ t.Fatalf("the nav is not on the first row: %q", place[navRow])
+ }
+ if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[1], "─") {
+ t.Fatalf("the tasks place drew a strip under the nav: %q", place[tabStripRow])
+ }
+ a.showPage(pageNone)
+ chatHead := headOf(t, a)
+ if !strings.Contains(chatHead[tabStripRow], a.chatDisplayName()) {
+ t.Fatalf("the strip is not under the nav in the chat: %q", chatHead[tabStripRow])
}
}
@@ -241,41 +266,35 @@ func TestHomesPulseIsTheBudgetAndTheClock(t *testing.T) {
}
}
-// THE STRIP AND THE BAR ANSWER THE POINTER ON THE SAME ROW, and the pulse above
-// them answers nothing. A press resolved against the old head — the strip on
-// row zero, or on row one only on tall terminals — would open a chat for a
-// click on the pulse.
-func TestTheStripAndTheBarAnswerOnTheSameRow(t *testing.T) {
+// THE NAV ANSWERS ON ROW ZERO AND THE STRIP ON ROW ONE, on a chat and on a
+// place alike. A press resolved against the old head, where the places and
+// the strip took turns on row one, would open a chat for a click on a place.
+func TestTheNavAndTheStripAnswerOnTheirOwnRows(t *testing.T) {
a := headLab(t)
for _, size := range headSizes {
a.width, a.height = size.w, size.h
a.touch()
- frame(a)
- home := headerHomeTarget(t, a)
- if _, ok := a.tabAt(home.span.from, placeTabRow); !ok {
- t.Fatalf("at %dx%d the strip does not answer on row %d", size.w, size.h, placeTabRow)
+ home := headerHome(t, a)
+ if a.tabRow != navRow {
+ t.Fatalf("at %dx%d the nav was drawn on row %d", size.w, size.h, a.tabRow)
+ }
+ if _, ok := a.tabAt(home.from, navRow); ok {
+ t.Fatalf("at %dx%d the strip answers on the nav's row", size.w, size.h)
}
- if cmd, took := a.tabPress(home.span.from, 0); !took || cmd != nil || a.at(pageHome) {
- t.Fatalf("at %dx%d a press on the pulse leaked into the strip", size.w, size.h)
+ if cmd, took := a.tabPress(home.from, navRow); !took || cmd != nil || a.at(pageHome) {
+ t.Fatalf("at %dx%d a press on the nav leaked into the strip", size.w, size.h)
}
}
walkTo(t, a, pageTasks)
- frame(a)
- if a.tabRow != placeTabRow {
- t.Fatalf("the bar was drawn on row %d, the strip on row %d", a.tabRow, placeTabRow)
+ home := headerHome(t, a)
+ if a.tabRow != navRow {
+ t.Fatalf("the place drew its nav on row %d", a.tabRow)
}
- for _, span := range a.tabs {
- if span.id != pageHome {
- continue
- }
- cmd, took := a.placeTabPress(span.from, placeTabRow)
- runCmd(cmd)
- if !took || !a.at(pageHome) {
- t.Fatal("a press on the bar's `home` did not open home")
- }
- return
+ cmd, took := a.navPress(home.from, navRow)
+ runCmd(cmd)
+ if !took || !a.at(pageHome) {
+ t.Fatal("a press on the nav's `home` did not open home")
}
- t.Fatal("the bar drew no `home`")
}
// INSIDE A CHAT THE COUNTS COME OFF THE PULSE'S OWN BEAT. No home is open to
diff --git a/internal/tui3/header_home_test.go b/internal/tui3/header_home_test.go
index d902b913df..927102254a 100644
--- a/internal/tui3/header_home_test.go
+++ b/internal/tui3/header_home_test.go
@@ -10,16 +10,18 @@ import (
"github.com/charmbracelet/x/ansi"
)
-func headerHomeTarget(t *testing.T, a *app) tabHit {
+// headerHome is where the nav drew `home` on the last frame: the head's first
+// row, on every page (topnav.go).
+func headerHome(t *testing.T, a *app) placeTabSpan {
t.Helper()
- _ = a.tabsRow(a.width)
- for _, hit := range a.chatTabHits {
- if hit.kind == tabHome {
- return hit
+ frame(a)
+ for _, span := range a.tabs {
+ if span.id == pageHome {
+ return span
}
}
- t.Fatal("Home is missing from navigation")
- return tabHit{}
+ t.Fatal("home is missing from the nav")
+ return placeTabSpan{}
}
func TestHeaderHomePreservesBothConversationAndNewChatDrafts(t *testing.T) {
@@ -30,11 +32,11 @@ func TestHeaderHomePreservesBothConversationAndNewChatDrafts(t *testing.T) {
a.input.setText("existing chat draft")
openStart(t, a)
a.input.setText("new chat draft")
- home := headerHomeTarget(t, a)
- if home.span.from != placeBarLead {
- t.Fatal("Home is not first in navigation")
+ home := headerHome(t, a)
+ if home.from != ansi.StringWidth(" "+plain(a.pal.wordmark(a.width)))+navLead {
+ t.Fatalf("home is not the nav's first word: %+v", home)
}
- cmd, took := a.tabPress(home.span.from, placeTabRow)
+ cmd, took := a.navPress(home.from, navRow)
if !took || !a.at(pageHome) {
t.Fatal("Home click did not open the home page")
}
@@ -52,31 +54,31 @@ func TestHeaderHomePreservesBothConversationAndNewChatDrafts(t *testing.T) {
}
}
-// THE ROWS AROUND THE STRIP ARE THE HEAD'S AND ANSWER NOTHING. The pulse above
-// it is a reading, and the rule and the blank under it are the seam; a press on
-// any of the three stays where it landed rather than opening a tab.
+// THE ROWS UNDER THE STRIP ARE THE HEAD'S AND ANSWER NOTHING, and neither does
+// the air on the nav's row. The rule and the blank are the seam, and the cells
+// between the wordmark and `home` are furniture; a press on any of them stays
+// where it landed. `home` itself answers the pointer over its pads too, and a
+// plain terminal shows that with the linear mark.
func TestTheHeadAroundTheStripIsInertAndHomeHasPlainHover(t *testing.T) {
a, _, _ := tabApp(t)
a.resume = func(string) (Agent, error) { return nil, nil }
a.pal = newPalette(tokens.NoColor, false)
- home := headerHomeTarget(t, a)
+ home := headerHome(t, a)
before := a.file
- for _, y := range []int{0, placeTabRow + 1, placeHeadRows - 1} {
- if _, ok := a.tabAt(home.span.from, y); ok {
- t.Fatalf("row %d of the head advertises a button", y)
+ for _, at := range []struct{ x, y int }{{home.from - 1, navRow}, {home.from, tabStripRow + 1}, {home.from, placeHeadRows - 1}} {
+ if _, ok := a.tabAt(at.x, at.y); ok {
+ t.Fatalf("cell %d,%d of the head advertises a button", at.x, at.y)
}
- drive(t, a, tea.MouseClickMsg{X: home.span.from, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: at.x, Y: at.y, Button: tea.MouseLeft})
if a.file != before || a.at(pageHome) {
- t.Fatalf("a press on row %d of the head navigated", y)
+ t.Fatalf("a press on cell %d,%d of the head navigated", at.x, at.y)
}
}
- for x := home.span.from; x < home.span.to; x++ {
- hot, ok := a.tabHoverAt(x, placeTabRow)
- if !ok {
+ for x := home.from; x < home.to; x++ {
+ if !a.navHover(x, navRow) || a.tabHover != pageHome {
t.Fatal("Home padding is not part of the target")
}
- a.hot = hot
- if !strings.Contains(plain(a.tabsRow(a.width)), "·home ") {
+ if !strings.Contains(plain(a.navLine(a.width, a.pal)), "·home ") {
t.Fatal("Home has no plain-terminal hover feedback")
}
}
@@ -108,9 +110,9 @@ func TestTheStripKeepsItsActiveTabAndTheHeadItsShapeAtEverySize(t *testing.T) {
if active != 1 {
t.Fatalf("lost active tab at %dx%d: %q", width, height, plain(line))
}
- if a.tabsHeight(width) != placeTabRow+1 || a.headHeight() != placeHeadRows {
+ if a.tabsHeight(width) != tabStripRow+1 || a.headHeight() != chatHeadRows {
t.Fatalf("at %dx%d the head is %d rows with the strip on row %d; it is %d, strip on %d",
- width, height, a.headHeight(), a.tabsHeight(width)-1, placeHeadRows, placeTabRow)
+ width, height, a.headHeight(), a.tabsHeight(width)-1, chatHeadRows, tabStripRow)
}
}
}
@@ -134,7 +136,7 @@ func TestPlainHeaderHoverChangesEveryActionWithoutMovingItsTarget(t *testing.T)
rest := a.tabsRow(a.width)
hits := append([]tabHit(nil), a.chatTabHits...)
for _, hit := range hits {
- hot, ok := a.tabHoverAt(hit.span.from, placeTabRow)
+ hot, ok := a.tabHoverAt(hit.span.from, tabStripRow)
if !ok {
continue
}
@@ -143,7 +145,7 @@ func TestPlainHeaderHoverChangesEveryActionWithoutMovingItsTarget(t *testing.T)
if hovered == rest {
t.Fatalf("target %v has no plain hover", hit.kind)
}
- after, ok := a.tabAt(hit.span.from, placeTabRow)
+ after, ok := a.tabAt(hit.span.from, tabStripRow)
if !ok || after.span != hit.span || after.kind != hit.kind {
t.Fatal("hover moved its target")
}
@@ -163,8 +165,8 @@ func TestHeaderAirDoesNotShrinkReadingWhenTerminalGrows(t *testing.T) {
for height := airyFloor; height <= 45; height++ {
a.height = height
a.touch()
- if pinned && a.headHeight() != placeHeadRows {
- t.Fatalf("%s at %d rows the head is %d rows, not the places' %d", where, height, a.headHeight(), placeHeadRows)
+ if pinned && a.headHeight() != chatHeadRows {
+ t.Fatalf("%s at %d rows the head is %d rows, not the chat's %d", where, height, a.headHeight(), chatHeadRows)
}
available := height - a.headHeight()
if previous > available {
@@ -178,42 +180,32 @@ func TestHeaderAirDoesNotShrinkReadingWhenTerminalGrows(t *testing.T) {
grows("in the conversation", true)
}
-// The label and its padded mouse target stay in the same cells when Home
-// replaces the conversation strip, including terminals with plain hover marks.
+// HOME KEEPS ITS WORD AND ITS CELLS BETWEEN A CONVERSATION AND THE DASHBOARD.
+// Dev's #1424 asked it of the strip's home chip; here home is the nav's first
+// word on every page (topnav.go), so the same promise is asked of the nav:
+// lowercase, and its target in the same columns on both, on every profile.
func TestHomeTabKeepsItsSpellingAndPositionAcrossViews(t *testing.T) {
for _, profile := range []tokens.Profile{tokens.NoColor, tokens.ANSI256, tokens.TrueColor} {
- for _, width := range []int{24, 40, 80, 160} {
+ for _, width := range []int{80, 120, 160} {
t.Run(itoa(int(profile))+"/"+itoa(width), func(t *testing.T) {
a := newStartLab(t).app()
a.resume = func(string) (Agent, error) { t.Fatal("home must not resume a conversation"); return nil, nil }
a.showPage(pageNone)
a.width, a.height = width, 40
a.pal = newPalette(profile, false)
- home := headerHomeTarget(t, a)
- chat := plain(a.tabsRow(width))
- column := strings.Index(chat, "home")
- if column != placeBarLead+len(tabPad) || strings.Contains(chat, "Home") {
- t.Fatalf("conversation home label is misplaced or capitalized: %q", chat)
- }
- hot, ok := a.tabHoverAt(home.span.from, placeTabRow)
- if !ok {
- t.Fatal("home has no hover target")
- }
- a.hot = hot
- hovered := plain(a.tabsRow(width))
- at := strings.Index(hovered, "home")
- if at < 0 || ansi.StringWidth(hovered[:at]) != column || strings.Contains(hovered, "Home") {
- t.Fatalf("hover changed home's word or position: %q", hovered)
+ chat := headerHome(t, a)
+ nav := plain(a.navLine(width, a.pal))
+ if !strings.Contains(nav, " home ") || strings.Contains(nav, "Home") {
+ t.Fatalf("the nav's home is misspelled: %q", nav)
}
- cmd, took := a.tabPress(home.span.from, placeTabRow)
+ cmd, took := a.navPress(chat.from, navRow)
if !took || !a.at(pageHome) {
t.Fatal("clicking home did not open Home")
}
drain(t, a, cmd)
- bar := plain(a.placeTabBar(width, false, a.pal))
- span := barWordSpan(t, a, pageHome)
- if strings.Index(bar, "home") != column || span.from != home.span.from || span.to != home.span.to {
- t.Fatalf("home moved between views: chat=%q dashboard=%q chat target=%+v dashboard target=%+v", chat, bar, home.span, span)
+ dash := headerHome(t, a)
+ if dash.from != chat.from || dash.to != chat.to {
+ t.Fatalf("home moved between views: chat target=%+v dashboard target=%+v", chat, dash)
}
})
}
diff --git a/internal/tui3/helpdoors_test.go b/internal/tui3/helpdoors_test.go
index 4a44eff5c4..acc627e31c 100644
--- a/internal/tui3/helpdoors_test.go
+++ b/internal/tui3/helpdoors_test.go
@@ -39,7 +39,7 @@ func TestTheKeySheetNamesTheWayToEveryPlace(t *testing.T) {
// THE CHORDS ARE SPELLED THROUGH THE ONE DOOR, so a Mac sheet says opt+ and this
// one does not (chords.go).
mac := helpText("", chordSpelling{meta: chordMetaWord})
- if strings.Contains(mac, chordJumpWords) || !strings.Contains(mac, "opt+1…8") {
+ if strings.Contains(mac, chordJumpWords) || !strings.Contains(mac, "opt+1…9") {
t.Errorf("the place rows are not spelled through chords.say — a Mac sheet still reads alt+:\n%s", mac)
}
}
diff --git a/internal/tui3/helpwrap_test.go b/internal/tui3/helpwrap_test.go
new file mode 100644
index 0000000000..f19b30c158
--- /dev/null
+++ b/internal/tui3/helpwrap_test.go
@@ -0,0 +1,71 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+// A WRAPPED /help ROW KEEPS THE COLUMN ITS SENTENCE STARTED IN. At a narrow
+// width the old wrap put the rest of the sentence at column 0, which read as
+// another key. The column is the leading spaces when the row already hangs,
+// and otherwise the cell after the gap that pads the key.
+func TestHelpWrappedLinesKeepTheirColumn(t *testing.T) {
+ help := helpText("", chordSpelling{meta: chordAltWord})
+ for _, width := range []int{60, 80, 110} {
+ room := width - noteLead - workIndentCols(width)
+ wrapped := false
+ for _, line := range strings.Split(help, "\n") {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ col := sheetColumn(line)
+ pieces := wrapSheetLine(line, room)
+ if len(pieces) < 2 {
+ continue
+ }
+ wrapped = true
+ for i, piece := range pieces {
+ if i == 0 || col == 0 {
+ continue
+ }
+ got := 0
+ for _, r := range piece {
+ if r != ' ' {
+ break
+ }
+ got++
+ }
+ if got != col {
+ t.Fatalf("at %d columns a wrapped row starts at %d, want the first line's column %d:\n %q\n %q",
+ width, got, col, line, piece)
+ }
+ }
+ }
+ if !wrapped {
+ t.Fatalf("at %d columns no /help row wrapped, so the column was never checked", width)
+ }
+
+ a := newTestApp(&fakeAgent{model: "m"})
+ a.width, a.height = width, 80
+ a.railAway = true
+ a.slash("/help")
+ var note *entry
+ for i := range a.entries {
+ if a.entries[i].kind == entryNote && strings.Contains(a.entries[i].text, "/help") {
+ note = &a.entries[i]
+ }
+ }
+ if note == nil || !note.sheet {
+ t.Fatal("/help did not land as a column sheet")
+ }
+ body := a.bodyWidth()
+ for _, row := range a.renderEntry(0, note, body) {
+ plainRow := plain(row)
+ if ansi.StringWidth(plainRow) > body {
+ t.Fatalf("at %d columns a /help row is wider than the column:\n%q", width, plainRow)
+ }
+ }
+ }
+}
diff --git a/internal/tui3/home.go b/internal/tui3/home.go
index 53c766d9c9..15d6f66090 100644
--- a/internal/tui3/home.go
+++ b/internal/tui3/home.go
@@ -2539,7 +2539,7 @@ func (a *app) homeKey(msg tea.KeyPressMsg) tea.Cmd {
a.pageMsg = ""
// THE ROUTER IS READ FIRST, AND IT IS ONE FUNCTION FOR EVERY PLACE
// (placekeys.go). It claims the chords that mean the same thing wherever you
- // are standing, alt+1…8, tab, alt+enter, alt+., the shift arrows, and `→`
+ // are standing, alt+1…9, tab, alt+enter, alt+., the shift arrows, and `→`
// when the row has verbs — and hands everything else straight back, so this
// handler keeps its right of first refusal over its own keys.
if cmd, took := a.placeKey(msg); took {
@@ -5230,16 +5230,37 @@ func homeGlyph(row session.SessionRow, ascii bool) string {
return homeIdleGlyph
}
-// homeName is what a conversation is CALLED on this surface's lists, through
-// [listName]: the title it gave itself, then the folder it lives in when that
-// folder reads as words, and then — rather than the id it usually is — the
-// plain word for a conversation nothing has named yet.
+// homeName is what a conversation is CALLED on this surface's lists: home's
+// sessions rows and the sessions place, through one rule.
//
-// IT NEVER HAD THE PICKER'S MIDDLE RUNG. [humanName] can fall back to the first
-// thing the person said because the resume picker has read the transcript; a
-// [session.SessionRow] carries no opening line, so this call passed a title and
-// a path and got a title-cased hex id whenever the title was empty.
+// A TITLE THAT IS ONLY THE SESSION'S ID IS NOT A NAME. The id arrives as the
+// raw folder stem, or already title-cased (`D53cceead3f99593` for
+// `d53cceead3f99593`), because [listName] raises the first letter of a stem
+// that happens to read as letters. EqualFold is the comparison that catches
+// both. The word is returned as itself, so a later pass that stored it back
+// onto the row does not title-case it into `New Conversation`.
+//
+// A TITLELESS ROW KEEPS [listName]'s ladder. A brand new launch has no title
+// yet; calling every empty title the word put that launch on home as a saved
+// chat. The sessions place fills an empty title with this function and then
+// asks again, so a stem that came back as the id is caught on that second pass.
func homeName(row session.SessionRow) string {
+ title := strings.TrimSpace(row.Title)
+ // A launch nobody has spoken in is not a saved chat. Open is not enough:
+ // the window sitting in that shell marks it in use. A live row, or one
+ // someone has spoken in, is the chat the sessions list and the sessions
+ // place both call by the word.
+ spoken := !row.At.IsZero() || row.Live
+ if !spoken {
+ return listName(row.Title, row.Transcript)
+ }
+ if strings.EqualFold(title, unnamedConversationWord) {
+ return unnamedConversationWord
+ }
+ id := strings.TrimSpace(row.ID)
+ if id != "" && title != "" && strings.EqualFold(title, id) {
+ return unnamedConversationWord
+ }
return listName(row.Title, row.Transcript)
}
diff --git a/internal/tui3/home_test.go b/internal/tui3/home_test.go
index ab434cca77..6da7868872 100644
--- a/internal/tui3/home_test.go
+++ b/internal/tui3/home_test.go
@@ -257,6 +257,17 @@ func homeText(a *app) string {
return ansi.Strip(strings.Join(lines, "\n"))
}
+// homeBodyText is [homeText] under the head. THE STRIP OF CHATS IS ON EVERY
+// PAGE (head.go), so a conversation's name stands on it over home as well as
+// on home's own rows, and a test about what home's rows list reads these.
+func homeBodyText(a *app) string {
+ lines := strings.Split(homeText(a), "\n")
+ if len(lines) > placeHeadRows {
+ lines = lines[placeHeadRows:]
+ }
+ return strings.Join(lines, "\n")
+}
+
// homeCardNow is the detail column exactly as it stands, without moving the
// cursor and without widening anything.
//
@@ -651,7 +662,7 @@ func TestTypingFiltersLiveWhileTheActionRowStaysTheDefault(t *testing.T) {
for _, r := range "pricing" {
a.homeKey(key(string(r)))
}
- text := homeText(a)
+ text := homeBodyText(a)
if !strings.Contains(text, "Pricing Research") {
t.Fatalf("the query lost the conversation it should have found:\n%s", text)
}
@@ -1391,7 +1402,7 @@ func TestAQueryMatchesWhatATaskCameTo(t *testing.T) {
for _, r := range "postgres" {
a.homeKey(key(string(r)))
}
- text := homeText(a)
+ text := homeBodyText(a)
if !strings.Contains(text, "Wednesday") {
t.Fatalf("a query over what the work came to found nothing:\n%s", text)
}
@@ -1602,7 +1613,7 @@ func TestHomeMarksARowWhoseFolderIsGoneWhereverItsAddressIsDrawn(t *testing.T) {
a.openHome()
// AT REST: THE ROW'S OWN MARGIN.
- for _, line := range strings.Split(homeText(a), "\n") {
+ for _, line := range strings.Split(homeBodyText(a), "\n") {
if strings.Contains(line, "A Project That Moved") && !strings.Contains(line, homeGoneShort) {
t.Fatalf("the resting row does not say %q:\n%s", homeGoneShort, homeText(a))
}
diff --git a/internal/tui3/homeband_answer_test.go b/internal/tui3/homeband_answer_test.go
index 5772e0c879..ba6191743e 100644
--- a/internal/tui3/homeband_answer_test.go
+++ b/internal/tui3/homeband_answer_test.go
@@ -326,10 +326,9 @@ func TestHomeAnswersItsOwnWindowThroughItsOwnResolver(t *testing.T) {
// SAYING NO FROM HOME, IN ONE KEYSTROKE.
//
// The standing card is the one question whose no lives on a key home cannot
-// spare: `esc` in the conversation is the outright no, and `esc` on home closes
-// home. So the engine's list carries `0 not set up` and this band draws it like
-// any other chip — a card met at home can now be answered all three ways
-// without walking to the window it is in.
+// spare: `esc` on home closes home. The engine's list carries the kind's own
+// no, and this band draws it like any other chip. A card met at home can be
+// answered all three ways without walking to the window it is in.
func TestHomeCanSayNoToAStandingCard(t *testing.T) {
watch := standing.Item{
Words: "tell me when ci goes red",
@@ -338,7 +337,7 @@ func TestHomeCanSayNoToAStandingCard(t *testing.T) {
}
lab := newAnswerLab(t, standingQuestion(9, watch, "wants to keep an eye on: tell me when ci goes red"), time.Now())
text := homeText(lab.a)
- for _, chip := range []string{"1 yes", "3 just once", "0 not set up"} {
+ for _, chip := range []string{"1 Watch for it", "3 Check once now", "0 Don't watch"} {
if !strings.Contains(text, chip) {
t.Fatalf("the card does not offer %q:\n%s", chip, text)
}
@@ -357,7 +356,7 @@ func TestHomeCanSayNoToAStandingCard(t *testing.T) {
if typed := lab.a.home.box.String(); typed != "" {
t.Fatalf("the decline also typed %q into the box", typed)
}
- if !strings.Contains(homeText(lab.a), answerSentWord+"not set up") {
+ if !strings.Contains(homeText(lab.a), answerSentWord+"Don't watch") {
t.Fatalf("home did not say what it just answered:\n%s", homeText(lab.a))
}
}
@@ -373,10 +372,10 @@ func TestHomeCanSayNoToAReminderThatOffersNoOnce(t *testing.T) {
}
lab := newAnswerLab(t, standingQuestion(9, reminder, "wants to keep an eye on: remind me at 6 to leave"), time.Now())
text := homeText(lab.a)
- if strings.Contains(text, "3 just once") {
+ if strings.Contains(text, "Check once now") || strings.Contains(text, "Only now") {
t.Fatalf("a one-off reminder was offered `once` from home:\n%s", text)
}
- if !strings.Contains(text, "0 not set up") {
+ if !strings.Contains(text, "0 Don't remind me") {
t.Fatalf("a one-off reminder was offered no way to say no:\n%s", text)
}
lab.a.homeKey(key(session.StandingNoKey))
diff --git a/internal/tui3/homebridge_test.go b/internal/tui3/homebridge_test.go
index a8efa5ab2c..20df7d8673 100644
--- a/internal/tui3/homebridge_test.go
+++ b/internal/tui3/homebridge_test.go
@@ -224,8 +224,12 @@ func TestEnterOnTheBarOpensThePlaceUnderTheCursor(t *testing.T) {
if !a.bar.on {
t.Fatal("the bar did not rise")
}
- // Two words along the bar, past the way back to the chats, which opens
- // nothing by itself...
+ // Three words along the bar, past teams and the way back to the chats,
+ // which opens nothing by itself...
+ drive(t, a, key("right"))
+ if a.bar.at != pageTeams {
+ t.Fatalf("→ landed the bar cursor on %q, want teams", a.bar.at.word())
+ }
drive(t, a, key("right"))
if a.bar.at != pageChats {
t.Fatalf("→ landed the bar cursor on %q, want chats", a.bar.at.word())
diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go
index cbd178fe64..db8fdb5bb9 100644
--- a/internal/tui3/homeexchange.go
+++ b/internal/tui3/homeexchange.go
@@ -693,7 +693,7 @@ func (a *app) answerExchangeCard(ex *homeExchange, notice *session.StandingNotic
func (a *app) changeExchangeCard(ex *homeExchange, notice *session.StandingNotice, words string) tea.Cmd {
ex.box.reset()
ex.changing = false
- ex.settle(standChangedWord, standChangeWord)
+ ex.settle(standChangedWord, session.StandingChangeWord(ex.view.item))
ex.rows = append(ex.rows, exchangeRow{kind: exchangeSaid, text: words})
ex.said = a.now()
// THE CORRECTION IS A TURN LIKE ANY OTHER from the pane's point of view: the
@@ -2195,7 +2195,7 @@ func exchangeHint(ex *homeExchange) string {
// `change when or where`, which the card itself says on the row it
// settles into; here it is the one word every question spells it with.
if verb, ok := questionVerbFor(questionCommentKey); ok {
- parts = append(parts, verb.key+" "+verb.word)
+ parts = append(parts, verb.key+" "+questionVerbWord(*ex.ask, verb))
}
}
}
diff --git a/internal/tui3/homeexchange_test.go b/internal/tui3/homeexchange_test.go
index 850fdfd7c3..1668a3d712 100644
--- a/internal/tui3/homeexchange_test.go
+++ b/internal/tui3/homeexchange_test.go
@@ -380,7 +380,7 @@ func TestTheCardInThePaneIsAnsweredWithOne(t *testing.T) {
drive(t, a, key("up"), key("enter"))
frame := homeText(a)
- for _, want := range []string{"remind me at 6 to leave", "at 6 today", "about $0.02, once", "1 yes, set it up"} {
+ for _, want := range []string{"remind me at 6 to leave", "at 6 today", "about $0.02, once", "1 Remind me"} {
if !strings.Contains(frame, want) {
t.Fatalf("the card does not say %q:\n%s", want, frame)
}
@@ -452,10 +452,10 @@ func TestTheErrandHintNamesOnlyTheAnswersTheCardDrew(t *testing.T) {
// A one-off reminder. "Do it once, now" says the wrong thing at the
// wrong moment for a line that was meant for six o'clock, so the card
// draws two numbered chips and the hint may name two digits.
- {"a one-off reminder", standReminder(), "1 yes, set it up · 0 no · o other", 2},
- // A watch is a thing a person may reasonably want done once, now — the
- // third answer is drawn, so the third digit is named.
- {"a watch", standItem(), "1 yes, set it up · 3 just once · 0 no · o other", 3},
+ {"a one-off reminder", standReminder(), "1 Remind me in 1 minute — 07:35 · 0 Don't remind me · o Change…", 2},
+ // A repeating check is a thing a person may reasonably want done once, now.
+ // The third answer is drawn, so the third digit is named.
+ {"a repeating check", standItem(), "1 Set it up · Mondays at 9am · 3 Only now, don't repeat · 0 Don't set it up · o Change…", 3},
} {
lab := newErrandLab(t)
mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now())
@@ -516,8 +516,8 @@ func TestTheErrandHintNamesOnlyTheAnswersTheCardDrew(t *testing.T) {
// that hands the keyboard back to the list, and a card left standing on the
// column is not an answer — so a person who asked for a reminder from home and
// then thought better of it had nothing to press. The decline is the engine's
-// own `0 not set up` ([session.StandingNoKey]), which is the same key on this
-// pane, on home's answer band and in the conversation.
+// own `0` ([session.StandingNoKey]), in the kind's own words, which is the same
+// key on this pane, on home's answer band and in the conversation.
func TestTheCardInThePaneIsDeclinedWithZero(t *testing.T) {
lab := newErrandLab(t)
mine := lab.session("-tmp-alpha", "aaaa000000000001", "pricing research", "/tmp/alpha", time.Now())
@@ -535,7 +535,7 @@ func TestTheCardInThePaneIsDeclinedWithZero(t *testing.T) {
// THE HINT NAMES IT, because this is the only way out of the question that
// answers it.
- if frame := homeText(a); !strings.Contains(frame, "0 no") {
+ if frame := homeText(a); !strings.Contains(frame, "0 Don't remind me") {
t.Fatalf("the pane does not name the decline:\n%s", frame)
}
drive(t, a, key(session.StandingNoKey))
@@ -1623,7 +1623,7 @@ func TestANarrowWindowStacksTheExchangeOverTheList(t *testing.T) {
if _, ok := a.homeStacked(); !ok {
t.Fatal("a narrow frame did not stack the exchange over the list")
}
- frame := homeText(a)
+ frame := homeBodyText(a)
if !strings.Contains(frame, homeAskHereWord) || !strings.Contains(frame, "› remind me at 6 to leave") {
t.Fatalf("the stacked pane is not on the screen:\n%s", frame)
}
diff --git a/internal/tui3/homepanel_sessions_test.go b/internal/tui3/homepanel_sessions_test.go
index 965ea0dd2e..f541bd9778 100644
--- a/internal/tui3/homepanel_sessions_test.go
+++ b/internal/tui3/homepanel_sessions_test.go
@@ -9,6 +9,45 @@ import (
"github.com/Agent-Field/codeaf/internal/session"
)
+// An untitled chat on home used to read as its session id with the first
+// letter raised (`D53cceead3f99593`). The sessions place already calls that
+// row `new conversation`. Home reads the name from the same [homeName].
+func TestHomeSessionsCallsAnUntitledChatNewConversation(t *testing.T) {
+ now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
+ id := "d53cceead3f99593"
+ transcript := "/chat/" + id + "/transcript.jsonl"
+ titled := session.SessionRow{
+ ID: "927d303242f9d00e", Transcript: "/chat/927d303242f9d00e/transcript.jsonl",
+ Title: "Porting the Picker", At: now.Add(-time.Hour),
+ }
+ for _, title := range []string{id, "D53cceead3f99593"} {
+ row := session.SessionRow{ID: id, Transcript: transcript, Title: title, At: now, Live: true}
+ in := homeGridInput{now: now, rows: []switcherRow{
+ {kind: switcherConversation, session: row, title: homeName(row)},
+ {kind: switcherConversation, session: titled, title: homeName(titled)},
+ }}
+ got := (sessionsPanel{homePanelBase{panelSessions}}).rows(&in)
+ var untitledTitle, titledTitle string
+ for _, line := range got.lines {
+ if line.cell == nil {
+ continue
+ }
+ switch line.row.ID {
+ case id:
+ untitledTitle = line.cell.title
+ case titled.ID:
+ titledTitle = line.cell.title
+ }
+ }
+ if untitledTitle != unnamedConversationWord {
+ t.Fatalf("title %q reads %q, want %q", title, untitledTitle, unnamedConversationWord)
+ }
+ if titledTitle != "Porting the Picker" {
+ t.Fatalf("a titled chat reads %q", titledTitle)
+ }
+ }
+}
+
func TestHomeSessionsShowsFifteenMostRecentConversations(t *testing.T) {
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
in := homeGridInput{now: now}
diff --git a/internal/tui3/homepanel_test.go b/internal/tui3/homepanel_test.go
index fa103d8a63..e4d7424a7e 100644
--- a/internal/tui3/homepanel_test.go
+++ b/internal/tui3/homepanel_test.go
@@ -135,8 +135,11 @@ func TestWhereYouWereLeadsWithThisWindowsOwnConversation(t *testing.T) {
if quiet := lines[own+3]; !strings.Contains(quiet, "Quiet Chat a") {
t.Fatalf("the quiet rows do not follow in recency order:\n%s", frame)
}
- // AND THE TWO ROWS THAT ARE ON OTHER PANELS ARE NOT HERE A SECOND TIME.
- if strings.Count(frame, "Swarm Task Splitting") != 1 || strings.Count(frame, "Bounty Reward Companies") > 1 {
+ // AND THE TWO ROWS THAT ARE ON OTHER PANELS ARE NOT HERE A SECOND TIME. The
+ // head is not counted: the strip of chats over home carries the tabs this
+ // window has open, which is a different list from home's panels.
+ body := strings.Join(lines[placeHeadRows:], "\n")
+ if strings.Count(body, "Swarm Task Splitting") != 1 || strings.Count(body, "Bounty Reward Companies") > 1 {
t.Fatalf("a conversation is drawn on two panels:\n%s", frame)
}
}
diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go
index 8a4a90127d..12a32ccbcb 100644
--- a/internal/tui3/homeslash.go
+++ b/internal/tui3/homeslash.go
@@ -188,7 +188,7 @@ func homeFate(word, rest string) string {
// /project IS THE PIN AND /folder IS NOT, since 2026-09-22
// (projectcmd.go says what the two used to share).
return fateTargetFolder
- case "settings", "search", "spend", "history", "home", "wall":
+ case "settings", "search", "spend", "history", "home", "wall", "teams":
return fatePlace
case "resume":
return fateResume
diff --git a/internal/tui3/host.go b/internal/tui3/host.go
index e3699ebda7..6de13ad331 100644
--- a/internal/tui3/host.go
+++ b/internal/tui3/host.go
@@ -81,13 +81,14 @@ import (
// a key sign-in WORKS, unchanged. The person pastes a secret into a box on
// this screen and it travels on the wire like every other
// answer; nothing about it needs a browser or a port.
-// /settings says [settingsRemoteWord] as it opens, and opens anyway.
-// Half these rows are this surface's own (the mouse, the
-// timestamps, the draft) and genuinely apply; the other half
-// govern the SESSION, which reads them from the far machine's
-// profile. A panel that closed itself would take working rows
-// away; one that said nothing would let a person turn a gate
-// off and watch it stay on.
+// /settings says [settingsHostNote] as it opens, and again when the
+// tab changes whose disk the rows are, and opens anyway.
+// Every tab but Teams writes this machine. The Teams tab
+// writes the far machine when the seam can take the change,
+// and the note on that tab says only that. A panel that
+// closed itself would take working rows away; one that said
+// nothing would let a person turn a gate off and watch it
+// stay on.
// the always key the consent card's "always" writes nothing (the door hands
// no save seams over a connection), so the row says "allowed"
// rather than "saved" — which is the truth: the answer holds
@@ -387,12 +388,39 @@ const (
// row and shares it with nothing. It says WHAT and leaves the why to the
// command, which is the trade every row in a frame makes.
connectAskRemoteWord = "connecting an account is not available over --host yet"
- // settingsRemoteWord opens the panel on a remote session.
- settingsRemoteWord = "these rows and changes belong to this machine — this conversation reads its profile on the other one"
// exportHereWord follows the path a remote session's /export landed on.
exportHereWord = " · on this machine"
+ // settingsLocalWord is /settings over a connection, on every tab but Teams,
+ // when the Teams tab can be saved on the far machine. The rows on show are
+ // this machine's. The second sentence is the one tab that is not.
+ settingsLocalWord = "these rows belong to this machine; the Teams tab is saved on the other one."
+ // settingsLocalUnreadWord is the same opening when the Teams tab cannot be
+ // written over this connection. It does not claim that tab is saved there.
+ settingsLocalUnreadWord = "these rows belong to this machine; this conversation reads its profile on the other one."
)
+// settingsHostNote is the sentence /settings says over a connection. onTeams
+// is the tab on show. savedThere means the seam can write that machine's
+// teams defaults. shownThere means those defaults have been read and are what
+// the tab is drawing, even when the write is refused.
+//
+// THE TEAMS TAB SAYS ONLY WHAT IS TRUE THERE. The other tabs write this
+// machine, and they name the Teams tab as the exception when that exception
+// is real. A note that said "these rows belong to this machine" on the Teams
+// tab would be the panel lying about the disk it just wrote.
+func settingsHostNote(onTeams bool, host string, savedThere, shownThere bool) string {
+ if onTeams && host != "" && savedThere {
+ return "these rows are saved on " + host + "."
+ }
+ if onTeams && host != "" && shownThere {
+ return "these rows are on " + host + ". changing them is not available over this connection."
+ }
+ if savedThere {
+ return settingsLocalWord
+ }
+ return settingsLocalUnreadWord
+}
+
// remoteProfileWord is the honest floor for commands whose setting or store
// belongs to the session's machine but has no wire door yet. The machine is
// named because "another machine" makes a destructive refusal needlessly
@@ -434,9 +462,10 @@ func (a *app) remoteProfileWord(thing string) string {
// spend THE FAR MACHINE'S, through Places.Ledger and a held cache.
// search THE FAR MACHINE'S, one call from the search command's goroutine.
// memory THE FAR MACHINE'S, all seven readings and writes together.
-// settings SPLIT, AND CORRECTLY: half its rows are this surface's own and
-// half are read from the far machine's profile, which is what
-// [settingsRemoteWord] says as it opens.
+// settings SPLIT, AND CORRECTLY: every tab but Teams writes this machine.
+// The Teams tab writes the far machine when the seam can take the
+// change, which is what [settingsHostNote] says, for the tab on
+// show.
//
// A PLACE THAT HAS NOT LEARNED SAYS SO, in one dim line where its rows would be
// ([place.remote], pages.go). The sentence is the place's own because the noun in
@@ -447,9 +476,9 @@ func (a *app) remoteProfileWord(thing string) string {
// CLAUDE.md states about the manual said about the code.
//
// AND THE FRAME SAYS WHOSE MACHINE IT IS. A room whose rows quietly changed which
-// disk they describe would be the same fault walked backwards, so the tab bar
-// carries the machine's name at its right end and nothing at all on a local
-// session ([app.placeBarMachine]). It is [app.host], the same field the status
+// disk they describe would be the same fault walked backwards, so the nav
+// carries the machine's name at its far end and nothing at all on a local
+// session (topnav.go's [app.navTails]). It is [app.host], the same field the status
// line's place segment, /status and the legend under the input all read, because
// the connection is shown as the place and is shown nowhere else.
const (
diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go
index 0486d0c7de..fae2d93595 100644
--- a/internal/tui3/hover.go
+++ b/internal/tui3/hover.go
@@ -128,13 +128,12 @@ const (
// NODE's (task.go). Every row of that column is a door into a node's room, so
// every row of it reacts — which is this file's own law read the other way
// round: the set that lights is the set [app.press] acts on, and in the
- // roster that is all of it. What the hover buys beyond the background step is
- // the disclosure triangle a family root reveals in its glyph cell — and that
- // cell is a fold ONLY on the frames where the triangle is drawn in it, which
- // is this law read strictly: the press reads the span the layout recorded
- // (task.go's [app.railLead]), so a state cell nobody is pointing at is the
- // row's, and the row is the node's door.
+ // roster that is all of it.
hoverRail
+ // hoverRailGroup is a folding group's heading in the roster (`Done 7 ▸`),
+ // index its group. The whole row lights because a press anywhere on it
+ // opens the group or folds it.
+ hoverRailGroup
// hoverRailArea is the roster's non-node space. The rail remains one
// pointer target even between rows, because its footer offer follows the
// hand across the whole column.
@@ -149,11 +148,6 @@ const (
// there to be an area of: everything that asks about the rail's rows would
// answer about a column that is not on the frame.
hoverRailGrip
- // hoverRailDoor is the STANDING column's own door — the footer line carrying
- // the `❯` and `ctrl+g hide` (task.go's [railStowHint]). It is the other half
- // of [hoverRailGrip]: one control in two states, so the right edge lights the
- // same way whether the column is up or away.
- hoverRailDoor
// hoverMarginDoor is one of the margin's two `+` rows, held by the SLASH WORD
// it types (margin.go): there are two of them and they type two different
// things, so the word is what tells them apart — and it is what the paint
@@ -166,12 +160,12 @@ const (
hoverMarginStand
// hoverRailMore is the footer's OTHER door — the one line that leaves the
// column for the task page (taskview.go's [taskSheetPastHint]). It is a kind
- // of its own for [hoverRailDoor]'s reason: it belongs to no node, and it does
- // something different from every other line of the footer.
+ // of its own because it belongs to no node, and it does something different
+ // from every other line of the footer.
hoverRailMore
// hoverRailStanding is the footer's standing count — `◦ 2 standing orders`,
// a door onto /standing (standdoor.go). It is a kind of its own for
- // [hoverRailDoor]'s reason and one more: it was a segment of the status row
+ // [hoverRailMore]'s reason and one more: it was a segment of the status row
// until 2026-09-09, and what lights has to be what the press acts on
// wherever the line is drawn.
hoverRailStanding
@@ -315,9 +309,9 @@ const (
// the person's own notes, and a paragraph that brightened would be
// promising a door on every sentence of it.
hoverQuestionOption
- // hoverDockLabel is the quiet word `chats` in front of the dock, and
- // hoverDockWall the `▦` beside it. They are one door and two kinds,
- // because a hover lights the piece under the pointer. hoverDockCell is
+ // hoverDockLabel is the dock's door, `▦ All`, glyph and word as one
+ // button, and hoverDockWall the `▦` alone on a row too narrow for the
+ // word (walldock.go). Either lights the whole door. hoverDockCell is
// one conversation's cell after them, held by the conversation's key
// rather than by its column (walldock.go): the dock narrows as the keys
// beside it change, and a hover stored as a column would follow the
@@ -325,18 +319,11 @@ const (
hoverDockLabel
hoverDockWall
hoverDockCell
- // hoverTraffic is one row of the manager's Traffic rail, held by its row
- // on the last frame (teamtraffic.go), and hoverTrafficGrip the narrow
- // frame's edge that lays the traffic over the body.
- hoverTraffic
- hoverTrafficGrip
- // hoverTrafficHide is the rail header's `hide` word, and hoverTrafficClose
- // the narrow frame's card's `Close esc` (teamrail.go).
- hoverTrafficHide
- hoverTrafficClose
- // hoverTrafficTab is the header's `Tasks 2` word, which lays the manager's
- // own tasks in the column (teamrail.go).
- hoverTrafficTab
+ // hoverSide is one of the side column's own rows or doors (sidecol.go):
+ // key is the row's identity and index the door on it, -1 for the row as a
+ // whole. A row is held by its key and not by its place because the band
+ // and the Traffic re-lay as things arrive.
+ hoverSide
// hoverThread is a line of a thread card in the conversation, held by its
// entry and the message it shows (teamthreadcard.go).
hoverThread
@@ -487,24 +474,16 @@ func (a *app) hoverTarget(x, y int) hoverAt {
// is on the frame none of the others can be: the roster is away, so every
// question below about a row of it answers about nothing (task.go's
// [app.railGripAt]).
- // THE TRAFFIC RAIL IS ASKED BEFORE THE TASK COLUMN, for the reason the
- // press asks it first: it stands at the frame's right edge, in columns the
- // task column's own questions would otherwise claim (teamtraffic.go).
- if at, ok := a.trafficHoverAt(x, y); ok {
- return at
- }
if a.railGripAt(x, y) {
return hoverAt{kind: hoverRailGrip}
}
if a.railSeamAt(x, y) {
return hoverAt{kind: hoverRailSeam}
}
- // AND THE STANDING COLUMN'S OWN DOOR, which is asked before the rows for the
- // reason the seam is: it is a line of the footer and belongs to no node, so a
- // question about which node is under the pointer would answer about the empty
- // space beside it (task.go's [app.railDoorAt]).
- if a.railDoorAt(x, y) {
- return hoverAt{kind: hoverRailDoor}
+ // THE SIDE COLUMN'S OWN ROWS AND DOORS, before the roster's nodes: its
+ // header, its band, its Traffic, and a group's heading (sidecol.go).
+ if at, ok := a.sideHoverAt(x, y); ok {
+ return at
}
// AND THE FOOTER'S STANDING COUNT, on exactly those terms: it is a line of
// the footer, it belongs to no node, and it answers to a click
@@ -747,6 +726,18 @@ func (a *app) markStale(i int) {
// longer exists is a highlight on somebody else's row.
func (a *app) dropHover() { a.hot = hoverAt{} }
+// dropResizeHover forgets hover that pointed at a door the new layout may
+// not draw. [app.dropHover] covers the body. The nav's own hover is separate:
+// [app.tabHover] is the place word, and [navMore.hot] is `more ▾`. Both feed
+// the hint line ([app.headHint]), which does not ask whether the last frame
+// still drew the door.
+func (a *app) dropResizeHover() {
+ a.dropHover()
+ a.barHover(pageNone)
+ a.navMoreHot(false)
+ a.navMore.hover = -1
+}
+
// The four questions the renderers ask.
// hoveringEntry reports whether the pointer is on this entry's rows.
@@ -786,7 +777,7 @@ func (a *app) hoveringRailMore() bool { return a.hot.kind == hoverRailMore }
// hoveringRailArea reports whether the pointer is anywhere over the roster.
func (a *app) hoveringRailArea() bool {
switch a.hot.kind {
- case hoverRail, hoverRailArea, hoverRailSeam, hoverRailMore, hoverRailStanding:
+ case hoverRail, hoverRailArea, hoverRailSeam, hoverRailMore, hoverRailStanding, hoverSide, hoverRailGroup:
return true
}
return false
@@ -798,10 +789,6 @@ func (a *app) hoveringRailSeam() bool { return a.hot.kind == hoverRailSeam }
// hoveringRailGrip reports whether the pointer is over the closed column's edge.
func (a *app) hoveringRailGrip() bool { return a.hot.kind == hoverRailGrip }
-// hoveringRailDoor reports whether the pointer is over the standing column's own
-// door line, which is the same control in its other state.
-func (a *app) hoveringRailDoor() bool { return a.hot.kind == hoverRailDoor }
-
// hoveringStatusModel reports whether the pointer is on the status row's model
// segment (render.go's [app.paintIdentity] is what it changes).
func (a *app) hoveringStatusModel() bool { return a.hot.kind == hoverStatusModel }
diff --git a/internal/tui3/hoverpressable_test.go b/internal/tui3/hoverpressable_test.go
index 798794e0f4..89725518da 100644
--- a/internal/tui3/hoverpressable_test.go
+++ b/internal/tui3/hoverpressable_test.go
@@ -303,6 +303,10 @@ func TestTheTaskRecordCardLightsTheEdgeUnderThePointer(t *testing.T) {
if !a.taskSheet.detailOn {
t.Fatal("enter did not go inside the card")
}
+ // THE POINTER RESOLVES AGAINST THE LAST FRAME, and the program draws one
+ // between the key and the next motion. The card draws no nav, so its first
+ // row is the card's own title, not the nav's.
+ frame(a)
drive(t, a, motionTo(2, 0))
if !a.hoveringTaskCard(int(taskCardHitHead)) {
diff --git a/internal/tui3/input.go b/internal/tui3/input.go
index 7ff82c0d67..b84f7ab29a 100644
--- a/internal/tui3/input.go
+++ b/internal/tui3/input.go
@@ -359,6 +359,18 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd {
if a.teamMenu.on && !door {
return a.teamMenuKey(msg)
}
+ // And so does the nav's fold menu (navmore.go).
+ if a.navMore.on && !door {
+ return a.navMoreKey(msg)
+ }
+ // The move picker, over everything, and then a team's card have the
+ // keyboard while they are up (teammove.go, teamsheet.go).
+ if a.tmove.on && !door {
+ return a.teamMoveKey(msg)
+ }
+ if a.tsheet.on && !door {
+ return a.teamSheetKey(msg)
+ }
if a.wall.on && !door {
return a.wallKey(msg)
}
@@ -465,7 +477,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd {
// five (pages.go). Each of the seven takes the whole frame, so there is
// nothing under it a key could mean anything to — and the six classes of the
// grammar are read before the place's own keys, on every place, which is what
- // makes `tab`, `alt+1…8` and `→` mean one thing wherever a person is standing
+ // makes `tab`, `alt+1…9` and `→` mean one thing wherever a person is standing
// ([app.placeKeyPress]).
//
// IT USED TO BE FIVE ARMS AT THREE DIFFERENT RUNGS. The settings panel and
@@ -769,7 +781,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd {
return cmd
}
- // AND alt+1…8 IS READ HERE, ON THE CONVERSATION'S ROAD. It is the one class
+ // AND alt+1…9 IS READ HERE, ON THE CONVERSATION'S ROAD. It is the one class
// of the place grammar that belongs to no place — it is how a person GETS to
// a room — and every claim above has already had its say, so a modal overlay
// that wants the chord still gets it first and nothing below has taken a
diff --git a/internal/tui3/managercolumn_test.go b/internal/tui3/managercolumn_test.go
index 1a65c1bd52..949a531fcc 100644
--- a/internal/tui3/managercolumn_test.go
+++ b/internal/tui3/managercolumn_test.go
@@ -8,14 +8,15 @@ import (
"github.com/Agent-Field/codeaf/internal/session"
)
-// managerColumnApp is the manager in front on a wide frame, with the task
-// column remembered open.
+// managerColumnApp is the manager in front on a wide frame, with the column
+// remembered open.
func managerColumnApp(t *testing.T) *app {
t.Helper()
a, _, _, _ := trafficApp(t)
a.width, a.height = 160, 40
a.railAway = false
- if !a.trafficOn() {
+ a.welcome.open = false
+ if a.sideKind() != sideKindManager {
t.Fatal("the manager is not in front")
}
return a
@@ -34,89 +35,136 @@ func managerTasks(a *app, n int) {
a.touch()
}
-// WITH THE MANAGER IN FRONT THE RIGHT COLUMN IS THE TRAFFIC. A task column
-// remembered open is not drawn and not reserved, no folded edge of either
-// stands beside the other, and with no tasks there is no Tasks word.
-func TestManagerColumnIsTrafficWhateverTheTaskColumnSaid(t *testing.T) {
+// sideWordAt is the screen cell of the header's word that brings view to the
+// front.
+func sideWordAt(t *testing.T, a *app, view int) (int, int) {
+ t.Helper()
+ lines, _ := a.railDrawnView(a.viewHeight())
+ if len(lines) == 0 || lines[0].side == nil {
+ t.Fatal("the column has no header")
+ }
+ for _, d := range lines[0].side.doors {
+ if d.act.kind == sideActView && d.act.view == view {
+ return a.railLeft() + len([]rune(railSeam)) + d.span.from, a.topHeight()
+ }
+ }
+ t.Fatalf("the header has no word for view %d", view)
+ return 0, 0
+}
+
+// WITH THE MANAGER IN FRONT THE ONE COLUMN OPENS ON THE TRAFFIC, and its
+// header offers the manager's own tasks as the other word, with its count,
+// even at zero. A press on the word lays the tasks in the same column at the
+// same width with the header where it was, and a press on the Traffic word
+// takes it back. There is no second column and no special case.
+func TestManagerColumnOpensOnTheTrafficAndOffersItsTasks(t *testing.T) {
a := managerColumnApp(t)
rows := railLines(t, a)
- joined := strings.Join(rows, "\n")
- if a.railWidth() != 0 || a.railShowing() || a.railStowed() {
- t.Fatalf("the task column is on the frame beside the manager: width %d showing %v stowed %v", a.railWidth(), a.railShowing(), a.railStowed())
+ head := railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord)
+ if head < 0 || a.sideView() != sideTraffic {
+ t.Fatalf("the manager's column does not open on the Traffic with both words:\n%s", strings.Join(rows, "\n"))
}
- if a.trafficWidth() <= trafficGripCols || railRowOf(rows, trafficWord) < 0 {
- t.Fatalf("the traffic is not the right column (%d):\n%s", a.trafficWidth(), joined)
+ managerTasks(a, 2)
+ rows = railLines(t, a)
+ if railRowOf(rows, sideTasksWord+" 2"+sideWordSep) != head || railRowOf(rows, "Task 1") >= 0 {
+ t.Fatalf("the Tasks word does not count the work behind it:\n%s", strings.Join(rows, "\n"))
}
- for _, never := range []string{railStowHint, "/task", trafficTasksWord} {
- if strings.Contains(joined, never) {
- t.Fatalf("%q is in the right column:\n%s", never, joined)
- }
+ body, cols := a.bodyWidth(), a.railWidth()
+
+ x, y := sideWordAt(t, a, sideTasks)
+ a.setHover(x, y)
+ if a.hot.kind != hoverSide || a.hot.key != sideHeadKey || a.hot.index < 0 {
+ t.Fatalf("the Tasks word does not answer the pointer: %+v", a.hot)
+ }
+ if words := a.dockHoverWords(); !strings.Contains(words, "tasks") || !strings.Contains(words, "click") {
+ t.Fatalf("the Tasks word's hint says %q", words)
}
- // ctrl+g has nothing to swap to, and the task column does not come back.
- drive(t, a, key(railStowKey))
- if a.railShowing() || a.trafficWidth() <= trafficGripCols {
- t.Fatal("ctrl+g brought an empty task column back over the traffic")
+ a.dropHover()
+ sideClick(t, a, x, y)
+ if a.sideView() != sideTasks {
+ t.Fatal("a press on Tasks did not bring the tasks to the front")
}
- // AND ITS OWN HIDE folds it to the edge, still with no task column beside it.
- drive(t, a, key(trafficKey))
- if a.trafficWidth() != trafficGripCols || a.railWidth() != 0 {
- t.Fatalf("alt+l left traffic %d and tasks %d", a.trafficWidth(), a.railWidth())
+ rows = railLines(t, a)
+ if a.bodyWidth() != body || a.railWidth() != cols || railRowOf(rows, sideTasksWord+" 2") != head || railRowOf(rows, "Task 1") <= head {
+ t.Fatalf("the tasks are not in the same column under the same header (body %d->%d, cols %d->%d):\n%s", body, a.bodyWidth(), cols, a.railWidth(), strings.Join(rows, "\n"))
}
- drive(t, a, key(trafficKey))
- if a.trafficWidth() <= trafficGripCols {
- t.Fatal("alt+l did not bring the traffic back")
+ x, y = sideWordAt(t, a, sideTraffic)
+ sideClick(t, a, x, y)
+ if a.sideView() != sideTraffic || a.bodyWidth() != body {
+ t.Fatal("a press on Traffic did not take the column back, or moved the body")
}
}
-// WITH LIVE TASKS OF ITS OWN the header offers them, `Traffic · Tasks 2`; a
-// press on the word lays them in the same column at the same width, and the
-// header line takes it back to the traffic.
-func TestManagerColumnOffersItsTasks(t *testing.T) {
+// THE WORD IN FRONT IS REMEMBERED PER KIND OF CHAT, for the session: a
+// manager opens on the Traffic, a member and a chat in no team on the tasks,
+// and a word the person chose in one kind of chat is what that kind opens on
+// next, whatever the other kind was left on. A chat in no team has only the
+// one word.
+func TestTheColumnsWordIsRememberedPerKindOfChat(t *testing.T) {
a := managerColumnApp(t)
- managerTasks(a, 2)
- rows := railLines(t, a)
- head := railRowOf(rows, trafficWord+trafficTabSep+trafficTasksWord+" 2")
- if head < 0 {
- t.Fatalf("the header does not offer the tasks:\n%s", strings.Join(rows, "\n"))
- }
- body := a.bodyWidth()
- cols := a.trafficWidth()
- tab := a.traffic.drawn.tab
- at, ok := a.trafficHoverAt(tab.from, head)
- if !ok || at.kind != hoverTrafficTab {
- t.Fatalf("the Tasks word does not answer the pointer: %+v", at)
- }
- a.hot = at
- if words := a.dockHoverWords(); !strings.Contains(words, railStowKey) {
- t.Fatalf("the Tasks word's hint says %q", words)
+ harbor := a.wall.teams[0].ID
+ manager := a.frontTabKey()
+ _, priceKey := trafficHandle(t, a, harbor, "openrouter")
+ if a.sideView() != sideTraffic {
+ t.Fatal("a manager does not open on the Traffic")
+ }
+ a.sideSetView(sideTasks)
+
+ spend(t, a, a.trafficGo(priceKey))
+ if a.sideKind() != sideKindMember || a.sideView() != sideTasks {
+ t.Fatalf("a member opens on view %d", a.sideView())
+ }
+ a.sideSetView(sideTraffic)
+
+ spend(t, a, a.trafficGo(manager))
+ if a.sideKind() != sideKindManager || a.sideView() != sideTasks {
+ t.Fatalf("the manager forgot its word: kind %d view %d", a.sideKind(), a.sideView())
+ }
+ spend(t, a, a.trafficGo(priceKey))
+ if a.sideView() != sideTraffic {
+ t.Fatal("the member forgot its word")
}
- a.hot = hoverAt{}
- if _, took := a.trafficPress(tab.from, head); !took || !a.trafficTasksShowing() {
- t.Fatal("a press on Tasks did not lay the tasks in the column")
- }
- frame, _, _ := a.frame()
- var right []string
- for _, r := range strings.Split(plain(frame), "\n") {
- right = append(right, plainCells(r, a.width-a.railWidth(), a.width))
- }
- joined := strings.Join(right, "\n")
- if a.bodyWidth() != body || a.railWidth() != cols || !strings.Contains(joined, "Task 1") || !strings.Contains(joined, trafficTasksWord+" 2") {
- t.Fatalf("the tasks are not in the traffic's column (body %d→%d, cols %d→%d):\n%s", body, a.bodyWidth(), cols, a.railWidth(), joined)
- }
- a.hot = hoverAt{kind: hoverRailDoor}
- if words := a.dockHoverWords(); !strings.Contains(words, "traffic") || !strings.Contains(words, railStowKey) {
- t.Fatalf("the tasks' header hint says %q", words)
- }
- a.hot = hoverAt{}
- drive(t, a, key(railStowKey))
- if a.trafficTasksShowing() || a.trafficWidth() != cols {
- t.Fatal("ctrl+g did not take the column back to the traffic")
- }
- // AND A PRESS ON THE TASKS' HEADER LINE TAKES IT BACK TOO.
- a.trafficTasksShow(true)
- a.frame()
- drive(t, a, clickAt(a.bodyWidth()+3, a.bodyTop()))
- if a.trafficTasksShowing() {
- t.Fatal("a press on the header line left the tasks in the column")
+
+ plainChat, _, _ := tabApp(t)
+ plainChat.profileDir = t.TempDir()
+ plainChat.width, plainChat.height = 160, 40
+ plainChat.welcome.open = false
+ managerTasks(plainChat, 1)
+ if plainChat.sideKind() != sideKindPlain || plainChat.sideView() != sideTasks {
+ t.Fatal("a chat in no team is not on its tasks")
+ }
+ plainChat.sideSetView(sideTraffic)
+ rows := railLines(t, plainChat)
+ if plainChat.sideView() != sideTasks || railRowOf(rows, sideTrafficWord) >= 0 || railRowOf(rows, sideTasksWord+" 1") < 0 {
+ t.Fatalf("a chat in no team offers a Traffic:\n%s", strings.Join(rows, "\n"))
+ }
+}
+
+// LEFT AND RIGHT SWITCH THE WORDS WHILE THE COLUMN HOLDS THE KEYBOARD, and
+// only then: the keyboard stays with the column, the chat in front stays in
+// front, and without the hold the arrows are the draft's.
+func TestLeftAndRightSwitchTheColumnsWords(t *testing.T) {
+ a := managerColumnApp(t)
+ managerTasks(a, 2)
+ front := a.frontTabKey()
+ drive(t, a, key("right"))
+ if a.sideView() != sideTraffic {
+ t.Fatal("an arrow with the keyboard in the draft switched the column")
+ }
+ drive(t, a, altT())
+ if !a.railHold {
+ t.Fatal("alt+t did not give the column the keyboard")
+ }
+ drive(t, a, key("right"))
+ if a.sideView() != sideTasks || !a.railHold || a.frontTabKey() != front {
+ t.Fatalf("right did not switch to the tasks in place: view %d hold %v", a.sideView(), a.railHold)
+ }
+ drive(t, a, key("left"))
+ if a.sideView() != sideTraffic || !a.railHold {
+ t.Fatal("left did not switch back to the Traffic")
+ }
+ drive(t, a, key("esc"))
+ if a.railHold {
+ t.Fatal("esc did not give the keyboard back")
}
}
diff --git a/internal/tui3/margin.go b/internal/tui3/margin.go
index ed8386a32d..9108f1e837 100644
--- a/internal/tui3/margin.go
+++ b/internal/tui3/margin.go
@@ -87,10 +87,10 @@ const (
// third task.
marginDoorMark = "+ "
// marginStandMoreWord is what the standing label calls the orders the column
- // had no room for ([app.marginStandHead]). It is the footer's own word for the
- // same fact one section down (`view more`, taskview.go's [taskSheetMoreHint]),
- // because a column that said `hidden` in one place and `more` in another would
- // be two vocabularies for "there is another page of this".
+ // had no room for ([app.marginStandHead]). It is the band's own word for
+ // the same fact (`+2 more`, sidecol.go), because a column that said `hidden`
+ // in one place and `more` in another would be two vocabularies for "there
+ // is another page of this".
marginStandMoreWord = "more"
)
diff --git a/internal/tui3/narrow_test.go b/internal/tui3/narrow_test.go
index e49f8104cb..5a58396611 100644
--- a/internal/tui3/narrow_test.go
+++ b/internal/tui3/narrow_test.go
@@ -21,98 +21,89 @@ import (
// not DO there: find six of the seven places, read the end of a sentence, and
// tell one conversation from another.
-// THE NARROW BAR STILL SAYS WHERE ELSE YOU CAN GO.
+// THE NARROW NAV STILL SAYS WHERE ELSE YOU CAN GO.
//
-// At sixty columns the bar used to collapse to the single word `home`, with
-// nothing on the frame saying that tasks, standing, memory, spend, search and
-// settings existed at all: seven words and their padding are fifty-seven cells,
-// the air between them is six more, and sixty overshot by three — straight past
-// the middle rung of the ladder, because on a quiet machine no place wears a
-// count. The air is what goes now.
-func TestTheNarrowBarStillSaysWhereElseYouCanGo(t *testing.T) {
+// At sixty columns the places' bar used to collapse to the single word `home`,
+// with nothing on the frame saying the other places existed at all. The nav
+// on the wordmark's row keeps every place reachable at every width: a word it
+// has no room for folds into `more ▾`, which is a door onto exactly those
+// places (navmore.go), and the air between two words never changes.
+func TestTheNarrowNavStillSaysWhereElseYouCanGo(t *testing.T) {
a := placeApp(t)
- // THE WIDTH WHERE THE AIR GOES is one cell under the bar's own width: the
- // words still fit there once the gaps between them are given up. It is read
- // off the bar rather than typed, because the bar is four words now and a
- // literal sixty was a width measured against seven.
- tight := ansi.StringWidth(plain(a.placeTabBar(200, false, a.pal))) - 1
- for _, width := range []int{tight, 60, 80, 120, 160} {
- bar := plain(a.placeTabBar(width, false, a.pal))
- for _, id := range barPages(a.page, false) {
- if !strings.Contains(bar, id.word()) {
- t.Fatalf("at %d columns the bar drew\n\t%q\nand a person cannot reach %q from it; every one of its places should be on the row:\n\t%q",
- width, bar, id.word(), plain(a.placeTabBar(200, false, a.pal)))
+ for _, width := range []int{50, 60, 80, 120, 160} {
+ bar := navPlaces(a, width, false)
+ for _, id := range barPages(a.navLit(), false) {
+ if !strings.Contains(" "+bar+" ", " "+id.word()+" ") && !pagesHold(a.navMore.folded, id) {
+ t.Fatalf("at %d columns the nav drew\n\t%q\nand a person cannot reach %q from it, on the row or behind `more`", width, bar, id.word())
}
}
- if got := ansi.StringWidth(bar); got > width {
- t.Fatalf("at %d columns the bar is %d cells wide and runs past the frame:\n\t%q", width, got, bar)
+ if got := ansi.StringWidth(plain(a.navLine(width, a.pal))); got > width {
+ t.Fatalf("at %d columns the nav is %d cells wide and runs past the frame", width, got)
+ }
+ // AND THE AIR IS HELD: two blank cells between two words, whatever the
+ // width, because what a narrow row gives up is words and never air.
+ if width >= 80 && !strings.Contains(bar, "home teams chats sessions spend settings") {
+ t.Fatalf("at %d columns the nav drew\n\t%q\nand should carry the six places two cells apart", width, bar)
+ }
+ if strings.Contains(bar, " ") {
+ t.Fatalf("at %d columns the nav drew\n\t%q\nwith the air between two words changed", width, bar)
}
}
- // AND THE WIDE TIER DID NOT MOVE. The air between the chips is what a narrow
- // frame gives up, so a frame with room for it still has it — a fix for sixty
- // columns that re-spaced a hundred and sixty would be a fix that cost every
- // other terminal something.
- wide := plain(a.placeTabBar(120, false, a.pal))
- if !strings.Contains(wide, "home chats sessions") {
- t.Fatalf("at 120 columns the bar drew\n\t%q\nand the air between two chips is gone; it should read\n\t%q",
- wide, " home chats sessions spend settings")
- }
- if narrow := plain(a.placeTabBar(tight, false, a.pal)); !strings.Contains(narrow, "home chats sessions") {
- t.Fatalf("at %d columns the bar drew\n\t%q\nand it should carry every word with the air between the chips given up:\n\t%q",
- tight, narrow, " home chats sessions spend settings")
+}
+
+// pagesHold reports whether one place is in a list of places.
+func pagesHold(list []page, id page) bool {
+ for _, p := range list {
+ if p == id {
+ return true
+ }
}
+ return false
}
-// A BAR TOO NARROW FOR EVERY WORD SAYS HOW MANY IT DROPPED.
+// A NAV TOO NARROW FOR EVERY WORD FOLDS THEM INTO `more ▾`, AND IT IS A DOOR.
//
-// Under the width where all seven fit there is no honest way to draw them, and
-// the collapse this ladder exists to prevent is not "fewer words" — it is a row
+// Under the width where all six fit there is no honest way to draw them, and
+// the collapse the ladder exists to prevent is not "fewer words", it is a row
// that says `home` and lets a person believe that is all there is. So what is
-// left ends in a count of the places that are not on it, and the key that
-// reaches them is on the foot of every place.
-func TestABarTooNarrowForEveryWordSaysHowManyItDropped(t *testing.T) {
+// left ends in `more ▾`, which stands for exactly the places it folded, and
+// never in a bare count a person cannot press.
+func TestANavTooNarrowForEveryWordFoldsIntoMore(t *testing.T) {
a := placeApp(t)
- for _, tc := range []struct{ width int }{{28}, {24}} {
- bar := plain(a.placeTabBar(tc.width, false, a.pal))
+ for _, width := range []int{34, 40, 48} {
+ bar := navPlaces(a, width, false)
if !strings.Contains(bar, a.page.word()) {
- t.Fatalf("at %d columns the bar drew\n\t%q\nand dropped the place you are standing in (%q)", tc.width, bar, a.page.word())
- }
- if !strings.Contains(bar, tokens.GlyphCollapsed) {
- t.Fatalf("at %d columns the bar drew\n\t%q\nand said nothing about the places it could not carry; it should end in a marked count, as in\n\t%q",
- tc.width, bar, " home sessions "+tokens.GlyphCollapsed+" 2")
+ t.Fatalf("at %d columns the nav drew\n\t%q\nand folded the place you are standing in (%q)", width, bar, a.page.word())
}
- if got := ansi.StringWidth(bar); got > tc.width {
- t.Fatalf("at %d columns the bar is %d cells wide and runs past the frame:\n\t%q", tc.width, got, bar)
+ if !strings.HasSuffix(bar, a.navMoreWord(a.pal)) || !a.navMore.span.pressable() {
+ t.Fatalf("at %d columns the nav drew\n\t%q\nand said nothing about the places it could not carry; it should end in `more ▾`", width, bar)
}
- // AND THE COUNT IS THE TRUTH. Every place that is not spelled on the row
- // is one the count has to stand for, or the row is a second way of
- // hiding them.
missing := 0
for _, id := range barPages(a.page, false) {
- if !strings.Contains(bar, id.word()) {
+ if !strings.Contains(" "+bar+" ", " "+id.word()+" ") {
missing++
+ if !pagesHold(a.navMore.folded, id) {
+ t.Fatalf("at %d columns %q is off the row and not behind `more`", width, id.word())
+ }
}
}
- // THE COUNT WEARS THE FOLD MARK, which is what tells it from a badge or
- // a door ([barMoreWord] and [foldSpellings]).
- if want := tokens.GlyphCollapsed + " " + itoa(missing); !strings.Contains(bar, want) {
- t.Fatalf("at %d columns the bar drew\n\t%q\nwhich leaves %d places off the row; the count should read %q",
- tc.width, bar, missing, want)
+ if missing != len(a.navMore.folded) || missing == 0 {
+ t.Fatalf("at %d columns the nav left %d places off and folded %v", width, missing, a.navMore.folded)
+ }
+ if strings.Contains(bar, "+") || strings.Contains(bar, tokens.GlyphCollapsed) {
+ t.Fatalf("at %d columns the fold is a count and not a word: %q", width, bar)
}
}
- // AND A FRAME WITH NO ROOM FOR THE COUNT SAYS NOTHING RATHER THAN RUNNING
- // PAST ITS OWN EDGE, which is the fault this whole ladder exists to prevent.
- for _, width := range []int{8, 12, 16} {
- if bar := plain(a.placeTabBar(width, false, a.pal)); ansi.StringWidth(bar) > width {
- t.Fatalf("at %d columns the bar is %d cells wide and runs past the frame:\n\t%q", width, ansi.StringWidth(bar), bar)
+ // AND A FRAME WITH NO ROOM EVEN FOR THE FOLD NEVER RUNS PAST ITS OWN EDGE.
+ for _, width := range []int{8, 12, 16, 24} {
+ if got := ansi.StringWidth(plain(a.navLine(width, a.pal))); got > width {
+ t.Fatalf("at %d columns the nav is %d cells wide and runs past the frame", width, got)
}
}
- // AND A COUNT NEVER APPEARS ON A BAR THAT CARRIED EVERYTHING. A `+0` beside
- // four words would be furniture, and furniture is what people stop seeing —
- // and the three places reached by command are not a count the bar owes.
- for _, width := range []int{60, 80, 120, 160} {
- if bar := plain(a.placeTabBar(width, false, a.pal)); strings.Contains(bar, "+") {
- t.Fatalf("at %d columns every place is on the bar and it still counts something:\n\t%q", width, bar)
+ // AND A NAV THAT CARRIED EVERYTHING FOLDS NOTHING.
+ for _, width := range []int{80, 120, 160} {
+ if navPlaces(a, width, false); a.navMore.span.pressable() {
+ t.Fatalf("at %d columns every place is on the row and it still folds %v", width, a.navMore.folded)
}
}
}
@@ -313,59 +304,67 @@ func pulseLab(t *testing.T) (*app, time.Time) {
return a, now
}
-// THE TOP LINE GIVES UP THE CLOCK BEFORE THE WORK COUNT.
+// THE TOP LINE GIVES THINGS UP IN THE OWNER'S ORDER.
//
-// This line was drawn ALL OR NOTHING: everything, or the program's name by
-// itself. So a sixty-column window — a split pane, an ssh session from a train —
-// spent twelve of its cells on `thu 12:01am` and then, one segment later, threw
-// the whole right end away and said nothing about the machine at all. It walks a
-// ranked ladder now, and the clock is the first thing off it: the terminal, the
-// window and the wall all say what time it is, and nothing else on this machine
-// says that twelve things have stopped and will not move until somebody looks.
-func TestTheTopLineGivesUpTheClockBeforeTheWorkCount(t *testing.T) {
+// The places and the pulse share the first row now, and the owner ruled the
+// order a narrowing row gives things up in (2026-09-24): the clock first, with
+// the machine's name, then the counts (the moving one before the one that
+// wants you), then the allowance behind the day's figure, then the trailing
+// places fold into `more ▾`, and the day's figure is the last clause standing.
+// Every width is checked: a thing given up early may never be on the row while
+// a thing given up later is gone, and every rung of the ladder is met.
+func TestTheTopLineGivesThingsUpInTheOwnersOrder(t *testing.T) {
a, now := pulseLab(t)
+ a.showPage(pageNone)
+ a.machine = machineFacts{wants: 12, hands: 4, spent: 123.45, ceiling: 500}
+ a.host = "spark"
clock := pulseClock(now)
- // THE WIDE TIERS DID NOT MOVE. A fix for sixty columns that cost a hundred
- // and sixty a segment would be a fix that made the common case worse.
- for _, width := range []int{80, 120, 160} {
- line := plain(a.pulseLine(width, a.pal, pulseWhole))
- for _, want := range []string{product, "12 want you", "4 moving", "$123.45 / " + railFigure(500), clock} {
- if !strings.Contains(line, want) {
- t.Fatalf("at %d columns the top line drew\n\t%q\nand lost %q; there is room for all of it:\n\t%q",
- width, line, want, " "+product+" 12 want you · 4 moving · $123.45 / "+railFigure(500)+" · "+clock)
+ type state struct{ clock, host, hands, wants, allowance, unfolded, spend bool }
+ seen := map[state]bool{}
+ for width := 220; width >= 30; width-- {
+ line := plain(a.navLine(width, a.pal))
+ if got := ansi.StringWidth(line); got > width {
+ t.Fatalf("at %d columns the top line is %d cells wide:\n\t%q", width, got, line)
+ }
+ if !strings.Contains(line, product) || !strings.Contains(line, " chats ") {
+ t.Fatalf("at %d columns the top line lost the wordmark or the lit place:\n\t%q", width, line)
+ }
+ s := state{
+ clock: strings.Contains(line, clock), host: strings.Contains(line, "on spark"),
+ hands: strings.Contains(line, "4 moving"), wants: strings.Contains(line, "12 want you"),
+ allowance: strings.Contains(line, "/ "+railFigure(500)), unfolded: !a.navMore.span.pressable(),
+ spend: strings.Contains(line, "$123.45"),
+ }
+ order := []bool{s.clock, s.host, s.hands, s.wants, s.allowance, s.unfolded, s.spend}
+ for i := range order {
+ for j := i + 1; j < len(order); j++ {
+ if order[i] && !order[j] {
+ t.Fatalf("at %d columns the top line gave up rung %d before rung %d:\n\t%q", width, j, i, line)
+ }
}
}
- }
- // AND THE LADDER, RUNG BY RUNG. Each width is the widest one at which the
- // rung below is the honest answer, and each expectation is a WHOLE clause
- // fewer than the one above it — never a clause with its end sliced off.
- for _, one := range []struct {
- width int
- want string
- }{
- {160, " >● " + product + " 12 want you · 4 moving · $123.45 / " + railFigure(500) + " · " + clock},
- {60, " >● " + product + " 12 want you · 4 moving · $123.45 / " + railFigure(500)},
- {47, " >● " + product + " 12 want you · 4 moving · $123.45"},
- {40, " >● " + product + " 12 want you · 4 moving"},
- {30, " >● " + product + " 12 want you"},
- {16, " " + product},
+ seen[s] = true
+ }
+ for _, want := range []state{
+ {true, true, true, true, true, true, true},
+ {false, true, true, true, true, true, true},
+ {false, false, true, true, true, true, true},
+ {false, false, false, true, true, true, true},
+ {false, false, false, false, true, true, true},
+ {false, false, false, false, false, true, true},
+ {false, false, false, false, false, false, true},
+ {false, false, false, false, false, false, false},
} {
- line := plain(a.pulseLine(one.width, a.pal, pulseWhole))
- if squash(line) != squash(one.want) {
- t.Fatalf("at %d columns the top line drew\n\t%q\nand it should have dropped whole segments by rank:\n\t%q",
- one.width, line, one.want)
- }
- if got := ansi.StringWidth(line); got > one.width {
- t.Fatalf("at %d columns the top line is %d cells wide and runs past the frame:\n\t%q", one.width, got, line)
+ if !seen[want] {
+ t.Fatalf("no width drew the rung %+v; the ladder skipped a step", want)
}
}
// AND THE CLOCK IS STILL THE ONE SEGMENT A QUIET MORNING KEEPS. It is the
- // lowest-ranked thing on the line and it is never EMPTY, which are two
- // different laws: a machine with nothing stopped, nothing moving and nothing
- // spent draws the name and the time.
- a.machine = machineFacts{}
- if line := plain(a.pulseLine(80, a.pal, pulseWhole)); !strings.Contains(line, clock) {
- t.Fatalf("over a quiet morning the top line drew\n\t%q\nand it should be the name and the time:\n\t%q", line, " "+product+" "+clock)
+ // lowest-ranked thing on the line and it is never EMPTY: a machine with
+ // nothing stopped, nothing moving and nothing spent draws the time.
+ a.machine, a.host = machineFacts{}, ""
+ if line := plain(a.navLine(120, a.pal)); !strings.HasSuffix(strings.TrimRight(line, " "), clock) {
+ t.Fatalf("over a quiet morning the top line drew\n\t%q\nand it should end in the time", line)
}
}
@@ -377,8 +376,10 @@ func TestTheTopLineGivesUpTheClockBeforeTheWorkCount(t *testing.T) {
// no allowance with an empty numerator in front of it, no bare `$`.
func TestANarrowTopLineNeverSaysTheDayCostNothing(t *testing.T) {
a, _ := pulseLab(t)
+ a.showPage(pageNone)
+ a.machine = machineFacts{wants: 12, hands: 4, spent: 123.45, ceiling: 500}
for width := 12; width <= 160; width++ {
- line := plain(a.pulseLine(width, a.pal, pulseWhole))
+ line := plain(a.navLine(width, a.pal))
switch {
case strings.Contains(line, "$0.00"):
t.Fatalf("at %d columns the top line drew\n\t%q\nover a day that spent $123.45; a dropped segment may not become a zero", width, line)
diff --git a/internal/tui3/navmore.go b/internal/tui3/navmore.go
new file mode 100644
index 0000000000..5fdc60e13d
--- /dev/null
+++ b/internal/tui3/navmore.go
@@ -0,0 +1,209 @@
+package tui3
+
+import (
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// ── THE NAV'S FOLD: `more ▾` AND THE PLACES BEHIND IT ───────────────────────
+//
+// A row too narrow for every place folds the trailing ones into `more ▾`
+// (topnav.go), and a press on it hangs this menu under it, listing exactly the
+// places it folded, each with the key that reaches it:
+//
+// ╭──────────────────╮
+// │ settings alt+6 │
+// │ standing alt+7 │
+// ╰──────────────────╯
+//
+// It is modal as every menu here is (teammenu.go): while it is up it has the
+// keyboard, `↑` `↓` walk it, `enter` goes to the place under the cursor and
+// `esc` puts it away. A press on a row goes there; a press anywhere off it,
+// `more ▾` included, only puts it away. IT NEVER MOVES FOCUS ANYWHERE ELSE:
+// putting it away leaves the cursor, the draft and the page exactly as they
+// were when it opened.
+
+// navMore is the fold's state: where its word was drawn and what it folded,
+// whether its menu is up, the row the keyboard is on and the row the pointer
+// is on, and where the last frame drew the menu, in frame cells.
+type navMore struct {
+ // span is where `more ▾` was drawn on the nav's row, empty with nothing
+ // folded; folded is the places behind it, in the bar's order.
+ span hudSpan
+ folded []page
+ // hot is the pointer resting on `more ▾` itself.
+ hot bool
+ on bool
+ cursor int
+ // hover is the row the pointer is on, -1 for none.
+ hover int
+ card wallRect
+ hits []wallHit
+}
+
+// navMoreFootWords is the hint line while the menu is up: its own keys, since
+// they are the only keys that do anything.
+const navMoreFootWords = "↑↓ choose · enter go · esc close"
+
+func (a *app) openNavMore() {
+ if len(a.navMore.folded) == 0 {
+ return
+ }
+ a.navMore.on, a.navMore.cursor, a.navMore.hover = true, 0, -1
+ a.touch()
+}
+
+func (a *app) closeNavMore() {
+ a.navMore.on, a.navMore.hover, a.navMore.card, a.navMore.hits = false, -1, wallRect{}, nil
+ a.touch()
+}
+
+// navMoreHot records the pointer on or off `more ▾`, repainting only when
+// that is news.
+func (a *app) navMoreHot(on bool) {
+ if a.navMore.hot == on {
+ return
+ }
+ a.navMore.hot = on
+ a.touch()
+}
+
+// navMoreGo is one place chosen from the menu.
+func (a *app) navMoreGo(id page) tea.Cmd {
+ a.closeNavMore()
+ if a.headCovers() {
+ a.headUncover()
+ }
+ if id == a.navLit() {
+ return nil
+ }
+ return a.showPage(id)
+}
+
+// navMoreKey is a key while the menu is up.
+func (a *app) navMoreKey(msg tea.KeyPressMsg) tea.Cmd {
+ m := &a.navMore
+ switch msg.String() {
+ case "esc":
+ a.closeNavMore()
+ case "up", "k":
+ m.cursor = max(m.cursor-1, 0)
+ a.touch()
+ case "down", "j":
+ m.cursor = min(m.cursor+1, len(m.folded)-1)
+ a.touch()
+ case "enter", "space":
+ if m.cursor >= 0 && m.cursor < len(m.folded) {
+ return a.navMoreGo(m.folded[m.cursor])
+ }
+ }
+ return nil
+}
+
+// navMoreHitAt is the menu's row under the pointer on the last frame.
+func (a *app) navMoreHitAt(x, y int) (wallHit, bool) {
+ for _, hit := range a.navMore.hits {
+ if x >= hit.x0 && x < hit.x1 && y >= hit.y0 && y < hit.y1 {
+ return hit, true
+ }
+ }
+ return wallHit{}, false
+}
+
+// navMorePress answers a left press while the menu is up.
+func (a *app) navMorePress(x, y int) tea.Cmd {
+ if hit, ok := a.navMoreHitAt(x, y); ok && hit.arg < len(a.navMore.folded) {
+ return a.navMoreGo(a.navMore.folded[hit.arg])
+ }
+ if !a.navMore.card.holds(x, y) {
+ a.closeNavMore()
+ }
+ return nil
+}
+
+// navMoreMotion lights the row under the pointer.
+func (a *app) navMoreMotion(x, y int) {
+ hit, ok := a.navMoreHitAt(x, y)
+ under := -1
+ if ok {
+ under = hit.arg
+ }
+ if under != a.navMore.hover {
+ a.navMore.hover = under
+ a.touch()
+ }
+}
+
+// navMoreOver lays the menu over a finished frame, hung from `more ▾`, and
+// writes down where its rows landed. With the menu down it hands the frame
+// back as it was given.
+func (a *app) navMoreOver(frame string) string {
+ if !a.navMore.on {
+ return frame
+ }
+ // A WIDER WINDOW UNFOLDS THE PLACES AND THE MENU GOES WITH THEM: a menu
+ // of nothing, or of words the row now shows, is not one to leave up.
+ if len(a.navMore.folded) == 0 || !a.navMore.span.pressable() {
+ a.navMore.on, a.navMore.card, a.navMore.hits = false, wallRect{}, nil
+ return frame
+ }
+ a.navMore.cursor = min(max(a.navMore.cursor, 0), len(a.navMore.folded)-1)
+ width, height := a.size()
+ card := a.navMoreCard(width, height)
+ a.navMore.hits = card.hits
+ if len(card.rows) == 0 {
+ a.navMore.card = wallRect{}
+ return frame
+ }
+ a.navMore.card = wallRect{card.x, card.y, card.x + card.w, card.y + len(card.rows)}
+ rows := strings.Split(frame, "\n")
+ for len(rows) < height {
+ rows = append(rows, "")
+ }
+ for dy, cr := range card.rows {
+ if y := card.y + dy; y >= 0 && y < len(rows) {
+ rows[y] = wallSplice(rows[y], cr, card.x, width)
+ }
+ }
+ return strings.Join(rows[:height], "\n")
+}
+
+// navMoreCard is the menu as a card: one row per folded place, its word and
+// its key, the row under the keyboard's cursor or the pointer on the cursor
+// ground. It is hung on the row under the nav, its left edge under the fold's
+// word and kept a cell inside the frame, and not drawn on a frame too small
+// to hold it whole.
+func (a *app) navMoreCard(width, height int) wallCard {
+ pal := a.pal
+ words, keys := 0, 0
+ for _, id := range a.navMore.folded {
+ words = max(words, ansi.StringWidth(id.word()))
+ keys = max(keys, ansi.StringWidth(a.chords.say(placeChord(id))))
+ }
+ const padX = 1
+ inner := 1 + words + 2 + keys + 1
+ w := inner + 2 + 2*padX
+ top := navRow + 1
+ if w > width-2 || top+len(a.navMore.folded)+2 > height {
+ return wallCard{}
+ }
+ x := min(max(a.navMore.span.from, 1), width-1-w)
+ lines := make([]wallCardLine, 0, len(a.navMore.folded))
+ for i, id := range a.navMore.folded {
+ word := id.word()
+ key := a.chords.say(placeChord(id))
+ ink := pal.ink
+ if id == a.navLit() {
+ ink = func(s string) string { return pal.bold(pal.accent(s)) }
+ }
+ s := " " + ink(word) + strings.Repeat(" ", words-ansi.StringWidth(word)+2) + pal.dim(key) + " "
+ lit := a.navMore.hover == i || (a.navMore.hover < 0 && a.navMore.cursor == i)
+ lines = append(lines, wallCardLine{
+ s: wallPopRowPaint(pal, s, inner, lit),
+ hits: []wallHit{{x0: 0, y0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: i}},
+ })
+ }
+ return wallCardBuild(pal, "", lines, x, top, w, padX, 0)
+}
diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go
index 1b104436b6..fbc9dc8ecf 100644
--- a/internal/tui3/notice.go
+++ b/internal/tui3/notice.go
@@ -1264,11 +1264,11 @@ func (a *app) chatTipReady() string {
if a.notices.hidden[slotHint] || a.at(pageHome) {
return ""
}
- // THE CLOSED TASK COLUMN IS NOT ONE OF THOSE STATES. `ctrl+g tasks` is what
- // the keys row says at rest once the column is put away ([railBackHint]),
+ // THE CLOSED SIDE COLUMN IS NOT ONE OF THOSE STATES. `alt+l tasks` is what
+ // the keys row says at rest once the column is put away ([app.sideBackHint]),
// and a tip that went quiet whenever it did was a tip that only ever
// appeared with the sidebar open (the owner's report, 2026-09-24).
- if word := a.hintWord(); a.questionRoomOpen() || (word != "" && word != railBackHint) ||
+ if word := a.hintWord(); a.questionRoomOpen() || (word != "" && word != a.sideBackHint()) ||
(a.chordLost && a.chords.meta == chordMetaWord) {
return ""
}
diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go
index 24b657b2e0..72ee472760 100644
--- a/internal/tui3/onboarding.go
+++ b/internal/tui3/onboarding.go
@@ -1658,7 +1658,7 @@ func (a *app) setupControlsKeys(width int) string {
// THE WAY OUT IS SECOND AND NOT LAST. At forty columns a legend has room
// for about two clauses, and of everything a chooser could teach, "this
// key leaves without choosing" is the one a person cannot guess.
- parts = []string{"enter takes it", "esc cancels", "↑↓ choose"}
+ parts = []string{"enter takes it", "esc cancel", "↑↓ choose"}
if s.modelOpen {
parts = append(parts, "type to narrow")
}
diff --git a/internal/tui3/onetopbar_test.go b/internal/tui3/onetopbar_test.go
index 96a57fa59d..d47682dbee 100644
--- a/internal/tui3/onetopbar_test.go
+++ b/internal/tui3/onetopbar_test.go
@@ -5,12 +5,12 @@ import (
"testing"
)
-// barGeometry reads one top-bar row: the row it is on, the column its first
-// word starts in, and the blank cells between that word and the next item.
+// barGeometry reads one head row: the row it is on, the column its first word
+// starts in, and the blank cells between that word and the next item.
func barGeometry(frame, first string) (y, x, gap int) {
for i, r := range strings.Split(plain(frame), "\n") {
at := strings.Index(r, first)
- if at < 0 || strings.TrimSpace(r[:at]) != "" {
+ if at < 0 {
continue
}
rest := r[at+len(first):]
@@ -19,31 +19,53 @@ func barGeometry(frame, first string) (y, x, gap int) {
return -1, -1, -1
}
-// ONE TOP BAR. The chat strip and the places bar are the same row: the first
-// word on the same line and in the same column, the same air between two
-// items, and the current item on the same ground, at every width a person uses.
-func TestOneTopBarPlacesAndChatsShareGeometry(t *testing.T) {
+// ONE TOP NAV. A conversation and a place draw the same nav: the places on the
+// wordmark's row, in the same cells, with the same air between two words. The
+// strip of chats is the next row only in a conversation. On a place that row
+// is the rule. The one thing that differs on row zero is which word is lit:
+// `chats` over a conversation, the place over a place.
+func TestOneTopNavOnAChatAndOnAPlace(t *testing.T) {
for _, width := range []int{80, 110, 160} {
a, _, _, _ := trafficApp(t)
a.open = func(workspace, transcript string) (Conversation, error) { return Conversation{}, nil }
a.welcome.open = false
- a.traffic.hidden = true
+ a.railAway = true
a.width, a.height = width, 24
a.touch()
chat, _, _ := a.frame()
- cy, cx, cgap := barGeometry(chat, "home")
- places := placeApp(t)
- places.width = width
- frame, _, _ := places.frame()
- py, px, pgap := barGeometry(frame, "home")
- if cy < 0 || py < 0 {
- t.Fatalf("at %d a bar is missing (chat row %d, places row %d)", width, cy, py)
- }
- if cy != py || py != places.tabRow || cx != px || cx != headLabelAt+1 || cgap != pgap || pgap != 2*len(tabPad)+placeBarGap {
- t.Fatalf("at %d the bars differ: chat row %d x %d gap %d, places row %d x %d gap %d", width, cy, cx, cgap, py, px, pgap)
- }
- if !strings.Contains(frame, activeGround(places.pal, tabPad+"home"+tabPad)) {
- t.Fatalf("at %d the place you stand in is not on the chat strip's current ground", width)
+ cy, cx, cgap := barGeometry(chat, " home ")
+ chatSpans := append([]placeTabSpan(nil), a.tabs...)
+ walkTo(t, a, pageSpend)
+ frame, _, _ := a.frame()
+ py, px, pgap := barGeometry(frame, " home ")
+ if cy != navRow || py != navRow || a.tabRow != navRow {
+ t.Fatalf("at %d the nav is on row %d in the chat and %d on the place", width, cy, py)
+ }
+ if cx != px || cgap != pgap || pgap != len(tabPad) {
+ t.Fatalf("at %d the navs differ: chat x %d gap %d, place x %d gap %d", width, cx, cgap, px, pgap)
+ }
+ if len(chatSpans) != len(a.tabs) {
+ t.Fatalf("at %d the chat's nav has %d buttons and the place's %d", width, len(chatSpans), len(a.tabs))
+ }
+ for i := range chatSpans {
+ if chatSpans[i] != a.tabs[i] {
+ t.Fatalf("at %d the button %d moved between the chat and the place: %+v, %+v", width, i, chatSpans[i], a.tabs[i])
+ }
+ }
+ // THE STRIP IS UNDER THE NAV IN THE CHAT, and absent on the place.
+ if sy, _, _ := barGeometry(chat, "harbor ▾"); sy != tabStripRow {
+ t.Fatalf("at %d the chat's strip is on row %d", width, sy)
+ }
+ if py2, _, _ := barGeometry(frame, "harbor ▾"); py2 >= 0 {
+ t.Fatalf("at %d the place drew the strip on row %d", width, py2)
+ }
+ placeRows := strings.Split(plain(frame), "\n")
+ if len(placeRows) <= placeHeadRows || !strings.HasPrefix(placeRows[1], "─") || strings.TrimSpace(placeRows[2]) != "" {
+ t.Fatalf("at %d the place's head is not the nav, the rule and a blank", width)
+ }
+ lit := a.pal.onPlaces()
+ if !strings.Contains(frame, lit.bold(lit.accent(tabPad+"spend"+tabPad))) {
+ t.Fatalf("at %d the place you stand in is not lit in the accent", width)
}
if a.tabActivePaint(" x ") != activeGround(a.pal, " x ") {
t.Fatal("the chat strip's tab in front has a ground of its own")
diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go
index 9d2ac42435..9400c8619e 100644
--- a/internal/tui3/pages.go
+++ b/internal/tui3/pages.go
@@ -49,6 +49,10 @@ const (
pageSpend
pageSearch
pageSettings
+ // pageTeams is the team-level view (place_teams.go). It is appended rather
+ // than put after pageHome because the ids are only names: the order a
+ // person meets the places in is [placeOrder]'s, and nothing stores an id.
+ pageTeams
// pageChats is the bar's way back to the conversations (place_chats.go):
// never stood in, only walked out through.
pageChats
@@ -246,6 +250,11 @@ type place interface {
// hint is the line under the composer: what the row under the cursor can be
// asked for, and how to leave.
hint(a *app) string
+ // about is what this place holds, in the few words the hint line has room
+ // for while the pointer rests on the place's word on the nav (topnav.go's
+ // [app.headHint]): `the teams you hand work to`. Like `hint` it has no
+ // default, so a place added later says what it is for or does not build.
+ about() string
// changed is the tab's count: how many things in here have moved since the
// person last looked at this place.
changed(a *app, since time.Time) int
@@ -369,18 +378,20 @@ func (placeBase) caretRow(a *app, width int, rows []placeRow) (int, int, bool) {
var placeRegistry = map[page]place{}
// placeOrder is the whole set, in the one order that matters: `alt+1` through
-// `alt+7`, and — for the first [placeBarPlaces] of them — left to right along
+// `alt+9`, and, for the first [placeBarPlaces] of them, left to right along
// the tab bar and round the circle `tab` walks.
//
// THE BAR IS FOUR PLACES AND HOME IS THEIR SUMMARY (DESIGN.md's law 10). What
-// wants you and what is running (home), the work itself (tasks), what it cost
-// (spend), and how this machine is set (settings). Standing, memory and search
-// come after them: still rooms, still reached by `/standing`, `/memory` and
-// `/search`, by the typed box's place offers, by `alt+5`…`alt+7` and by the
-// map — but not drawn on a bar a person reads a hundred times a day, until they
-// are the rooms a person walks into a hundred times a day.
-//
-// THE THREE KEEP A DIGIT EACH so a hand that learned `alt+5` finds a room there
+// wants you and what is running (home), the work itself (sessions, the tasks
+// place), what it cost (spend), and how this machine is set (settings), with
+// teams and the way back to the chats between home and the work. Standing,
+// memory and search come after them: still rooms, still reached by
+// `/standing`, `/memory` and `/search`, by the typed box's place offers, by
+// `alt+7`…`alt+9` and by the map, but not drawn on a bar a person reads a
+// hundred times a day, until they are the rooms a person walks into a hundred
+// times a day.
+//
+// THE THREE KEEP A DIGIT EACH so a hand that learned `alt+7` finds a room there
// rather than a key that does nothing.
//
// IT IS A LIST HERE AND NOT AN `init` ORDER. Go runs a package's `init`s in
@@ -389,14 +400,15 @@ var placeRegistry = map[page]place{}
// `place_home.go` sorts after `place_tasks.go` would silently reorder the bar
// and every number on it.
//
-// AND THE CHATS ARE SECOND, right after home (place_chats.go): the room a
-// person came from, and goes back to more than to any other. A place added to
-// the bar later, teams, slots in between home and chats as one more row here.
-var placeOrder = []page{pageHome, pageChats, pageTasks, pageSpend, pageSettings, pageStanding, pageMemory, pageSearch}
+// TEAMS IS SECOND AND THE CHATS THIRD (the owner's order, 2026-09-24). Teams
+// is where a person runs the work they handed off (place_teams.go, DESIGN.md
+// section 8.4), so it is read as often as home is; the chats (place_chats.go)
+// are the room a person came from, and goes back to more than to any other.
+var placeOrder = []page{pageHome, pageTeams, pageChats, pageTasks, pageSpend, pageSettings, pageStanding, pageMemory, pageSearch}
// placeBarPlaces is how many of [placeOrder] the tab bar draws: the four a day
-// is read through, and the way back to the chats.
-const placeBarPlaces = 5
+// is read through, teams beside home, and the way back to the chats.
+const placeBarPlaces = 6
// barPages is the places the bar draws while a person stands at `here`: the
// first [placeBarPlaces], and the room they are standing in when it is one of
@@ -580,113 +592,18 @@ func (a *app) placeCount(id page) int {
return 0
}
-// ── the tab bar ─────────────────────────────────────────────────────────────
-
-// placeTabBar is the second row of every place: the four words ([barPages]), the
-// one you are standing in wearing the band, and a number beside any place that
-// has something new in it.
-//
-// ONE TOP BAR (ruled 2026-09-24). The places bar and the chat strip are one row
-// in one place: the same y ([placeTabRow], the strip's row), the first word at
-// the same x ([placeBarLead] is the strip's [headLabelAt]), the same air between
-// two items ([placeBarGap], the strip's gap after Home and after the chip), and
-// the same ground on the current item ([activeGround], the strip's tab in
-// front). The strip's geometry was kept and this bar moved to it: the strip is
-// the row a person spends the day on, its grounded chips and close marks need
-// the two cells of air to read as separate things, and five short words have
-// the room to spare. [TestOneTopBarPlacesAndChatsShareGeometry] pins it.
-//
-// IT IS [sheetTabBar] WITH THE TITLES PASSED IN, and it is drawn with that
-// function's chip, [tabPad], for the reason that
-// function's comment already gives: "this panel IS a tab bar — the same object
-// the task strip is, drawn the same way, so that 'which page am I on' is one
-// visual question across the app rather than two". The settings panel keeps its
-// own inner bar under this one, and the two are told apart by what they are
-// made of rather than by a decoration: this one is the places, that one is
-// settings' own sections.
-//
-// ── THE WIDTH LADDER ────────────────────────────────────────────────────────
-//
-// A bar that is cut in half is a bar that lies about how many places there are,
-// so it gives up words in a stated order rather than being trimmed:
-//
-// 1. every word, every count, with the bar's own air between the chips —
-// while they fit;
-// 2. EVERY WORD AGAIN, WITH THE AIR GIVEN UP. When the bar was seven words,
-// they and the padding each chip carries were fifty-seven cells and the air
-// between them six more, so a sixty-column terminal — a split pane, an ssh
-// session from a train, a phone — overshot by three and fell all the way
-// past the middle rung to the single word `home`, because on a quiet machine
-// no place wears a count. The words are what this row is FOR and the space
-// between them is not, so the space is what goes first.
-// 3. as many words as fit, in the bar's own order, always carrying the place
-// you are standing in and any place wearing a count, and ending with a dim
-// count of the places that did not fit ([barMoreWord]).
-//
-// THE BAR IS THE SIGN AND THE FOOT IS THE ROUTE. A row this narrow cannot say
-// `tab next place` as well as the words — at rung 3 there are not seven cells
-// spare for it — so what the bar owes a person is that the other rooms EXIST,
-// and the key that reaches them is on the foot of every place
-// ([placeHintTail]), which [hintFit] protects to the last cell there is. A bar
-// collapsed to the word `home` said neither of those things, and every other
-// place was undiscoverable on exactly the tier where a person is least able to
-// go looking for them.
-//
-// `numbered` is the map ([app.mapShowing]): every chip grows the digit that
-// jumps to it, in the cells the words were already in, and the places off the
-// bar are drawn after them with theirs — nothing moves that a person has to
-// re-find when the map goes away, and the three digits the bar does not show
-// are shown where the keys are.
-func (a *app) placeTabBar(width int, numbered bool, pal palette) string {
- every := func(page) bool { return true }
- for gap := placeBarGap; gap >= 0; gap-- {
- if full, spans, ok := a.tabBarAt(width, numbered, pal, every, gap, 0); ok {
- a.tabs = spans
- return a.placeBarMachine(full, width, pal)
- }
- }
- keep, elided := a.barWordsAt(width, numbered)
- some, spans, _ := a.tabBarAt(width, numbered, pal, func(id page) bool { return keep[id] }, 0, elided)
- a.tabs = spans
- return a.placeBarMachine(some, width, pal)
-}
-
-// barMoreWord is the count of places a narrow bar could not carry, in the
-// spellings [rowfit.go]'s law 2 asks a fact to degrade through: `▸ 3 more` while
-// there are cells for it, and `▸ 3` when there are not.
-//
-// IT IS THE SURFACE'S ONE FOLD SENTENCE ([foldSpellings]) AND NO LONGER A `+`.
-// This row and the command menu's own tail mean the same thing — a navigation
-// list has more items than fit — and they were two writers with two spellings:
-// `+3 more` here against `▸ 3 more` there, so a person could not tell whether
-// `+3` was a count, a badge or a door. The mark is the half that says which, and
-// it is on every rung of the ladder: the word `more` gives way before `▸` does.
-//
-// IT IS A SIGN AND NOT A DOOR, and that is decided rather than unfinished: it
-// opens nothing, wears no cursor and claims no span, exactly as the machine's
-// name at the other end of this row does ([placeBarMachine]). A chip that
-// carried a press would have to pick one of the places it stands for, and the
-// key that reaches them all in order is `tab`.
-func barMoreWord(n, room int) string {
- if n <= 0 {
- return ""
- }
- for _, say := range foldSpellings(n, "") {
- if tabPadCols+ansi.StringWidth(say) <= room {
- return say
- }
- }
- // AND A FRAME WITH NO ROOM EVEN FOR `+6` SAYS NOTHING, rather than running
- // past its own edge. A count that overflowed the row would be this ladder
- // committing the fault it exists to prevent.
- return ""
-}
+// ── the places' words on the nav ────────────────────────────────────────────
+//
+// THE PLACES ARE DRAWN ON THE HEAD'S FIRST ROW, after the wordmark, on every
+// page (topnav.go). They used to be a bar of their own on the second row of a
+// place and nowhere else, taking turns with the chat strip; what is left here
+// is what the nav reads about each place.
-// barChipWord is the word one place's chip carries: its own word, the digit the
-// map grows in front of it, and the count behind it. It is factored out of
-// [app.tabBarAt] so the ladder can MEASURE a chip without painting one, and so
-// the measurement and the paint can never come to disagree about how wide a
-// word is.
+// barChipWord is the word one place's button carries: its own word, the digit
+// the map grows in front of it, and the count behind it. It is factored out so
+// the nav's ladder can MEASURE a button without painting one, and so the
+// measurement and the paint can never come to disagree about how wide a word
+// is.
func (a *app) barChipWord(id page, numbered bool) string {
word := id.word()
if numbered {
@@ -698,102 +615,13 @@ func (a *app) barChipWord(id page, numbered bool) string {
return word
}
-// barWordsAt chooses the words a bar too narrow for all seven carries, and says
-// how many it had to leave off.
-//
-// THE MANDATORY HALF FIRST: the place you are standing in and the word under the
-// cursor may never go ([app.barKeeps] holds that argument), and neither may a
-// place wearing a count, because a number is this row saying something moved in
-// a room you are not standing in.
-//
-// THEN THE ROW IS FILLED IN THE BAR'S OWN ORDER AND STOPS AT THE FIRST WORD
-// THAT WILL NOT FIT — [rowfit.go]'s law 3 said about words instead of facts. A
-// fill that skipped `standing` because `spend` was shorter would draw a
-// different four places at every width, and `alt+1` … `alt+7` name positions
-// that never move; a prefix plus your own word is a reading a person can learn.
-//
-// The count's own cells are reserved out of the fill, measured against the
-// longest spelling this row could end up drawing, because a bar that spent its
-// last cells on one more word and then had no room to say two others exist
-// would be the collapse this ladder is here to prevent, one word later.
-func (a *app) barWordsAt(width int, numbered bool) (map[page]bool, int) {
- shown := barPages(a.page, numbered)
- cost := func(id page) int { return ansi.StringWidth(a.barChipWord(id, numbered)) + tabPadCols }
- keep := make(map[page]bool, len(shown))
- spent := placeBarLead
- for _, id := range shown {
- if a.barKeeps(id) || a.placeCount(id) > 0 {
- keep[id] = true
- spent += cost(id)
- }
- }
- folds := foldSpellings(len(shown), "")
- reserve := tabPadCols + ansi.StringWidth(folds[len(folds)-1])
- for _, id := range shown {
- if keep[id] {
- continue
- }
- if spent+cost(id)+reserve > width {
- break
- }
- keep[id] = true
- spent += cost(id)
- }
- elided := 0
- for _, id := range shown {
- if !keep[id] {
- elided++
- }
- }
- return keep, elided
-}
-
-// placeMachineLead is the word in front of the machine's name at the right end
-// of the bar. It is there so that a bare `spark` in the row the seven places are
-// drawn in cannot be read as an eighth place.
+// placeMachineLead is the word in front of the machine's name on the nav's far
+// end. It is there so that a bare `spark` on the row the places are drawn in
+// cannot be read as one more place.
const placeMachineLead = "on "
-// placeBarMachine puts the MACHINE THESE PLACES ARE ABOUT at the right end of
-// the tab bar, and puts nothing there at all on a local session.
-//
-// THE PLACES FOLLOW THE SESSION'S MACHINE NOW, AND A ROOM THAT MOVED WITHOUT
-// SAYING SO WOULD BE THE SAME FAULT WALKED BACKWARDS. Home used to draw one
-// sentence saying its rows belonged to the wrong machine; it draws the right
-// machine's rows instead ([app.readWorld]) — so the thing a person cannot see
-// any more is WHOSE work they are reading, and the fix is a name rather than a
-// sentence, because it is true on every frame of every place rather than in one
-// state of one of them.
-//
-// IT IS [app.host] AND NOT A SECOND SPELLING OF IT. The status line's place
-// segment writes `spark:app`, /status writes `spark:/srv/code/app`, and the
-// legend under the input writes `spark · porting the parser` — three renderings
-// of one field, which host.go's header states as the law that the connection is
-// shown as the place and nowhere else. This is the fourth, and it is the machine
-// alone because a place is a listing of a whole disk rather than of one
-// workspace.
-//
-// AND IT DISAPPEARS COMPLETELY ON A LOCAL SESSION, which is the test host.go
-// holds every indicator to: it is invisible when there is nothing to say. It
-// also gives up its cells before the bar gives up a word — the places are what
-// the row is for, and a name that pushed `search` off the end would be telling
-// somebody about a machine instead of about their own rooms.
-func (a *app) placeBarMachine(bar string, width int, pal palette) string {
- name := strings.TrimSpace(a.host)
- if name == "" {
- return bar
- }
- word := placeMachineLead + name
- used, room := ansi.StringWidth(bar), ansi.StringWidth(word)
- // placeBarLead's worth of air at each end, and placeBarGap between the last
- // chip and the name, so the row breathes the way every other row of this bar does.
- if used+placeBarGap+room+placeBarLead > width {
- return bar
- }
- return bar + strings.Repeat(" ", width-used-room-placeBarLead) + pal.dim(word)
-}
-
-// barKeeps is the word the ladder may never give up: the place you are standing
-// in, and — while the cursor is on the bar — the word the cursor is on.
+// barKeeps is the word the nav's ladder may never fold: the place you are
+// standing in, and, while the cursor is on the bar, the word the cursor is on.
//
// A CURSOR ON A WORD THE LADDER DROPPED WOULD BE A CURSOR NOBODY CAN SEE, which
// is SCREEN 3a's clause said about a row rather than a key: nothing on this
@@ -804,18 +632,11 @@ func (a *app) barKeeps(id page) bool {
return id == a.page || (a.bar.on && id == a.bar.at)
}
-// placeBarLead is the cell the bar's first chip starts in, one cell before its
-// first word, so the word lands where the chat strip's first word does.
-const placeBarLead = headLabelAt
-
-// placeBarGap is the air between two chips on the bar: the chat strip's.
-const placeBarGap = 2
-
-// placeTabSpan is where one place's CHIP sits on the bar, so the draw and the
+// placeTabSpan is where one place's BUTTON sits on the nav, so the draw and the
// press agree about it. It is the settings panel's [tabSpan] with the place it
-// belongs to carried on it — the bar gives up words as the frame narrows
-// (the ladder above), so a span computed from the list of places rather than
-// from the bar that was actually painted would open whichever room happened to
+// belongs to carried on it: the nav folds words as the frame narrows
+// (topnav.go's ladder), so a span computed from the list of places rather than
+// from the row that was actually painted would open whichever room happened to
// sit at that position on a wider terminal.
//
// It covers the chip's padding as well as its word, for the reason [tabSpan]
@@ -826,75 +647,6 @@ type placeTabSpan struct {
from, to int
}
-// tabBarAt draws the bar over the places `keep` admits, says where each chip
-// landed, and says whether it fit.
-//
-// `gap` is the air between two chips, which the ladder above gives up before it
-// gives up a word, and `elided` is how many places are not on this bar at all —
-// drawn as [barMoreWord] at the end of the row, in the cells that are left.
-func (a *app) tabBarAt(width int, numbered bool, pal palette, keep func(page) bool, gap, elided int) (string, []placeTabSpan, bool) {
- line, plain := strings.Repeat(" ", placeBarLead), strings.Repeat(" ", placeBarLead)
- shown := barPages(a.page, numbered)
- spans := make([]placeTabSpan, 0, len(shown))
- at, first := placeBarLead, true
- for _, id := range shown {
- if !keep(id) {
- continue
- }
- if !first {
- line += strings.Repeat(" ", gap)
- plain += strings.Repeat(" ", gap)
- at += gap
- }
- first = false
- // THE MAP GROWS THE NUMBER IN THE CELL THE WORD WAS ALREADY IN
- // (SCREEN 3b). Nothing shifts, nothing pops up, and letting go of the
- // map leaves the bar exactly where the eye left it ([app.barChipWord]).
- word := a.barChipWord(id, numbered)
- chip := tabPad + word + tabPad
- band := ansi.StringWidth(word) + tabPadCols
- switch {
- case a.bar.on && id == a.bar.at:
- // THE CURSOR'S OWN BAND, AND IT REPLACES THE SELECTED MARK RATHER THAN
- // STACKING ON IT. While the cursor is up here the bar is the row a
- // person is standing on, and the question the frame has to answer is
- // "where is my cursor" — not "which room am I in", which the body
- // underneath is already answering with every one of its rows. Two
- // grounds on one word would be the screen saying both at once and
- // neither clearly ([barCursor]).
- line += pal.cursor(pal.bold(pal.ink(chip)), band)
- case id == a.page:
- // THE WORD YOU ARE STANDING IN IS TIER 1, BOLD, AND NOT AN ACCENT.
- // SCREEN 2a's first level is spelled out: "1 · page — bright, bold,
- // one word, only in the tab bar", and the accent on a place is spent
- // on the two live states and on nothing else (styles.go's THE
- // ONE-ACCENT LAW). The band under it is what says "here".
- line += activeGround(pal, chip)
- case id == a.tabHover:
- // AND THE POINTER LIFTS THE WORD AND DOES NOTHING ELSE: the selected
- // word's own ink and weight, with no band under it. A word that grew a
- // ground on hover would look like the room a person was standing in,
- // and a bar with two banded words on it says nothing at all; a word
- // that lifted a tier says "this one is a door", which is the whole of
- // what a pointer resting on it has learned.
- line += pal.bold(pal.ink(chip))
- default:
- line += pal.dim(chip)
- }
- plain += chip
- spans = append(spans, placeTabSpan{id: id, from: at, to: at + ansi.StringWidth(chip)})
- at += ansi.StringWidth(chip)
- }
- // AND THE COUNT OF WHAT IS NOT HERE RIDES THE END OF THE ROW, with no span
- // behind it: it is a sign, and [barMoreWord] says why it is not a door.
- if more := barMoreWord(elided, width-ansi.StringWidth(plain)); more != "" {
- chip := tabPad + more + tabPad
- line += pal.dim(chip)
- plain += chip
- }
- return line, spans, ansi.StringWidth(plain) <= width
-}
-
// ── THE BAR IS A ROW THE CURSOR CAN STAND ON ────────────────────────────────
// barCursor is the tab bar as a ROW, and not only as a set of targets.
@@ -996,7 +748,7 @@ func (a *app) barWalk(back bool) {
// THE CURSOR COMES DOWN EITHER WAY. Pressing the word you are already standing
// in is not a door — going there would close and reopen the room, throwing away
// the filter somebody typed and the row they were on, which is the same law the
-// pointer already keeps ([app.placeTabPress]) — so it simply puts the cursor
+// pointer already keeps ([app.navPress]), so it simply puts the cursor
// back in the body.
func (a *app) barEnter() tea.Cmd {
at := a.bar.at
@@ -1018,7 +770,7 @@ func (a *app) barEnter() tea.Cmd {
// on. Anywhere else `↑` is the walk it has always been, and the place keeps it.
func (a *app) barReach() bool {
pl := a.showing()
- if pl == nil || a.bar.on || a.tabRow < 1 {
+ if pl == nil || a.bar.on || a.tabRow < 0 {
return false
}
// HOME'S COLUMN HAS NO WAY UP ONTO THE BAR (owner, 2026-09-17: "don't let
@@ -1066,9 +818,9 @@ func (a *app) barKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
a.barDrop()
return nil, true
case "up", "ctrl+p":
- // THERE IS NOTHING OVER THE BAR. Row zero is the pulse, which is telemetry
- // and not a control, so `↑` here is a key that has arrived at the top —
- // swallowed rather than falling through into the body it just left.
+ // THERE IS NOTHING OVER THE BAR. It is row zero, the nav itself, so `↑`
+ // here is a key that has arrived at the top, swallowed rather than
+ // falling through into the body it just left.
return nil, true
}
// AND EVERY PRINTABLE CHARACTER GOES WHERE IT ALWAYS GOES, taking the cursor
@@ -1183,10 +935,9 @@ func placeLineHits(hits []placeHit) []int { return placeHitsOf(hits, -1) }
// placeFrame is THE frame. Every place is drawn in it, and the head, the foot
// and the clamp below belong to the router rather than to any place:
//
-// row 0 the pulse — this machine's vital signs (pulse.go)
-// row 1 the tab bar — the seven places, and where you are
-// row 2 a dim rule
-// row 3 blank
+// row 0 the nav: the wordmark, the places, the pulse (topnav.go)
+// row 1 a dim rule
+// row 2 blank
// ... the body — the place's own rows
// ... blank, then a dim rule
// ... the composer, with the scope chip at the right of its box row
@@ -1235,16 +986,10 @@ func placeFrameWithBar(a *app, width, height int,
hits = append(hits, hit)
}
- // THE HEAD IS THE CONVERSATION'S HEAD, drawn by the same function with the
- // bar as its middle row (head.go).
- //
- // THE BAR IS ROW ONE AND THE POINTER IS TOLD SO HERE. A press arrives as a
- // row of the terminal, and the only honest way to know which row the bar
- // ended up on is to record it where it was drawn — the clamp below can cut
- // it off a frame too short for its own contents, and a press resolved
- // against a constant would then open a place for a click on a body row.
- a.tabRow = placeTabRow
- for _, row := range a.headRows(width, a.placeTabBar(width, a.mapShowing, pal), pal) {
+ // THE HEAD IS THE NAV, THE RULE AND A BLANK. The strip is a chat's row and
+ // is not drawn on a place (head.go), so this frame does not lay it out and
+ // does not keep its hit spans.
+ for _, row := range a.headRows(width, "", pal) {
add(row, nil)
}
@@ -1589,11 +1334,11 @@ func placeFrameWithBar(a *app, width, height int,
if len(lines) > height {
removed := len(lines) - height
- // A FRAME TOO SHORT FOR ITS OWN CONTENTS LOSES THE BAR, and the pointer
- // is told that too: -1 is "there is no tab bar on this frame", which is
- // the only answer that cannot turn a press on a body row into a place
- // change.
- a.tabRow = -1
+ // A FRAME TOO SHORT FOR ITS OWN CONTENTS KEEPS THE NAV. A place draws
+ // no strip, so there is no strip row to give back; the targets are
+ // cleared anyway so a stale span from a chat cannot catch the press.
+ a.chatTabHits = nil
+ a.wall.chip, a.wall.door = hudSpan{}, hudSpan{}
keep, keepHits := lines[:1], hits[:1]
lines = append(keep, lines[len(lines)-(height-1):]...)
hits = append(keepHits, hits[len(hits)-(height-1):]...)
@@ -1750,7 +1495,7 @@ const (
// it where the place declares no verbs).
//
// IT SAYS WHAT THE KEY DOES. It read `→ verbs on this row`, which named a
- // CATEGORY on a line where `alt+1…8 go to a place`, `alt+enter send it off as
+ // CATEGORY on a line where `alt+1…9 go to a place`, `alt+enter send it off as
// a task` and `esc close` all name an act — and `verbs` is the machinery's
// word for the strip rather than anybody's word for what pressing `→` gets
// them. The card's own `→ verbs: pause, stop` keeps the noun because the acts
@@ -1759,7 +1504,7 @@ const (
placeMapVerbWords = "→ show what this row can do"
// placeMapWords is the hint line while the map is drawn (SCREEN 3b): the
// chord list, in the cells the hint was already in.
- placeMapWords = "alt+1…8 go to a place · " + placeMapTaskWords + " · " +
+ placeMapWords = "alt+1…9 go to a place · " + placeMapTaskWords + " · " +
placeMapVerbWords + " · " + mapCloseWords
// placeMapTaskWords is the map's clause about the chord that starts a task,
// named so the line can be drawn WITHOUT it: only home starts things, so on
@@ -1884,13 +1629,19 @@ func (a *app) placeHintSaid() string {
if a.hopShowing() {
return hopFootWords
}
+ // AND A WORD OF THE HEAD UNDER THE POINTER SAYS WHAT IT OPENS AND ITS KEY
+ // (topnav.go's [app.headHint]), over every resting sentence, because the
+ // pointer on it is a person asking exactly that.
+ if hint := a.headHint(); hint != "" {
+ return hint
+ }
if a.mapShowing {
// THE SWITCHER IS NAMED ON THE MAP AND NOWHERE ELSE ON A PLACE. The map
// is this surface's own chord list — the one line whose job is to say
// what the keys are — and the place's resting foot is four clauses that
// the design fixes word for word (FIDELITY.md item 3). A key bound on
// every place and drawn on none of them would break SCREEN 3a's clause,
- // and this is the line that keeps it, exactly as it keeps the `ctrl+1…8`
+ // and this is the line that keeps it, exactly as it keeps the `ctrl+1…9`
// alias ([chordSpelling.mapLine]).
line := a.chords.mapLine(a.placeMapSaid(), a.ctrlDigits())
if a.hopAvailable() {
@@ -2302,6 +2053,8 @@ func (a *app) showPage(id page) (cmd tea.Cmd) {
a.standDownRest()
a.closeStrip()
a.mapShowing = false
+ // AND THE NAV'S FOLD MENU: it was a way to this door, and it is used.
+ a.navMore.on = false
// AND THE COMPOSER LAYER GOES WITH THE PLACE IT WAS OPENED ON. It names that
// place in its own foot and dims that place's rows behind it; carried onto the
// next room it would be a decision drawn over a page it was never about
diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go
index 7ade13b74f..417f91d35e 100644
--- a/internal/tui3/pages_test.go
+++ b/internal/tui3/pages_test.go
@@ -61,16 +61,23 @@ func placeFrameText(a *app) string {
// agree with is the registry rather than a literal seven this file counted. What
// is left here is the two facts that are about the EDGES of that list.
func TestTheSevenPlacesAreOneList(t *testing.T) {
- if len(pages()) != 7 {
- t.Fatalf("there are %d places, and the design has seven", len(pages()))
+ if len(pages()) != 8 {
+ t.Fatalf("there are %d places, and the design has eight rooms", len(pages()))
}
- // AND alt+9 IS NOTHING, rather than the first place again: the eight
- // digits are the seven rooms and the way back to the chats.
- if _, ok := placeDigit("alt+9"); ok {
- t.Fatal("alt+9 reaches a place that does not exist")
+ // AND alt+0 IS NOTHING, rather than a tenth place: the nine digits are the
+ // eight rooms and the way back to the chats, teams second and the chats
+ // third (the owner's order).
+ if _, ok := placeDigit("alt+0"); ok {
+ t.Fatal("alt+0 reaches a place that does not exist")
}
- if got, ok := placeDigit("alt+2"); !ok || got != pageChats {
- t.Fatalf("alt+2 reaches %q, not the chats", got.word())
+ if got, ok := placeDigit("alt+2"); !ok || got != pageTeams {
+ t.Fatalf("alt+2 reaches %q, not teams", got.word())
+ }
+ if got, ok := placeDigit("alt+3"); !ok || got != pageChats {
+ t.Fatalf("alt+3 reaches %q, not the chats", got.word())
+ }
+ if got, ok := placeDigit("alt+9"); !ok || got != pageSearch {
+ t.Fatalf("alt+9 reaches %q, not search", got.word())
}
}
@@ -98,7 +105,7 @@ func TestATabWearsNoCountUntilSomethingAnswersForIt(t *testing.T) {
if a.places != nil {
t.Fatal("a surface that has not counted yet wired a counter")
}
- bar := plain(a.placeTabBar(a.width, false, a.pal))
+ bar := navPlaces(a, a.width, false)
for _, digit := range "0123456789" {
if strings.ContainsRune(bar, digit) {
t.Fatalf("the bar wears a figure with nothing to count: %q", bar)
@@ -214,38 +221,41 @@ func TestNothingIsEverPutBackBecauseNothingRefuses(t *testing.T) {
// placelaws_test.go, over every registered place at six widths — [tierPhone]
// among them, which this one never reached for the four places it did not name.
-// THE TAB BAR GIVES UP WORDS IN A STATED ORDER RATHER THAN BEING CUT IN HALF. A
-// bar trimmed mid-word is a bar lying about how many places there are.
+// THE NAV FOLDS WORDS IN A STATED ORDER RATHER THAN BEING CUT IN HALF. A row
+// trimmed mid-word is a row lying about how many places there are.
//
-// THE COUNT IS OF THE BAR'S OWN WORDS. The bar is four places (DESIGN.md's law
-// 10); a fold that counted the three reached by command would say `▸ 5` over a
-// bar that only ever had four to give up.
-func TestTheTabBarFoldsRatherThanBeingCut(t *testing.T) {
+// THE FOLD IS OF THE NAV'S OWN WORDS. The row is six places (DESIGN.md's law
+// 10); a fold that stood for the three reached by command would offer rooms
+// the row never had to give up.
+func TestTheNavFoldsRatherThanBeingCut(t *testing.T) {
a := placeApp(t)
shown := barPages(a.page, false)
- wide := plain(a.placeTabBar(160, false, a.pal))
+ wide := navPlaces(a, 160, false)
for _, id := range shown {
if !strings.Contains(wide, id.word()) {
- t.Fatalf("the wide bar is missing %q: %q", id.word(), wide)
+ t.Fatalf("the wide nav is missing %q: %q", id.word(), wide)
}
}
- narrow := plain(a.placeTabBar(24, false, a.pal))
- if ansi.StringWidth(narrow) > 24 {
- t.Fatalf("the narrow bar runs past its frame: %q", narrow)
+ narrow := navPlaces(a, 40, false)
+ if ansi.StringWidth(plain(a.navLine(40, a.pal))) > 40 {
+ t.Fatalf("the narrow nav runs past its frame: %q", narrow)
}
// AND WHAT SURVIVES IS THE PLACE YOU ARE STANDING IN. Everything else is
- // something you can still reach; this is the one fact the bar exists for.
+ // something you can still reach; this is the one fact the row exists for.
if !strings.Contains(narrow, a.page.word()) {
- t.Fatalf("the narrow bar dropped the place you are on: %q", narrow)
+ t.Fatalf("the narrow nav dropped the place you are on: %q", narrow)
}
missing := 0
for _, id := range shown {
if !strings.Contains(narrow, id.word()) {
missing++
+ if !pagesHold(a.navMore.folded, id) {
+ t.Fatalf("%q left the narrow nav and is not behind `more`: %q", id.word(), narrow)
+ }
}
}
- if want := tokens.GlyphCollapsed + " " + itoa(missing); missing == 0 || !strings.Contains(narrow, want) {
- t.Fatalf("the narrow bar left %d of its four words off and should end in %q: %q", missing, want, narrow)
+ if missing == 0 || missing != len(a.navMore.folded) || !strings.HasSuffix(narrow, a.navMoreWord(a.pal)) {
+ t.Fatalf("the narrow nav left %d of its words off and should end in `more ▾` over them: %q, %v", missing, narrow, a.navMore.folded)
}
}
@@ -304,10 +314,10 @@ func TestTheMapDrawsInTheCellsThatWereAlreadyThere(t *testing.T) {
t.Fatalf("the map moved the frame: %d rows became %d", len(before), len(after))
}
// THE NUMBERS ARE ON THE TABS, and the three places off the bar are drawn
- // after the four with theirs: the map is the one surface whose job is to show
- // every key, so `alt+5`…`alt+7` are on it.
- if bar := after[placeTabRow]; !strings.Contains(bar, "1 home") || !strings.Contains(bar, "2 chats") || !strings.Contains(bar, "5 settings") ||
- !strings.Contains(bar, "6 standing") || !strings.Contains(bar, "8 search") {
+ // after the six with theirs: the map is the one surface whose job is to show
+ // every key, so `alt+7`…`alt+9` are on it.
+ if bar := after[navRow]; !strings.Contains(bar, "1 home") || !strings.Contains(bar, "2 teams") || !strings.Contains(bar, "3 chats") ||
+ !strings.Contains(bar, "6 settings") || !strings.Contains(bar, "7 standing") || !strings.Contains(bar, "9 search") {
t.Fatalf("the map put no numbers on the tab bar: %q", bar)
}
// AND THE CHORD LIST IS THE HINT LINE.
@@ -703,7 +713,7 @@ func TestATabWearsTheCountTheSeamGivesIt(t *testing.T) {
pageSpend.word(): 9,
pageStanding.word(): 0,
}
- bar := plain(a.placeTabBar(160, false, a.pal))
+ bar := navPlaces(a, 160, false)
if !strings.Contains(bar, "sessions 2") {
t.Fatalf("the tasks tab does not wear its count: %q", bar)
}
@@ -798,18 +808,20 @@ func TestTheNumbersOpenAPlaceFromTheConversationToo(t *testing.T) {
}
}
-// THE TAB BAR CARRIES ITS FOUR WORDS AT EVERY WIDTH A PERSON ACTUALLY USES, and
-// only those four: `home tasks spend settings` (DESIGN.md's law 10). The ladder
+// THE TAB BAR CARRIES ITS SIX WORDS AT EVERY WIDTH A PERSON ACTUALLY USES, and
+// only those six: `home teams chats sessions spend settings` (DESIGN.md's law
+// 10, with teams after home by the teams page ruling, c-2, and the chats third,
+// the owner's order). The ladder
// that gives words up is for terminals narrower than any of these
-// ([app.placeTabBar]); at 80 columns and up nothing is dropped. Standing,
+// (topnav.go); at 80 columns and up nothing is folded. Standing,
// memory and search are rooms reached by command, by their digit and by the
// map — not words on the row a person reads a hundred times a day.
func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) {
a := placeApp(t)
for _, width := range []int{80, 120, 200} {
- bar := plain(a.placeTabBar(width, false, a.pal))
- if !strings.Contains(bar, "home chats sessions spend settings") {
- t.Fatalf("at %d columns the bar is not the four places in order: %q", width, bar)
+ bar := navPlaces(a, width, false)
+ if !placeWordsInOrder(bar, "home", "teams", "chats", "sessions", "spend", "settings") {
+ t.Fatalf("at %d columns the bar is not the six places in order: %q", width, bar)
}
for _, id := range []page{pageStanding, pageMemory, pageSearch} {
if strings.Contains(bar, id.word()) {
@@ -820,7 +832,24 @@ func TestTheTabBarCarriesTheFourAtEveryUsableWidth(t *testing.T) {
// AND A ROOM OFF THE BAR IS ON IT WHILE YOU STAND IN IT. A bar with no word
// lit is a bar that does not know where you are.
walkTo(t, a, pageMemory)
- if bar := plain(a.placeTabBar(120, false, a.pal)); !strings.Contains(bar, "settings memory") {
+ if bar := navPlaces(a, 120, false); !strings.Contains(bar, "settings memory") {
t.Fatalf("standing in memory, the bar does not say so: %q", bar)
}
}
+
+// placeWordsInOrder reports whether every word stands in text, each after the
+// one before it and apart from its neighbours by spaces alone.
+func placeWordsInOrder(text string, words ...string) bool {
+ at := 0
+ for i, word := range words {
+ n := strings.Index(text[at:], word)
+ if n < 0 {
+ return false
+ }
+ if i > 0 && strings.TrimSpace(text[at:at+n]) != "" {
+ return false
+ }
+ at += n + len(word)
+ }
+ return true
+}
diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go
index a586e8df75..49f4db8518 100644
--- a/internal/tui3/payload_test.go
+++ b/internal/tui3/payload_test.go
@@ -262,8 +262,8 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) {
// question rather than written down (homeexchange.go's
// [exchangeAnswerWords]): the digits the card drew, then the key that
// asks for the box instead.
- {"1 yes, set it up · 3 just once · 0 no · c change",
- []string{"1", "3", "0", "c"}},
+ {"1 Set it up · 3 Only now, don't repeat · 0 Don't set it up · o Change…",
+ []string{"1", "3", "0", "o"}},
{"1-3 shape · esc never mind", []string{"1-3", "esc"}},
{"y allow · n deny · a always", []string{"y", "n", "a"}},
{"↑↓ move · →← tree · enter open · alt+w wide · esc",
diff --git a/internal/tui3/place_chats.go b/internal/tui3/place_chats.go
index f120bfeaa9..1e0c1a9f02 100644
--- a/internal/tui3/place_chats.go
+++ b/internal/tui3/place_chats.go
@@ -9,9 +9,9 @@ import (
// The places' bar is where a person stands to read the machine, and until this
// it had no word for the one room they came from: the chats, the conversation
// surface with its tab strip. `esc` went back, and nothing on the bar said so.
-// So the bar carries `chats`, second, right after home:
+// So the bar carries `chats`, third, right after home and teams:
//
-// home chats tasks spend settings
+// home teams chats sessions spend settings
//
// A press on it, its digit and `enter` on it with the bar's cursor all do one
// thing: the place closes and the conversation that was in front is in front
@@ -31,9 +31,11 @@ type placeChats struct{ placeBase }
func init() { registerPlace(placeChats{}) }
-func (placeChats) id() page { return pageChats }
-func (placeChats) word() string { return "chats" }
-func (placeChats) cursorAt(a *app) int { return 0 }
+func (placeChats) id() page { return pageChats }
+func (placeChats) word() string { return "chats" }
+func (placeChats) cursorAt(a *app) int { return 0 }
+func (placeChats) about() string { return "every conversation, one at a time" }
+
func (placeChats) hint(a *app) string { return "back to your chats" }
func (placeChats) open(a *app) tea.Cmd { return nil }
func (placeChats) enter(a *app) tea.Cmd { return a.goChats() }
diff --git a/internal/tui3/place_chats_test.go b/internal/tui3/place_chats_test.go
index f94c62b6d2..af7ffbb09b 100644
--- a/internal/tui3/place_chats_test.go
+++ b/internal/tui3/place_chats_test.go
@@ -5,13 +5,13 @@ import (
"testing"
)
-// THE BAR HAS A WAY BACK TO THE CHATS, second after home, and a press on it,
+// THE NAV HAS A WAY BACK TO THE CHATS, third after home and teams, and a press on it,
// its digit and enter on it with the bar's cursor all do one thing: the place
// closes and the conversation that was in front is in front again.
func TestTheChatsOnTheBarGoBackToTheConversation(t *testing.T) {
a := placeApp(t)
front := a.file
- bar := plain(a.placeTabBar(a.width, false, a.pal))
+ bar := navPlaces(a, a.width, false)
if h, c, k := strings.Index(bar, "home"), strings.Index(bar, "chats"), strings.Index(bar, pageTasks.word()); h < 0 || c < h || k < c {
t.Fatalf("the bar does not read home, chats: %q", bar)
}
@@ -25,7 +25,7 @@ func TestTheChatsOnTheBarGoBackToTheConversation(t *testing.T) {
if chats.to == 0 {
t.Fatalf("the chats word has no span: %+v", a.tabs)
}
- cmd, took := a.placeTabPress(chats.from+1, a.tabRow)
+ cmd, took := a.navPress(chats.from+1, a.tabRow)
if !took {
t.Fatal("the press on chats was not taken")
}
@@ -35,15 +35,19 @@ func TestTheChatsOnTheBarGoBackToTheConversation(t *testing.T) {
}
a.openHome()
drive(t, a, key(placeChord(pageChats)))
- if a.pageShowing() || a.file != front || placeChord(pageChats) != "alt+2" {
+ if a.pageShowing() || a.file != front || placeChord(pageChats) != "alt+3" {
t.Fatalf("%s left the router on %q", placeChord(pageChats), a.page.word())
}
// And `tab`, which walks the rooms, steps over it.
a.openHome()
drive(t, a, key("tab"))
- if a.page != pageTasks {
+ if a.page != pageTeams {
t.Fatalf("tab from home landed on %q", a.page.word())
}
+ drive(t, a, key("tab"))
+ if a.page != pageTasks {
+ t.Fatalf("tab from teams landed on %q, not past the chats", a.page.word())
+ }
}
// WITH NO CONVERSATION OPEN THE WAY BACK IS A NEW CHAT.
diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go
index 35639a80f4..84a0bd19f2 100644
--- a/internal/tui3/place_home.go
+++ b/internal/tui3/place_home.go
@@ -499,6 +499,8 @@ func (placeHome) box(a *app) *editor {
// hint is HOME'S WHOLE LINE, the router's own keys included. At rest that line is
// the design's sentence word for word and names four keys exactly (SCREEN 1a,
// home.go's [app.homeHint]); the router's tail appended here would make it five.
+func (placeHome) about() string { return "what wants you and what is running" }
+
func (placeHome) hint(a *app) string { return a.homeHint() }
// changed is ZERO AND THAT IS THE DESIGN. Home is where the "since you left"
diff --git a/internal/tui3/place_memory.go b/internal/tui3/place_memory.go
index cdcac06d83..dc19e16fe1 100644
--- a/internal/tui3/place_memory.go
+++ b/internal/tui3/place_memory.go
@@ -882,6 +882,8 @@ func (placeMemory) note(a *app, width int) []string {
// hint is WHAT THE ROW UNDER THE CURSOR CAN BE ASKED FOR (pages.go's
// [place.hint]), which on this place is two different sentences: a shelf heading
// unrolls and a line is talked about.
+func (placeMemory) about() string { return "what the agents remember" }
+
func (placeMemory) hint(a *app) string {
if a.mem.edit != nil {
return memoryEditHint
diff --git a/internal/tui3/place_search.go b/internal/tui3/place_search.go
index f7d4e98bcb..ac0a071c23 100644
--- a/internal/tui3/place_search.go
+++ b/internal/tui3/place_search.go
@@ -551,20 +551,22 @@ func (placeSearch) key(a *app, msg tea.KeyPressMsg) tea.Cmd { return a.searchKey
const (
// searchHitHint is the foot standing on a result: what enter opens, how to
// move between them, and that the box is still a search box.
- searchHitHint = "enter opens it at that turn · ↑↓ pick · type to search · esc clears the words"
+ searchHitHint = "enter opens it at that turn · ↑↓ pick · type to search · esc clear the words"
// searchAskHint is the foot with nothing to stand on — the teaching page and
// a search that found nothing. NOTHING IS NAMED THAT IS NOT BOUND, so
// `enter` and `↑↓` are simply absent rather than promised over an empty
// body (place_standing.go's hint holds the same argument at more length).
- searchAskHint = "type to search · esc clears the words"
+ searchAskHint = "type to search · esc clear the words"
)
// hint is WHAT THE ROW UNDER THE CURSOR CAN BE ASKED FOR (pages.go's
// [place.hint]) — and on the teaching page and the no-hit line there is no row,
// so the foot says only the two things that are true there.
+func (placeSearch) about() string { return "search every conversation" }
+
func (placeSearch) hint(a *app) string {
if a.search.reading.foldAt(a.search.cursor) {
- return foldEnterWord(a.search.unfolded) + " · ↑↓ pick · type to search · esc clears the words"
+ return foldEnterWord(a.search.unfolded) + " · ↑↓ pick · type to search · esc clear the words"
}
if _, ok := a.search.reading.at(a.search.cursor); ok {
return searchHitHint
diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go
index 96c88d9eb9..68e4b6eb11 100644
--- a/internal/tui3/place_sessions.go
+++ b/internal/tui3/place_sessions.go
@@ -1831,7 +1831,9 @@ func (placeTasks) rowID(a *app) string {
func (placeTasks) window(a *app, key string) (bool, tea.Cmd) {
return a.taskSheet.window(a, key), nil
}
-func (placeTasks) note(a *app, width int) []string { return a.taskSheet.note(a, width) }
+func (placeTasks) note(a *app, width int) []string { return a.taskSheet.note(a, width) }
+func (placeTasks) about() string { return "every session and task this machine ran" }
+
func (placeTasks) hint(a *app) string { return a.taskSheet.hint(a) }
func (placeTasks) changed(a *app, since time.Time) int { return a.taskSheet.changed(a, since) }
diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go
index a7f99df572..2165650e2c 100644
--- a/internal/tui3/place_settings.go
+++ b/internal/tui3/place_settings.go
@@ -20,9 +20,8 @@ import (
// (docs/design/home-rethink/ARCHITECTURE.md's three layers).
//
// IT IS THE ONE PLACE WITH A SECOND BAR INSIDE IT, and the two are not a
-// repetition: the upper one is the seven places and the lower one is this
-// place's own sections. The panel is where [placeTabBar] was lifted from, so
-// they are drawn by the same geometry and read as one object at two scales.
+// repetition: the nav at the top is the places and the bar in the body is this
+// place's own sections.
// placeSettings is this place's handle on the registry (pages.go's [place]
// states the contract and why the handle holds no state of its own).
@@ -40,7 +39,10 @@ func (placeSettings) open(a *app) tea.Cmd {
// change when somebody changes them — but the tab bar's numbers are
// recomputed on that beat, and a room that armed no clock stopped the whole
// bar counting while it was up ([placeSettings.tick]).
- return a.armPlaceClock()
+ //
+ // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A
+ // local launch asks for nothing.
+ return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults())
}
// tick re-reads nothing and keeps the beat: see the note over [placeSettings.open].
@@ -180,6 +182,8 @@ func (placeSettings) note(a *app, width int) []string {
return []string{" " + pal.dim(noteFit(a.sheet.footNote(), width-2))}
}
+func (placeSettings) about() string { return "how this machine is set" }
+
func (placeSettings) hint(a *app) string { return a.sheet.keysLine() }
// key is the panel's own grammar (settings.go's [app.sheetKey]): the value being
@@ -199,9 +203,10 @@ func (placeSettings) key(a *app, msg tea.KeyPressMsg) tea.Cmd {
// title bar (connectcaps.go).
func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) {
s := &a.sheet
+ var cmd tea.Cmd
switch {
case s.edit != nil:
- a.sheetEditKey(msg)
+ cmd = a.sheetEditKey(msg)
case s.sel != nil:
a.sheetSelectKey(msg)
case s.conn.entry != nil:
@@ -210,7 +215,7 @@ func (placeSettings) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) {
return nil, false
}
a.touch()
- return nil, true
+ return cmd, true
}
// caretRow is the connections key entry: a box this sheet draws INSIDE the row
diff --git a/internal/tui3/place_spend.go b/internal/tui3/place_spend.go
index 276420de87..3811b6f125 100644
--- a/internal/tui3/place_spend.go
+++ b/internal/tui3/place_spend.go
@@ -926,6 +926,8 @@ const (
// hold them ([app.spendWindowKey] asks [placeWindowFits] the same question), and
// a foot promising them under a head that is not drawing them would be this
// surface advertising a key that does nothing.
+func (placeSpend) about() string { return "what the work has cost" }
+
func (placeSpend) hint(a *app) string {
var parts []string
stop := a.spendStopAt(a.spend.cursor)
diff --git a/internal/tui3/place_standing.go b/internal/tui3/place_standing.go
index 90c76febca..a640fa7236 100644
--- a/internal/tui3/place_standing.go
+++ b/internal/tui3/place_standing.go
@@ -915,7 +915,9 @@ func (placeStanding) rowID(a *app) string { return a.orders.rowID() }
func (placeStanding) window(a *app, key string) (bool, tea.Cmd) {
return a.orders.window(a, key), nil
}
-func (placeStanding) note(a *app, width int) []string { return a.orders.note(a, width) }
+func (placeStanding) note(a *app, width int) []string { return a.orders.note(a, width) }
+func (placeStanding) about() string { return "the standing orders" }
+
func (placeStanding) hint(a *app) string { return a.orders.hint(a) }
func (placeStanding) changed(a *app, since time.Time) int { return a.orders.changed(a, since) }
diff --git a/internal/tui3/place_teams.go b/internal/tui3/place_teams.go
new file mode 100644
index 0000000000..676a95c84d
--- /dev/null
+++ b/internal/tui3/place_teams.go
@@ -0,0 +1,533 @@
+package tui3
+
+import (
+ "sort"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE TEAMS PLACE ─────────────────────────────────────────────────────────
+//
+// The handle the registry files (pages.go's [place]), and the keys, the
+// pointer and the acts of the page teamspage.go describes. The second place on
+// the bar, right after home: `home teams chats sessions spend settings`.
+//
+// TWO SHAPES, ONE PLACE. With a manager in the pane the page hosts that
+// conversation whole (teamspagehost.go): the frame is the conversation's own,
+// laid out beside the rail, and the composer has the keyboard; `alt+↑↓` puts it
+// on the page's buttons and `esc` gives it back. With no manager in the pane
+// (a team without one, a closed team, the `All teams` row, no teams at all)
+// the page is drawn in the shared place frame and has the keyboard itself.
+
+// placeTeams is this place's handle (pages.go's [place] states the contract
+// and why the handle holds no state of its own).
+type placeTeams struct{ placeBase }
+
+func init() { registerPlace(placeTeams{}) }
+
+func (placeTeams) id() page { return pageTeams }
+func (placeTeams) word() string { return "teams" }
+
+// counted is true: what is in here is a pile of things, and a packet waiting
+// on the person is news about this place.
+func (placeTeams) counted() bool { return true }
+
+// open loads the teams at the door (the one read made on the loop, and it does
+// not block, teamseam.go), settles the selection, brings the selected team's
+// manager in front, and arms the router's beat, which the page answers.
+func (placeTeams) open(a *app) tea.Cmd {
+ a.teamsEnsure()
+ a.tp.focus, a.tp.cur, a.tp.hot = false, teamsRef{}, teamsRef{}
+ a.tp.answering, a.tp.msg = "", ""
+ a.tp.top = teamsTopCache{}
+ a.teamsSettle()
+ // The keyboard starts on the selected team's row, where `↓` walks from.
+ // A managed team's page opens with the keyboard on the manager's composer.
+ a.tp.focus, a.tp.cur = true, teamsRef{act: teamsActSelect, id: a.tp.sel}
+ if t, ok := a.teamsSelected(); ok && t.Manager != "" && !t.Closed() {
+ a.tp.focus = false
+ }
+ return tea.Batch(a.armPlaceClock(), a.teamsRead(true), a.teamsBringManager())
+}
+
+// tick is the router's beat: the counts are the router's own, and the page
+// reads the store on it only while an open team has a manager.
+func (placeTeams) tick(a *app, now time.Time) (bool, tea.Cmd) {
+ if !a.teamsManaged() {
+ return true, nil
+ }
+ return true, a.teamsRead(true)
+}
+
+// close drops what the page drew; the selection and the
+// fold are kept for the next visit, as a place's views are.
+func (placeTeams) close(a *app) {
+ a.tp.focus, a.tp.host, a.tp.targets = false, "", nil
+ a.tp.answering = ""
+ a.tp.top = teamsTopCache{}
+ // A drag, the members card and a move waiting on its line belong to the
+ // page and go with it; a move made keeps its Undo for the next visit.
+ a.tdrag, a.tcrew = teamDrag{}, teamCrew{}
+ a.tmove.pend = teamMovePend{}
+}
+
+func (placeTeams) body(a *app, width, room int) []placeRow { return a.teamsBody(width, room) }
+
+// ownFrame is the hosted shape: the manager's own conversation beside the
+// rail (teamspagehost.go). Every other shape is the shared frame's.
+func (placeTeams) ownFrame(a *app, width, height int) ([]string, []placeHit, int, int, bool) {
+ return a.teamsHostFrame()
+}
+
+// remote is the one line over --host against an engine without the teams
+// doors: this window's own file is not the session's, and the page does not
+// pretend it is.
+func (placeTeams) remote(a *app) string {
+ if a.teamsOff() {
+ return teamHostedWord
+ }
+ return ""
+}
+
+// stops is every body line with a target on it, top first: the router's
+// names for rows, which a scrolled pane does not move.
+func (placeTeams) stops(a *app) []int {
+ out := make([]int, 0, len(a.tp.targets))
+ for _, t := range a.tp.targets {
+ out = append(out, t.line)
+ }
+ sort.Ints(out)
+ return out
+}
+
+// cursorAt is the body line the keyboard is on, the first stop when it is on
+// none, so `↑` from the first target reaches the bar.
+func (placeTeams) cursorAt(a *app) int {
+ if t, ok := a.teamsCursorTarget(); ok {
+ return t.line
+ }
+ return 0
+}
+
+func (placeTeams) rowID(a *app) string {
+ if !a.tp.focus {
+ return ""
+ }
+ r := a.tp.cur
+ return itoa(int(r.act)) + ":" + r.id + ":" + r.arg + ":" + r.opt
+}
+
+// note is the page's one line of news, and otherwise the one fact under the
+// rows that a person may not see anywhere else: that the manager has the
+// keyboard or that the page does.
+func (placeTeams) note(a *app, width int) []string {
+ if a.tp.msg != "" {
+ return []string{" " + a.pal.dim(noteFit(a.tp.msg, width-2))}
+ }
+ return nil
+}
+
+// hint is what the pointer or the cursor is on, with its key, and otherwise
+// the page's keys.
+func (placeTeams) about() string { return "the teams you hand work to" }
+
+func (placeTeams) hint(a *app) string {
+ if a.tmove.on {
+ return a.teamMoveHint()
+ }
+ if a.tsheet.on {
+ return a.teamSheetHint()
+ }
+ if words := a.teamDragHint(); words != "" {
+ return words
+ }
+ if a.tcrew.on {
+ return a.teamCrewHint()
+ }
+ if words := a.teamsTargetHint(); words != "" {
+ return words
+ }
+ if n := len(a.teamsPickedIDs()); n > 0 {
+ return itoa(n) + " picked · m moves them into… · space picks · esc clears"
+ }
+ if a.tp.answering != "" {
+ return "enter decide · esc put it away"
+ }
+ if !a.teamsAny() {
+ return "o organize · n new team · " + homeDoorWord
+ }
+ return "↑↓ walk · enter open · m move into… · space pick · p members · s settings · c close · n new team · o organize · " + homeDoorWord
+}
+
+// changed is how many packets wait on the person, which is the count a person
+// wants beside `teams` on the bar.
+func (placeTeams) changed(a *app, since time.Time) int {
+ n := 0
+ for _, p := range a.tp.packets {
+ if p.Waiting() && p.Team == teamstore.Person && p.At.After(since) {
+ n++
+ }
+ }
+ return n
+}
+
+// summary is what is behind the place, for home's typed drop-up.
+func (placeTeams) summary(a *app) string {
+ n := 0
+ for _, t := range a.wall.teams {
+ if !t.Root && !t.Closed() {
+ n++
+ }
+ }
+ switch n {
+ case 0:
+ return ""
+ case 1:
+ return "1 team"
+ }
+ return itoa(n) + " teams"
+}
+
+// owns is the `Your own answer…` box, which has the whole keyboard while it is
+// open, as every box inside a place does.
+func (placeTeams) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) {
+ if a.tp.answering == "" {
+ return nil, false
+ }
+ return a.teamsAnswerKey(msg), true
+}
+
+// key is the page's own keys, the router's classes read first (placekeys.go).
+func (placeTeams) key(a *app, msg tea.KeyPressMsg) tea.Cmd {
+ cmd, _ := a.teamsKey(msg)
+ return cmd
+}
+
+// wheel walks the page's buttons exactly as the arrows do, a row a notch.
+func (placeTeams) wheel(a *app, delta int) (tea.Cmd, bool) {
+ step := tea.KeyPressMsg{Code: tea.KeyDown}
+ if delta < 0 {
+ step, delta = tea.KeyPressMsg{Code: tea.KeyUp}, -delta
+ }
+ for i := 0; i < delta; i++ {
+ a.teamsKey(step)
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil, true
+}
+
+func (placeTeams) enter(a *app) tea.Cmd {
+ if t, ok := a.teamsCursorTarget(); ok {
+ return a.teamsDo(t)
+ }
+ return nil
+}
+
+// ── THE KEYBOARD ────────────────────────────────────────────────────────────
+
+// teamsHasKeys reports whether the page, rather than a hosted composer, has
+// the keyboard.
+func (a *app) teamsHasKeys() bool { return a.tp.host == "" || a.tp.focus }
+
+// teamsCursorIndex is the index of the target the cursor is on, -1 for none.
+func (a *app) teamsCursorIndex() int {
+ if !a.tp.focus && a.tp.host != "" {
+ return -1
+ }
+ for i, t := range a.tp.targets {
+ if t.ref() == a.tp.cur {
+ return i
+ }
+ }
+ return -1
+}
+
+// teamsCursorTarget is the target the cursor is on.
+func (a *app) teamsCursorTarget() (teamsTarget, bool) {
+ if i := a.teamsCursorIndex(); i >= 0 {
+ return a.tp.targets[i], true
+ }
+ return teamsTarget{}, false
+}
+
+// teamsCursorHome puts the cursor on the selected team's rail row, or on the
+// first target when that is not drawn.
+func (a *app) teamsCursorHome() {
+ for _, t := range a.tp.targets {
+ if t.act == teamsActSelect && t.id == a.tp.sel {
+ a.tp.cur = t.ref()
+ return
+ }
+ }
+ if len(a.tp.targets) > 0 {
+ a.tp.cur = a.tp.targets[0].ref()
+ }
+}
+
+// teamsWalk moves the cursor to the nearest target on the row above (dy -1)
+// or below (dy 1), keeping as close to its column as it can, or along its row
+// for dx. It reports whether it moved.
+func (a *app) teamsWalk(dx, dy int) bool {
+ at := a.teamsCursorIndex()
+ if at < 0 {
+ a.teamsCursorHome()
+ return true
+ }
+ here := a.tp.targets[at]
+ order := make([]int, len(a.tp.targets))
+ for i := range order {
+ order[i] = i
+ }
+ sort.SliceStable(order, func(i, j int) bool {
+ ti, tj := a.tp.targets[order[i]], a.tp.targets[order[j]]
+ if ti.y != tj.y {
+ return ti.y < tj.y
+ }
+ return ti.x0 < tj.x0
+ })
+ // ↑ AND ↓ STAY ON THEIR SIDE: the rail walks the rail and the pane walks
+ // the pane, so a walk down the teams is never pulled into a card beside
+ // them. ← and → walk along a row, and past its end cross to the nearest
+ // target on the other side.
+ best, bestScore := -1, 1<<30
+ for _, i := range order {
+ t := a.tp.targets[i]
+ if i == at {
+ continue
+ }
+ var score int
+ switch {
+ case dy < 0 && t.y < here.y && t.pane == here.pane:
+ score = (here.y-t.y)*1000 + abs(t.x0-here.x0)
+ case dy > 0 && t.y > here.y && t.pane == here.pane:
+ score = (t.y-here.y)*1000 + abs(t.x0-here.x0)
+ case dx < 0 && t.y == here.y && t.x0 < here.x0:
+ score = here.x0 - t.x0
+ case dx > 0 && t.y == here.y && t.x0 > here.x0:
+ score = t.x0 - here.x0
+ case dx < 0 && here.pane && !t.pane, dx > 0 && !here.pane && t.pane:
+ score = 100000 + abs(t.y-here.y)*1000 + abs(t.x0-here.x0)
+ default:
+ continue
+ }
+ if score < bestScore {
+ best, bestScore = i, score
+ }
+ }
+ if best < 0 {
+ return false
+ }
+ a.tp.cur = a.tp.targets[best].ref()
+ return true
+}
+
+// teamsLetters are the page's bare-letter keys, each the button of the same
+// word on the selected team.
+//
+// `m` IS MOVE INTO… (ruling c-12), so starting a manager moved to `M`: the
+// ruling names the letter, and a manager is started once per team while a
+// team is moved whenever the tree is reshaped.
+var teamsLetters = map[string]teamsAct{
+ "s": teamsActSettings, "c": teamsActClose, "w": teamsActWall, "n": teamsActNewTeam,
+ "o": teamsActOrganize, "M": teamsActManager, "r": teamsActReopen, "d": teamsActDelete,
+}
+
+// teamsMoveLetter is `Move into…`, on the selected team or the picked ones.
+const teamsMoveLetter = "m"
+
+// teamsKey is a key while the page has the keyboard. It reports whether it
+// took it.
+func (a *app) teamsKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
+ key := msg.String()
+ switch key {
+ case "up", "k":
+ if !a.tp.focus {
+ a.tp.focus = true
+ a.teamsCursorHome()
+ } else {
+ // The first target's `↑` is the router's: it reaches the bar
+ // ([app.barReach]) before this key is asked.
+ a.teamsWalk(0, -1)
+ }
+ a.touch()
+ return nil, true
+ case "down", "j":
+ if !a.tp.focus {
+ a.tp.focus = true
+ a.teamsCursorHome()
+ } else {
+ a.teamsWalk(0, 1)
+ }
+ a.touch()
+ return nil, true
+ case "left", "h":
+ a.tp.focus = true
+ if !a.teamsWalk(-1, 0) {
+ a.teamsCursorHome()
+ }
+ a.touch()
+ return nil, true
+ case "right", "l":
+ a.tp.focus = true
+ a.teamsWalk(1, 0)
+ a.touch()
+ return nil, true
+ case "enter", "space":
+ t, ok := a.teamsCursorTarget()
+ if !ok {
+ a.tp.focus = true
+ a.teamsCursorHome()
+ a.touch()
+ return nil, true
+ }
+ // SPACE PICKS A TEAM ON THE RAIL, as it picks a tile on the wall, so
+ // several can be moved with one `Move into…`.
+ if key == "space" && t.act == teamsActSelect && !t.pane {
+ if u, ok := a.teamByID(t.id); ok && !u.Root && !u.Closed() {
+ a.teamsPick(t.id)
+ return nil, true
+ }
+ }
+ return a.teamsDo(t), true
+ case teamsMoveLetter:
+ ids := a.teamsMoveIDs()
+ if len(ids) == 0 {
+ return nil, true
+ }
+ return a.teamMoveOpen(ids, teamMoveFromPage), true
+ case teamCrewLetter:
+ if t, ok := a.teamsSelected(); ok && !t.Closed() {
+ return a.teamCrewOpen(t.ID), true
+ }
+ return nil, true
+ case "esc":
+ if len(a.tp.picked) > 0 {
+ a.tp.picked = nil
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil, true
+ }
+ if a.tp.host != "" {
+ a.tp.focus = false
+ a.touch()
+ return nil, true
+ }
+ a.leavePlace()
+ return nil, true
+ }
+ if act, ok := teamsLetters[key]; ok {
+ return a.teamsLetter(act), true
+ }
+ return nil, false
+}
+
+// teamsLetter is one of the bare letters: the act on the selected team, when
+// the page offers it there.
+func (a *app) teamsLetter(act teamsAct) tea.Cmd {
+ for _, t := range a.tp.targets {
+ if t.act != act {
+ continue
+ }
+ switch act {
+ case teamsActNewTeam, teamsActOrganize, teamsActRootManager:
+ return a.teamsDo(t)
+ }
+ if t.id == a.tp.sel {
+ return a.teamsDo(t)
+ }
+ }
+ if act == teamsActManager {
+ for _, t := range a.tp.targets {
+ if t.act == teamsActRootManager {
+ return a.teamsDo(t)
+ }
+ }
+ }
+ return nil
+}
+
+// teamsAnswerKey is a key in the `Your own answer…` box.
+func (a *app) teamsAnswerKey(msg tea.KeyPressMsg) tea.Cmd {
+ box := &a.tp.answer
+ switch msg.String() {
+ case "esc":
+ a.tp.answering = ""
+ box.reset()
+ case "enter":
+ words := strings.TrimSpace(box.String())
+ id := a.tp.answering
+ if words == "" {
+ return nil
+ }
+ a.tp.answering = ""
+ box.reset()
+ a.touch()
+ return a.teamsDecide(id, "", words)
+ case "backspace":
+ box.deleteBackward()
+ case "left":
+ box.left()
+ case "right":
+ box.right()
+ default:
+ if text := msg.Key().Text; text != "" {
+ box.insert(text)
+ }
+ }
+ a.touch()
+ return nil
+}
+
+// ── THE POINTER ─────────────────────────────────────────────────────────────
+
+// teamsTargetAt is the target under the pointer on the last frame.
+func (a *app) teamsTargetAt(x, y int) (teamsTarget, bool) {
+ for _, t := range a.tp.targets {
+ if y == t.y && x >= t.x0 && x < t.x1 {
+ return t, true
+ }
+ }
+ return teamsTarget{}, false
+}
+
+// teamsTargetHint is what the hint line says over the target under the pointer,
+// or under the cursor while the page has the keyboard.
+func (a *app) teamsTargetHint() string {
+ if a.tp.hot != (teamsRef{}) {
+ for _, t := range a.tp.targets {
+ if t.ref() == a.tp.hot {
+ return t.hint
+ }
+ }
+ }
+ if t, ok := a.teamsCursorTarget(); ok {
+ return t.hint
+ }
+ return ""
+}
+
+// teamsHover lights the target under the pointer, repainting only when that
+// moved.
+func (a *app) teamsHover(x, y int) {
+ t, _ := a.teamsTargetAt(x, y)
+ if r := t.ref(); r != a.tp.hot {
+ a.tp.hot = r
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+}
+
+// teamsPress is a left press on the page: a target does what it says, and the
+// keyboard stays where it was.
+func (a *app) teamsPress(x, y int) (tea.Cmd, bool) {
+ t, ok := a.teamsTargetAt(x, y)
+ if !ok {
+ return nil, false
+ }
+ return a.teamsDo(t), true
+}
diff --git a/internal/tui3/placebar_test.go b/internal/tui3/placebar_test.go
index 6472c1bf9a..366c7aee1f 100644
--- a/internal/tui3/placebar_test.go
+++ b/internal/tui3/placebar_test.go
@@ -168,11 +168,11 @@ func TestTheBarCursorRepaintsTheWordAndMovesNothing(t *testing.T) {
a := placeApp(t)
before := a.frame
beforeFrame, _, _ := before()
- beforeRow := strings.Split(beforeFrame, "\n")[placeTabRow]
+ beforeRow := strings.Split(beforeFrame, "\n")[navRow]
barTop(t, a)
afterFrame, _, _ := a.frame()
- afterRow := strings.Split(afterFrame, "\n")[placeTabRow]
+ afterRow := strings.Split(afterFrame, "\n")[navRow]
if beforeRow == afterRow {
t.Fatalf("the bar is painted exactly as it was with the cursor on it: %q", plain(afterRow))
@@ -184,13 +184,13 @@ func TestTheBarCursorRepaintsTheWordAndMovesNothing(t *testing.T) {
// rather than merely somewhere in the program's own memory.
drive(t, a, key("right"))
walked, _, _ := a.frame()
- if strings.Split(walked, "\n")[placeTabRow] == afterRow {
+ if strings.Split(walked, "\n")[navRow] == afterRow {
t.Fatal("→ along the bar changed nothing on the frame")
}
}
// AND THE WIDTH LADDER MAY NOT DROP THE WORD THE CURSOR IS ON. The bar gives up
-// words as the frame narrows (pages.go's [app.placeTabBar]), and a cursor on a
+// words as the frame narrows (topnav.go), and a cursor on a
// word that was given up would be a cursor nobody can see — which is SCREEN 3a's
// clause said about a row rather than about a key.
func TestTheNarrowBarKeepsTheWordTheCursorIsOn(t *testing.T) {
@@ -214,7 +214,7 @@ func TestTheNarrowBarKeepsTheWordTheCursorIsOn(t *testing.T) {
// ── the bar is not a mode ───────────────────────────────────────────────────
-// `tab`, `shift+tab` AND `alt+1…8` KEEP WORKING FROM THE BAR, and a printable
+// `tab`, `shift+tab` AND `alt+1…9` KEEP WORKING FROM THE BAR, and a printable
// character goes to the composer with the cursor following it back down.
//
// A ROW THAT CAPTURED THE KEYBOARD WOULD BE A MODE, and the six classes have no
@@ -279,8 +279,9 @@ func TestTheBarCursorWrapsAtBothEnds(t *testing.T) {
// ── the pointer on the bar ──────────────────────────────────────────────────
-// MOTION OVER A TAB WORD CHANGES THAT WORD'S INK AND NOTHING ELSE ON THE FRAME,
-// and motion off it puts the ink back.
+// MOTION OVER A TAB WORD CHANGES THAT WORD'S GROUND AND THE HINT LINE AND
+// NOTHING ELSE ON THE FRAME, and motion off it puts both back. The hint line
+// says what the word opens and its key (topnav.go's [app.headHint]).
//
// A TAB WORD IS A DOOR AND A DOOR SHOULD LOOK BACK. The bar answered a press and
// said nothing at all while a pointer crossed it, so seven words that open seven
@@ -290,14 +291,18 @@ func TestHoveringATabWordLiftsItsInkAndNothingElse(t *testing.T) {
before, _, _ := a.frame()
span := barWordSpan(t, a, pageSettings)
- drive(t, a, tea.MouseMotionMsg{X: span.from, Y: placeTabRow})
+ drive(t, a, tea.MouseMotionMsg{X: span.from, Y: navRow})
after, _, _ := a.frame()
beforeRows, afterRows := strings.Split(before, "\n"), strings.Split(after, "\n")
if len(beforeRows) != len(afterRows) {
t.Fatalf("the frame changed height under a pointer: %d rows became %d", len(beforeRows), len(afterRows))
}
+ hint := len(afterRows) - 1
+ if got := plain(afterRows[hint]); !strings.Contains(got, placeChord(pageSettings)+" settings") || !strings.Contains(got, placeFor(pageSettings).about()) {
+ t.Fatalf("the hint line does not say what the hovered word opens: %q", got)
+ }
for at := range beforeRows {
- if at == placeTabRow {
+ if at == navRow || at == hint {
continue
}
if beforeRows[at] != afterRows[at] {
@@ -305,13 +310,13 @@ func TestHoveringATabWordLiftsItsInkAndNothingElse(t *testing.T) {
at, plain(beforeRows[at]), plain(afterRows[at]))
}
}
- if beforeRows[placeTabRow] == afterRows[placeTabRow] {
- t.Fatalf("the hovered word is painted exactly as it was: %q", plain(afterRows[placeTabRow]))
+ if beforeRows[navRow] == afterRows[navRow] {
+ t.Fatalf("the hovered word is painted exactly as it was: %q", plain(afterRows[navRow]))
}
// AND THE CELLS ARE THE SAME CELLS: the ink lifted, the bar did not move.
- if plain(beforeRows[placeTabRow]) != plain(afterRows[placeTabRow]) {
+ if plain(beforeRows[navRow]) != plain(afterRows[navRow]) {
t.Fatalf("the hover moved a cell on the bar:\n%q\n%q",
- plain(beforeRows[placeTabRow]), plain(afterRows[placeTabRow]))
+ plain(beforeRows[navRow]), plain(afterRows[navRow]))
}
// AND IT MOVED NOTHING A KEY WOULD HAVE MOVED.
if a.page != pageHome {
@@ -322,9 +327,13 @@ func TestHoveringATabWordLiftsItsInkAndNothingElse(t *testing.T) {
// door claiming to be under a pointer that is somewhere else.
drive(t, a, tea.MouseMotionMsg{X: span.from, Y: placeHeadRows + 1})
restored, _, _ := a.frame()
- if strings.Split(restored, "\n")[placeTabRow] != beforeRows[placeTabRow] {
+ if strings.Split(restored, "\n")[navRow] != beforeRows[navRow] {
t.Fatalf("the pointer left the bar and the ink stayed lifted:\n%q",
- plain(strings.Split(restored, "\n")[placeTabRow]))
+ plain(strings.Split(restored, "\n")[navRow]))
+ }
+ if strings.Split(restored, "\n")[hint] != beforeRows[hint] {
+ t.Fatalf("the pointer left the bar and the hint line kept its sentence:\n%q",
+ plain(strings.Split(restored, "\n")[hint]))
}
}
@@ -337,10 +346,10 @@ func TestHoveringTheGapBetweenTabsLiftsNothing(t *testing.T) {
if !ok {
t.Skip("this bar has no gap between two chips")
}
- drive(t, a, tea.MouseMotionMsg{X: gap, Y: placeTabRow})
+ drive(t, a, tea.MouseMotionMsg{X: gap, Y: navRow})
after, _, _ := a.frame()
- if strings.Split(before, "\n")[placeTabRow] != strings.Split(after, "\n")[placeTabRow] {
- t.Fatalf("a pointer in the gap lifted a word:\n%q", plain(strings.Split(after, "\n")[placeTabRow]))
+ if strings.Split(before, "\n")[navRow] != strings.Split(after, "\n")[navRow] {
+ t.Fatalf("a pointer in the gap lifted a word:\n%q", plain(strings.Split(after, "\n")[navRow]))
}
}
@@ -357,7 +366,7 @@ func TestTheHoverAndTheBarCursorCompose(t *testing.T) {
t.Fatal("the cursor walked onto the word this test means to hover")
}
span := barWordSpan(t, a, pageSettings)
- drive(t, a, tea.MouseMotionMsg{X: span.from, Y: placeTabRow})
+ drive(t, a, tea.MouseMotionMsg{X: span.from, Y: navRow})
if a.tabHover != pageSettings {
t.Fatalf("the pointer is recorded over %q", a.tabHover.word())
@@ -366,7 +375,7 @@ func TestTheHoverAndTheBarCursorCompose(t *testing.T) {
t.Fatalf("the hover moved the cursor: it is on %q (up=%v)", a.bar.at.word(), a.bar.on)
}
// Both marks are on the one row, and the row still says the same four words.
- row := strings.Split(mustFrame(a), "\n")[placeTabRow]
+ row := strings.Split(mustFrame(a), "\n")[navRow]
for _, id := range barPages(a.page, false) {
if !strings.Contains(plain(row), id.word()) {
t.Fatalf("the bar lost %q while wearing two marks: %q", id.word(), plain(row))
@@ -380,20 +389,20 @@ func TestTheHoverAndTheBarCursorCompose(t *testing.T) {
func TestTheWheelOverTheBarWalksThePlaces(t *testing.T) {
a := placeApp(t)
a.frame()
- drive(t, a, tea.MouseWheelMsg{X: 4, Y: placeTabRow, Button: tea.MouseWheelDown})
+ drive(t, a, tea.MouseWheelMsg{X: 4, Y: navRow, Button: tea.MouseWheelDown})
if want := nextPage(pageHome, false); a.page != want {
t.Fatalf("a wheel notch over the bar landed on %q, want %q", a.page.word(), want.word())
}
a.frame()
- drive(t, a, tea.MouseWheelMsg{X: 4, Y: placeTabRow, Button: tea.MouseWheelUp})
+ drive(t, a, tea.MouseWheelMsg{X: 4, Y: navRow, Button: tea.MouseWheelUp})
if a.page != pageHome {
t.Fatalf("the wheel back up landed on %q, want home again", a.page.word())
}
// AND ONE ROOM A TICK AND NOT THREE. Three would open two rooms nobody asked
// to see on the way to the third, and each opening throws away a filter.
a.frame()
- drive(t, a, tea.MouseWheelMsg{X: 4, Y: placeTabRow, Button: tea.MouseWheelDown})
- drive(t, a, tea.MouseWheelMsg{X: 4, Y: placeTabRow, Button: tea.MouseWheelDown})
+ drive(t, a, tea.MouseWheelMsg{X: 4, Y: navRow, Button: tea.MouseWheelDown})
+ drive(t, a, tea.MouseWheelMsg{X: 4, Y: navRow, Button: tea.MouseWheelDown})
if want := pages()[2]; a.page != want {
t.Fatalf("two notches walked to %q, want %q", a.page.word(), want.word())
}
diff --git a/internal/tui3/placebodies.go b/internal/tui3/placebodies.go
index d9e3125500..8f8506cb66 100644
--- a/internal/tui3/placebodies.go
+++ b/internal/tui3/placebodies.go
@@ -36,16 +36,23 @@ import (
// Both keep their bodies exactly as they drew them. What changed is the frame
// around them and the keyboard, which is the whole of what that wave claimed.
-// placeHeadRows is how many rows every place spends before its body: the pulse,
-// the tab bar, the rule, and the blank under it (pages.go's [placeFrame]).
+// placeHeadRows is how many rows every place spends before its body: the nav,
+// the rule, and the blank under it (pages.go's [placeFrame]).
+//
+// THE STRIP IS NOT ONE OF THEM. It is a chat's own row, drawn only while a
+// conversation is in front (head.go), so a place's body starts one row higher
+// than a chat's and a click on that row is the page's.
//
// IT IS A CONSTANT AND THE POINTER DEPENDS ON IT. A press arrives as a row of
// the terminal and has to become a row of the body, and the only honest way to
-// subtract the head is to have exactly one number for how tall the head is —
-// which is also why the tab bar took the blank row home used to draw rather than
-// being added under it. A fifth head row is a change to this constant and to
-// nothing else.
-const placeHeadRows = 4
+// subtract the head is to have exactly one number for how tall the head is.
+// A head row added or removed is a change to this constant and to nothing else.
+const placeHeadRows = 3
+
+// chatHeadRows is the head while a conversation is in front: the places' three
+// rows with the strip between the nav and the rule. A room inside a chat wears
+// it too. A place does not.
+const chatHeadRows = placeHeadRows + 1
// placeNote is the one line a place says about what it is holding, drawn under
// the rule and above the composer (pages.go's [placeFrame] states the law).
diff --git a/internal/tui3/placecounts_test.go b/internal/tui3/placecounts_test.go
index fb40cd3132..f0614e149a 100644
--- a/internal/tui3/placecounts_test.go
+++ b/internal/tui3/placecounts_test.go
@@ -58,7 +58,7 @@ func TestATabWearsWhatChangedSinceYouLeftThatPlace(t *testing.T) {
// MEMORY IS OFF THE BAR, SO ITS NUMBER IS ON THE MAP — the one row that
// draws every place with its digit — and on the bar the moment you stand in
// it (pages.go's [barPages]).
- if bar := plain(a.placeTabBar(160, true, a.pal)); !strings.Contains(bar, itoa(placeDigitOf(pageMemory))+" memory 3") {
+ if bar := navPlaces(a, 160, true); !strings.Contains(bar, itoa(placeDigitOf(pageMemory))+" memory 3") {
t.Fatalf("the map does not carry the count: %q", bar)
}
}
@@ -76,7 +76,7 @@ func TestAPlaceWithNoLookStampWearsNoNumber(t *testing.T) {
if len(brain.asked) != 0 {
t.Fatalf("the store was asked for a delta with no origin to measure from: %v", brain.asked)
}
- bar := plain(a.placeTabBar(160, false, a.pal))
+ bar := navPlaces(a, 160, false)
for _, digit := range "0123456789" {
if strings.ContainsRune(bar, digit) {
t.Fatalf("the bar wears a figure with no origin behind it: %q", bar)
diff --git a/internal/tui3/placeeverymouse_test.go b/internal/tui3/placeeverymouse_test.go
index a5b4be312f..f2af75681c 100644
--- a/internal/tui3/placeeverymouse_test.go
+++ b/internal/tui3/placeeverymouse_test.go
@@ -39,7 +39,7 @@ func TestClickingATabWordWorksFromEveryPlace(t *testing.T) {
if !ok {
t.Fatalf("the %s tab is not on the bar the %s place drew", target.word(), place.id.word())
}
- if a.tabRow != placeTabRow {
+ if a.tabRow != navRow {
t.Fatalf("the %s place drew its tab bar on row %d", place.id.word(), a.tabRow)
}
drive(t, a, tea.MouseClickMsg{X: x, Y: a.tabRow, Button: tea.MouseLeft})
diff --git a/internal/tui3/placeeveryone_test.go b/internal/tui3/placeeveryone_test.go
index e00d816c21..15261bcf05 100644
--- a/internal/tui3/placeeveryone_test.go
+++ b/internal/tui3/placeeveryone_test.go
@@ -63,6 +63,12 @@ func everyPlaceTable() []everyPlace {
return hits
},
},
+ {
+ id: pageTeams,
+ open: teamsPlaceLab,
+ cursor: teamsCursorLine,
+ hits: teamsHits,
+ },
{
id: pageTasks,
open: func(t *testing.T) *app { return historyApp(t, 200) },
@@ -293,7 +299,7 @@ func TestTabLeavesEveryPlaceAndComesBack(t *testing.T) {
}
}
-// alt+1…8 JUMPS FROM EVERY PLACE. The numbers are the bar's own order and they
+// alt+1…9 JUMPS FROM EVERY PLACE. The numbers are the bar's own order and they
// mean the same thing wherever you are standing — or, for a room with nothing
// in it, they say why and leave you where you were. What they may never do is
// nothing at all.
diff --git a/internal/tui3/placehost_test.go b/internal/tui3/placehost_test.go
index fd9573278d..72f409cf3c 100644
--- a/internal/tui3/placehost_test.go
+++ b/internal/tui3/placehost_test.go
@@ -164,7 +164,7 @@ func TestTheTasksPlaceOverHostDrawsTheFarMachinesWork(t *testing.T) {
// Settings is NOT one of them, and that is the point of the default being "".
// Every row on it is either this surface's own or is read from the far machine's
-// profile, and it already says so as it opens ([settingsRemoteWord]) — a place
+// profile, and it already says so as it opens ([settingsHostNote]). A place
// that drew one dim line instead would have taken working rows away.
func TestTheSettingsPlaceIsNotGatedOverHost(t *testing.T) {
a := hostedPlaceLab(t)
diff --git a/internal/tui3/placejump_test.go b/internal/tui3/placejump_test.go
index 12503d1e9e..e57adc1131 100644
--- a/internal/tui3/placejump_test.go
+++ b/internal/tui3/placejump_test.go
@@ -5,7 +5,7 @@ import (
"testing"
)
-// ── alt+1…8 IS THE ONE CLASS THAT BELONGS TO NO PLACE ───────────────────────
+// ── alt+1…9 IS THE ONE CLASS THAT BELONGS TO NO PLACE ───────────────────────
//
// placeeveryone_test.go asks the numbers of all seven ROOMS. Nothing asked them
// of the surface a person spends most of their time on: the conversation. And
diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go
index 28b666040f..98339f28c4 100644
--- a/internal/tui3/placekeys.go
+++ b/internal/tui3/placekeys.go
@@ -20,7 +20,7 @@ import (
// ↑↓ enter esc tab move, open, back out, next place never text
// any printable goes to the composer, always never a verb
// alt+enter send what you typed off as a task one chord
-// alt+1…8 jump straight to a place drawn on the map
+// alt+1…9 jump straight to a place drawn on the map
// alt+ change how THIS place is shown drawn on the map
// shift+←→↑↓ move this place's time window no letters spent
// → then a letter act on the row — letters are verbs only here
diff --git a/internal/tui3/placelaws_test.go b/internal/tui3/placelaws_test.go
index e103b66cd1..06a5aa4f26 100644
--- a/internal/tui3/placelaws_test.go
+++ b/internal/tui3/placelaws_test.go
@@ -194,7 +194,7 @@ func TestNoPlaceFileMentionsTheBar(t *testing.T) {
// forbidden is the ROUTER'S bar reaching into a place file, and each of these
// names one piece of it.
forbidden := []string{"barCursor", "a.bar.", "barRaise", "barDrop", "barWalk",
- "barEnter", "barReach", "barKey(", "tabHover", "placeTabBar", "tabBarAt"}
+ "barEnter", "barReach", "barKey(", "tabHover", "navLine", "navPress"}
for _, name := range placeSourceFiles(t) {
if !strings.HasPrefix(name, "place_") {
continue
@@ -322,7 +322,7 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) {
// doors rather than counted out again here.
wantFor := func(id page, size [2]int) edges {
height := size[1]
- got := edges{bar: placeTabRow, headRule: 2, blank: placeHeadRows - 1,
+ got := edges{bar: navRow, headRule: 1, blank: placeHeadRows - 1,
footRule: height - placeFootRowsFor(id, height) + 1, box: -1, hint: height - 1}
if id == pageHome {
got.box = height - 1 - boxFloor(height)
@@ -339,8 +339,8 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) {
rows[i] = ansi.Strip(line)
}
got := edges{bar: a.tabRow, headRule: -1, blank: -1, footRule: -1, box: -1, hint: len(rows) - 1}
- if strings.HasPrefix(rows[2], "──") {
- got.headRule = 2
+ if len(rows) > 1 && strings.HasPrefix(rows[1], "──") {
+ got.headRule = 1
}
if strings.TrimSpace(rows[placeHeadRows-1]) == "" {
got.blank = placeHeadRows - 1
diff --git a/internal/tui3/placemouse.go b/internal/tui3/placemouse.go
index 2fd748e45b..fc63a1db85 100644
--- a/internal/tui3/placemouse.go
+++ b/internal/tui3/placemouse.go
@@ -10,14 +10,15 @@ import (
// Three gestures, one law each, and every one of them is the same law on all
// seven places:
//
-// a press on a tab word goes to that place
+// a press on a tab word goes to that place (topnav.go's [app.navPress])
// a press on a body row is `enter` on it: the cursor lands and the row
// opens, and a click never spends (pages.go's
// [place.press])
// the pointer resting previews the row it is over, and moves nothing —
-// over a tab word, that preview is the word's own ink
+// over a tab word, that preview is the word's
+// hover ground (topnav.go's [app.navHover])
// the wheel walks the cursor, three rows a tick, and over the
-// tab bar walks the places, one room a tick
+// nav walks the places, one room a tick
//
// THIS FILE IS THE ARITHMETIC AND NEVER THE PLACES. Which place answers which
// gesture is pages.go's — it is the one file that may know them all by name —
@@ -31,13 +32,6 @@ import (
// body scrolls, so a span or a line computed independently would open the wrong
// room or act on the wrong row exactly when a person could not tell why.
-// placeTabRow is the row of the frame the tab bar is drawn on: under the pulse
-// and over the rule (pages.go's [placeFrame] draws the ladder). The frame
-// records where it actually landed in [app.tabRow], because a frame too short
-// for its own contents drops it — this is the number that is true on every
-// frame that has one.
-const placeTabRow = 1
-
// placeWheelRows is how far one turn of the wheel walks a place's cursor. It is
// three because three is what every other list on this surface moves by
// (app.go's wheel ladder, copy mode, the task page, home), and a wheel that
@@ -57,77 +51,8 @@ func placeWheelDelta(button tea.MouseButton) int {
return 0
}
-// placeTabPress is a press on the tab bar: the place whose chip it landed in.
-//
-// A TAB WORD IS A DOOR. The bar names seven rooms and bands the one you are
-// standing in; a bar that answered a press with nothing would be seven labels,
-// which is what the owner met in the built binary. The gap between two chips
-// belongs to no room and is swallowed — a bar that rounded a miss to its
-// nearest neighbour would open the wrong place for a one-cell slip.
-//
-// AND PRESSING THE PLACE YOU ARE ALREADY IN DOES NOTHING AT ALL. Going there is
-// closing and reopening it, which throws away the filter somebody typed and the
-// row they were standing on; pressing where you are standing is not a gesture.
-func (a *app) placeTabPress(x, y int) (tea.Cmd, bool) {
- // ROW ZERO IS THE PULSE AND CAN NEVER BE THE BAR, so anything under one is
- // "no frame has drawn a bar yet" — the state a window is in before its first
- // paint, and the state [app.frame] puts it back into for every surface that
- // does not go through [placeFrame].
- if !a.pageShowing() || a.tabRow < 1 || y != a.tabRow {
- return nil, false
- }
- for _, span := range a.tabs {
- if x < span.from || x >= span.to {
- continue
- }
- if span.id == a.page {
- return nil, true
- }
- return a.showPage(span.id), true
- }
- // THE REST OF THE ROW IS STILL THE BAR'S. A press in the gap, or out past
- // the last chip, is a press on a row that has nothing under it — swallowing
- // it is what stops it falling through to a body row it visually is not.
- return nil, true
-}
-
-// placeTabHover is the pointer resting over the tab bar: THE WORD UNDER IT LIFTS
-// ONE INK TIER AND NOTHING ELSE ON THE FRAME MOVES.
-//
-// A TAB WORD IS A DOOR AND A DOOR SHOULD LOOK BACK. The bar answered a press
-// ([app.placeTabPress]) and said nothing at all while a pointer crossed it, so
-// seven words that open seven rooms read as a label strip until somebody
-// gambled a click on one. The lift is the selected word's own ink and weight
-// with no band under it (pages.go's [app.tabBarAt]) — a band would make the
-// hovered word look like the room a person is standing in, and a bar with two
-// grounds on it says nothing clearly.
-//
-// Hovering a tab changes no cursor, place or window. Clicking opens the place;
-// leaving the bar puts the ink back.
-func (a *app) placeTabHover(x, y int) bool {
- if !a.pageShowing() || a.tabRow < 1 || y != a.tabRow {
- // THE POINTER LEAVING THE ROW IS NEWS TOO, and it is the half that is easy
- // to forget: a word left lifted after the hand moved away is a door that
- // claims to be under a pointer that is somewhere else.
- a.barHover(pageNone)
- return false
- }
- under := pageNone
- for _, span := range a.tabs {
- if x >= span.from && x < span.to {
- under = span.id
- break
- }
- }
- a.barHover(under)
- // THE ROW IS THE BAR'S WHETHER OR NOT A WORD WAS UNDER THE POINTER, for
- // [app.placeTabPress]'s reason: the gap between two chips belongs to no room,
- // and letting it fall through would light a body row the pointer visually is
- // not on.
- return true
-}
-
-// placeTabWheel is the wheel turned over the tab bar: IT WALKS THE PLACES.
+// placeTabWheel is the wheel turned over the nav's row on a place: IT WALKS
+// THE PLACES.
//
// The wheel means "the next one of these" everywhere else on this surface — it
// walks a list's cursor, three rows a tick (placeWheelRows) — and over a row
@@ -139,7 +64,7 @@ func (a *app) placeTabHover(x, y int) bool {
// open two rooms nobody asked to see on its way to the third, and each of those
// openings closes the last room and throws away its filter.
func (a *app) placeTabWheel(y int, delta int) (tea.Cmd, bool) {
- if !a.pageShowing() || a.tabRow < 1 || y != a.tabRow || delta == 0 {
+ if !a.pageShowing() || a.tabRow < 0 || y != a.tabRow || delta == 0 {
return nil, false
}
return a.walkPage(delta < 0), true
diff --git a/internal/tui3/placemouse_test.go b/internal/tui3/placemouse_test.go
index 4069439883..d1d9046fb4 100644
--- a/internal/tui3/placemouse_test.go
+++ b/internal/tui3/placemouse_test.go
@@ -72,7 +72,7 @@ func TestClickingATabWordGoesToThatPlace(t *testing.T) {
if !ok {
t.Fatal("the spend tab is not on this bar")
}
- drive(t, a, tea.MouseClickMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: x, Y: navRow, Button: tea.MouseLeft})
if a.page != pageSpend {
t.Fatalf("clicking the spend tab left the router on %q", a.page.word())
}
@@ -86,23 +86,25 @@ func TestClickingATabWordGoesToThatPlace(t *testing.T) {
// The spend place has no box, so what a reopen would reset is its cursor.
a.moveSpend(1)
was := a.spend.cursor
- drive(t, a, tea.MouseClickMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: x, Y: navRow, Button: tea.MouseLeft})
if a.spend.cursor != was {
t.Fatalf("pressing the tab you are on reopened the place: the cursor moved from %d to %d", was, a.spend.cursor)
}
}
-// AND A PRESS BETWEEN TWO WORDS DOES NOTHING. The gap belongs to no room, and
-// a bar that rounded a miss to its nearest neighbour would be a bar that opens
-// the wrong place for a one-cell slip.
+// AND A PRESS IN THE NAV'S AIR DOES NOTHING. Two words' buttons touch, each
+// owning its own pad cells, so the air on the row is the cells before the
+// first button and after the last; they belong to no room, and a row that
+// rounded a miss to its nearest neighbour would open the wrong place for a
+// one-cell slip.
func TestClickingBetweenTwoTabsDoesNothing(t *testing.T) {
a := placeApp(t)
placeFrameText(a)
gap, ok := placeTabGapColumn(a)
if !ok {
- t.Fatal("this bar has no gap between two chips")
+ t.Fatal("this nav has no air before its first button")
}
- drive(t, a, tea.MouseClickMsg{X: gap, Y: placeTabRow, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: gap, Y: navRow, Button: tea.MouseLeft})
if a.page != pageHome {
t.Fatalf("a press in the gap moved to %q", a.page.word())
}
@@ -126,7 +128,7 @@ func TestAFrameWithNoTabBarHasNoTabToPress(t *testing.T) {
if a.tabRow >= 0 {
t.Fatalf("the phone frame claims a tab bar on row %d", a.tabRow)
}
- drive(t, a, tea.MouseClickMsg{X: 4, Y: placeTabRow, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: 4, Y: navRow, Button: tea.MouseLeft})
if a.page != pageHome {
t.Fatalf("a press on the phone frame's second row went to %q", a.page.word())
}
@@ -142,16 +144,18 @@ func placeTabColumnOf(a *app, id page) (int, bool) {
return 0, false
}
-// placeTabGapColumn is a cell between two chips: the last column before the
-// second chip begins, which no chip claims.
+// placeTabGapColumn is a cell of the nav's air: the last column before the
+// first button, between it and the wordmark, which no button claims.
func placeTabGapColumn(a *app) (int, bool) {
- if len(a.tabs) < 2 {
+ if len(a.tabs) < 1 || a.tabs[0].from < 1 {
return 0, false
}
- if a.tabs[1].from-1 < a.tabs[0].to {
- return 0, false
+ for _, span := range a.tabs {
+ if a.tabs[0].from-1 >= span.from && a.tabs[0].from-1 < span.to {
+ return 0, false
+ }
}
- return a.tabs[1].from - 1, true
+ return a.tabs[0].from - 1, true
}
// ── the wheel ───────────────────────────────────────────────────────────────
diff --git a/internal/tui3/planrail.go b/internal/tui3/planrail.go
index 8f00e64b97..e6fd8ad369 100644
--- a/internal/tui3/planrail.go
+++ b/internal/tui3/planrail.go
@@ -12,8 +12,8 @@ package tui3
// SO THERE IS ONE RENDERER AND THIS FILE IS ITS ADAPTER. A store row is lent a
// [taskNode] ([planRailNode]) carrying only what the store knows — its title,
// its state, when it started, what it has cost, and the step it is running —
-// and that node is drawn by [app.railEntryRows], the function every node row on
-// the column is drawn by. A figure the store does not keep, such as tokens or
+// and that node is drawn by [app.railEntryRow], the function every node row on
+// the side column is drawn by: one line, its glyph, its name and its time. A figure the store does not keep, such as tokens or
// the model, is left unset, and the renderer's emptiness law draws nothing for
// it rather than a zero.
@@ -221,22 +221,39 @@ func planTwigsOf(rows []session.PlanTaskRow) []*planTwig {
}
// planRailLines draws a run's parts under a row, each one THROUGH THE NODE
-// RENDERER and in the old tree's connectors: `stems` is the ancestry of the row
-// they hang from, and `more` says whether rows of that one's own come after
-// them, so the last part closes its branch only when nothing else hangs there.
+// RENDERER and one line each, as every task on the side column is (sidecol.go):
+// depth is how far under the row they hang, two cells a level, which is the
+// only shape the column gives a family now that its forest is gone.
//
// Every line a part draws carries its store id, which is what makes it a door
// onto that task's page ([app.openRailPlan]).
-func (a *app) planRailLines(kids []*planTwig, stems []bool, more bool, width int) []railLine {
+func (a *app) planRailLines(kids []*planTwig, depth, width int) []railLine {
var out []railLine
- for i, kid := range kids {
- after := i < len(kids)-1 || more
- at := append(append([]bool(nil), stems...), after)
- rows, _, _ := a.railEntryRows(railEntry{node: planRailNode(kid.row), stems: at, root: len(kid.kids) > 0}, width)
- for j, text := range rows {
- out = append(out, railLine{text: text, entry: -1, plan: kid.row.ID, head: j == 0})
+ lead := strings.Repeat(" ", depth)
+ for _, kid := range kids {
+ text := a.railEntryRow(railEntry{node: planRailNode(kid.row)}, max(width-len(lead), 0))
+ out = append(out, railLine{text: lead + text, entry: -1, plan: kid.row.ID, head: true})
+ out = append(out, a.planRailLines(kid.kids, depth+1, width)...)
+ }
+ return out
+}
+
+// planPageLines is [app.planRailLines] for a task's page, which has the room
+// the side column gave up: under each part's one line stand the lines the
+// column moved to its hint ([app.railUnder]), what the part is doing and what
+// it is costing, so the page still names a call in flight the way the rail
+// once did beside the row.
+func (a *app) planPageLines(kids []*planTwig, depth, width int) []railLine {
+ var out []railLine
+ lead := strings.Repeat(" ", depth)
+ room := max(width-len(lead), 0)
+ for _, kid := range kids {
+ node := planRailNode(kid.row)
+ out = append(out, railLine{text: lead + a.railEntryRow(railEntry{node: node}, room), entry: -1, plan: kid.row.ID, head: true})
+ for _, under := range a.railUnder(node, max(room-4, 0)) {
+ out = append(out, railLine{text: lead + " " + under, entry: -1, plan: kid.row.ID})
}
- out = append(out, a.planRailLines(kid.kids, at, false, width)...)
+ out = append(out, a.planPageLines(kid.kids, depth+1, width)...)
}
return out
}
@@ -244,10 +261,7 @@ func (a *app) planRailLines(kids []*planTwig, stems []bool, more bool, width int
// planRailRoot draws one run whose own row no node on the column carries: its
// row, then its parts, every one of them through the node renderer.
func (a *app) planRailRoot(twig *planTwig, width int) []railLine {
- rows, _, _ := a.railEntryRows(railEntry{node: planRailNode(twig.row), root: len(twig.kids) > 0}, width)
- out := make([]railLine, 0, len(rows))
- for j, text := range rows {
- out = append(out, railLine{text: text, entry: -1, plan: twig.row.ID, head: j == 0})
- }
- return append(out, a.planRailLines(twig.kids, nil, false, width)...)
+ text := a.railEntryRow(railEntry{node: planRailNode(twig.row)}, width)
+ out := []railLine{{text: text, entry: -1, plan: twig.row.ID, head: true}}
+ return append(out, a.planRailLines(twig.kids, 1, width)...)
}
diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go
index c3abd10f8c..2cd6fdc5b9 100644
--- a/internal/tui3/planroom.go
+++ b/internal/tui3/planroom.go
@@ -360,8 +360,8 @@ func (a *app) planRoomStopTarget() stopTarget {
const planRoomPartsWord = "under it"
// planPageKinWidth is the most a task's room spends on one row of its parts. A
-// part's row is the rail's row, whose handle stands at the row's far end; on a
-// page the width of the terminal that handle would sit a screen away from the
+// part's row is the side column's row, whose time stands at the row's far end; on
+// a page the width of the terminal that time would sit a screen away from the
// title it belongs to, so the rows are drawn at a width a column could have.
const planPageKinWidth = 64
@@ -377,9 +377,11 @@ const planRoomWaitsWord = "waits"
// planRoomPartRows is what the task hangs on and what hangs under it, under the
// transcript: the tasks it waits on and the tasks waiting on it, its own first,
-// then its parts, each drawn as the rail draws a task ([app.planRailLines], the
-// row shape #1420 settled on). A section with nothing in it is absent. Every
-// row is a door: a press opens that task's room ([app.press]).
+// then its parts, each drawn as the side column draws a task: through the node
+// renderer, one line with its state glyph, its name and its time, a part's own
+// parts a level in, and under it its call and its clock and money
+// ([app.planPageLines]). A section with nothing in it is absent. Every row is a
+// door: a press opens that task's room ([app.press]).
func (a *app) planRoomPartRows(width int) []row {
plan := a.roomPlan()
if plan == nil {
@@ -404,7 +406,7 @@ func (a *app) planRoomPartRows(width int) []row {
}
if len(plan.page.Children) > 0 {
out = append(out, row{entry: -1}, row{text: a.pal.dim(fit(planRoomPartsWord, width)), entry: -1})
- for _, line := range a.planRailLines(planTwigsOf(plan.page.Children), nil, false, min(width, planPageKinWidth)) {
+ for _, line := range a.planPageLines(planTwigsOf(plan.page.Children), 0, min(width, planPageKinWidth)) {
out = append(out, row{text: line.text, entry: -1, plan: line.plan})
}
}
diff --git a/internal/tui3/pulse.go b/internal/tui3/pulse.go
index ae4944dc1c..11b940d468 100644
--- a/internal/tui3/pulse.go
+++ b/internal/tui3/pulse.go
@@ -1,12 +1,13 @@
package tui3
-// THE PULSE: THE ONE LINE AT THE TOP OF EVERY FRAME.
+// THE PULSE: THE FAR END OF THE LINE AT THE TOP OF EVERY FRAME.
//
-// codeaf 2 want you · 4 moving · $0.55 / $20.00 · tue 1:11pm
-// codeaf $0.55 / $20.00 · tue 1:11pm
+// >● codeaf home teams chats … 2 want you · 4 moving · $0.55 / $20.00 · tue 1:11pm
+// >● codeaf home teams chats … $0.55 / $20.00 · tue 1:11pm
//
-// The program's name on the left, and right-aligned on the other end — quiet
-// unless it has a reason not to be — the machine's own vital signs. The first
+// The program's name and the places on the left (topnav.go), and right-aligned
+// on the other end, quiet unless it has a reason not to be, the machine's own
+// vital signs. The first
// line is a conversation's and every place's; the second is home's, which leaves
// its counts to the panels under it (DESIGN.md's law 11, [pulseBudget]). It is the
// watch made visible (docs/HOME-BRIDGE.md): a person who has just sat down learns
@@ -50,7 +51,21 @@ package tui3
// sheds a word about the machine. (What this law used to say was "THE CLOCK
// ALWAYS DRAWS", and it was read as a width law as well as an emptiness law,
// which is how sixty columns came to spend twelve cells on `thu 12:01am`
-// while the whole right end went unwritten — see [app.pulseRungs].)
+// while the whole right end went unwritten.)
+//
+// - THE LADDER IS THE NAV'S NOW (topnav.go's [app.navTails]). The line shares
+// its row with the places, and the owner ruled the order the row gives
+// things up in (2026-09-24): the clock, then the moving count, then the
+// words of `2 want you` (never its count), then the places fold, and the
+// day's figure is the last clause but one standing.
+//
+// - AND THE COUNT THAT WANTS YOU NEVER LEAVES THE ROW. It was allowed to,
+// for one wave, on the argument that a place's own rows and the strip's
+// marks say it too; at eighty columns that left a conversation's frame
+// with no number anywhere on its top line for the things stopped on the
+// person reading it, which is the one fact a glance at this row is for.
+// So `2 want you` shortens to `2 ?` in the same amber before anything
+// else is given up, and it outlasts the money and the places.
//
// - THE ALLOWANCE IS A FRACTION HERE, AND THE OWNER OVERRULED THIS FILE TO PUT
// IT THERE. What stood here for four waves was the opposite law, and it read:
@@ -78,8 +93,6 @@ package tui3
import (
"strings"
"time"
-
- "github.com/charmbracelet/x/ansi"
)
// The words the pulse says, quoted in internal/manual/chat/home.md exactly as
@@ -124,110 +137,6 @@ const (
pulseBudget
)
-// pulseLine is that line, painted, exactly `width` cells wide at most.
-//
-// THE NAME NEVER GIVES WAY TO THE SEGMENTS. A frame too narrow to hold both
-// draws the name alone: the segments are a glance somebody takes and the name is
-// what tells them which program they are looking at, and a top line that clipped
-// the second to fit the first would have got the order of those two backwards.
-//
-// AND THE SEGMENTS GIVE WAY ONE AT A TIME, BY RANK. This line used to be drawn
-// ALL OR NOTHING: everything, or the name alone. So a sixty-column frame — a
-// split pane, an ssh session from a train — spent twelve of its cells on
-// `thu 12:01am` and then, one segment later, threw the whole right end away and
-// said nothing about the machine at all. It walks [app.pulseRungs] now, which is
-// [rowfit.go]'s ranked-prefix law applied to this line: the widest rung that
-// fits is the one drawn, and what a narrow frame shows is a SUBSET of what a
-// wide one shows rather than a different line.
-func (a *app) pulseLine(width int, pal palette, mode pulseMode) string {
- // THE NAME IS STRUCTURE, SO IT WEARS A QUIET ROLE. THE ACCENT BUDGET IS ONE
- // THING PER SCREEN and it is always the live one — the row waiting on
- // somebody, the work in flight, the card under the cursor. A product name is
- // none of those: it is the same word on every frame home has ever drawn,
- // which is the definition of a thing the eye learns to skip, and spending the
- // loudest hue on it left home with two places claiming to be first.
- //
- // So it takes the second tier — one rung above the margin it shares the line
- // with, so it still reads as the line's head — and keeps the WEIGHT, which is
- // what says "this is the title" on a sixteen-colour terminal that has no rungs
- // to spend.
- // AND IT IS THE ONE NAME, READ OFF THE ONE CONSTANT (styles.go's [product]).
- // This line used to hold a second spelling of its own (`pulseName`), which is
- // how the surface came to greet a fresh install with one name in the wordmark
- // and another in the prose under it.
- name := " " + pal.wordmark(width)
- for _, tail := range a.pulseRungs(a.now(), pal, mode) {
- if tail == "" {
- break
- }
- gap := width - ansi.StringWidth(name) - ansi.StringWidth(tail) - 1
- if gap >= 1 {
- return name + strings.Repeat(" ", gap) + tail
- }
- }
- return name
-}
-
-// pulseRungs is every line the right end of the pulse is allowed to be, WIDEST
-// FIRST, and it is where this file's ranking is written down.
-//
-// ── THE RANK, AND THE ARGUMENT FOR IT ──
-//
-// From the top of the ladder down, the order things are given up in is: the
-// clock, then the day's ALLOWANCE, then the day's SPEND, then what is moving,
-// and the count of what has stopped on a person is the last thing on the line to
-// go. THE ARGUMENT IS ONE SENTENCE: the terminal's own bar, the window manager
-// and the wall clock all say what time it is, and nothing anywhere else on this
-// machine says that two pieces of work have stopped and will not move until
-// somebody looks — so a cell that could carry either carries the one that is
-// only available here, and the whole ladder falls out of ranking the segments by
-// how much a person could have learned that fact any other way.
-//
-// Within that, `2 want you` outranks `4 moving` because a thing that has stopped
-// needs a person and a thing in flight does not, which is the sort order of the
-// list underneath this line said again; and the spend outranks the allowance
-// because a figure is a fact and a fraction is that fact plus a bound, so the
-// bound is the half that can go while the clause still says something true.
-//
-// ── AND A DROPPED SEGMENT MAY NOT MAKE THE LINE LIE ──
-//
-// This is the constraint that shapes the money rungs. The allowance is dropped
-// by RESPELLING the money clause — `$0.55 / $20.00` becomes `$0.55`, which is
-// what this line said for four waves and is true — and the spend is dropped by
-// removing the clause whole, which leaves no `$` on the line at all. What must
-// never happen is a narrow frame drawing `$0.00`, or a `/ $20.00` with nothing
-// in front of it: either would be the line reporting a figure it had actually
-// given up on. (The ONE sanctioned `$0.00` on this surface is the live status
-// line of a conversation, so its segments do not jump sideways as money arrives
-// — that is what [dollars] returning `$0.00` at zero is for. It is not this
-// line, and this line never borrows the exception: the emptiness law keeps a
-// zero day off the pulse ([app.pulseParts]) and the ladder never puts one back.
-func (a *app) pulseRungs(now time.Time, pal palette, mode pulseMode) []string {
- p := a.pulseParts(now, pal, mode)
- rung := func(parts ...string) string {
- var kept []string
- for _, part := range parts {
- if part != "" {
- kept = append(kept, part)
- }
- }
- return strings.Join(kept, pulseGap)
- }
- // The rungs, in the order the ladder is climbed down. Two neighbours can come
- // out identical — a machine with no allowance set has one money spelling, a
- // quiet day has none — and a rung that is the same string as the one above it
- // simply fails the same measurement twice, which costs nothing and keeps the
- // ladder readable as the list of decisions it is.
- return []string{
- rung(p.wants, p.hands, p.money, p.clock),
- rung(p.wants, p.hands, p.money),
- rung(p.wants, p.hands, p.spend),
- rung(p.wants, p.hands),
- rung(p.wants),
- "",
- }
-}
-
// pulseSegments is what the right of the line says, in order and already
// painted, with every segment that is not true left out.
//
@@ -238,8 +147,8 @@ func (a *app) pulseRungs(now time.Time, pal palette, mode pulseMode) []string {
// all three at once, which is why the design put them here — it is the whole
// machine in four clauses.
//
-// It is the TOP RUNG of [app.pulseRungs] and it is built out of the same pieces,
-// so the widest line this file can draw and the line the ladder starts from can
+// It is the TOP RUNG of the nav's ladder ([app.navTails]) and it is built out of
+// the same pieces, so the widest line and the line the ladder starts from can
// never come to disagree about how a segment is spelled.
func (a *app) pulseSegments(now time.Time, pal palette) []string {
p := a.pulseParts(now, pal, pulseWhole)
@@ -254,12 +163,18 @@ func (a *app) pulseSegments(now time.Time, pal palette) []string {
// pulseParts is every clause the top line can carry, each already painted and
// each "" where the emptiness law says it is not true. It is ONE FUNCTION rather
-// than two so that [app.pulseSegments] and [app.pulseRungs] cannot drift.
+// than two so that [app.pulseSegments] and [app.navTails] cannot drift.
//
// `money` and `spend` are the two spellings of one clause — the fraction and the
// figure — and they are the ladder's way of giving up the allowance without
// giving up the day's bill.
-type pulseParts struct{ wants, hands, money, spend, clock string }
+//
+// `wants` and `ask` are the two spellings of the other clause that has two:
+// `2 want you` and `2 ?`, the same count in the same amber, the second in the
+// mark every tab and square waiting on a person wears ([tabSignalGlyph]). It
+// is how the ladder keeps the one thing that may never vanish on a row too
+// narrow for the words.
+type pulseParts struct{ wants, ask, hands, money, spend, clock string }
func (a *app) pulseParts(now time.Time, pal palette, mode pulseMode) pulseParts {
facts := a.machine
@@ -277,6 +192,7 @@ func (a *app) pulseParts(now time.Time, pal palette, mode pulseMode) pulseParts
// loudest thing this line can say and it is the first thing on it, which
// is the sort order of the list underneath said in one segment.
p.wants = pal.warn(itoa(facts.wants) + pulseWantWord)
+ p.ask = pal.warn(itoa(facts.wants) + " " + tabSignalGlyph(tabNeedsPerson, pal.ascii))
}
if facts.hands > 0 {
// CYAN, AND THE WHOLE CLAUSE, for the same reason: in flight is one fact.
diff --git a/internal/tui3/questiondemo.go b/internal/tui3/questiondemo.go
index 3b4bfef47c..a07babda96 100644
--- a/internal/tui3/questiondemo.go
+++ b/internal/tui3/questiondemo.go
@@ -295,7 +295,7 @@ func demoStandingCard() session.Question {
Ask: session.AskChoice,
Form: session.FormCard,
Asker: session.Asker{Kind: session.AskerModel},
- Head: session.StandingAskLead + "the release notes every Monday at 9am",
+ Head: session.StandingHeadCheck,
Reason: session.StandingAskReason,
Stakes: session.StakesReversible,
// THE WORDS ARE THE CARD'S OWN CONSTANTS and never a second spelling of
diff --git a/internal/tui3/questionkeys.go b/internal/tui3/questionkeys.go
index 36e797adf5..2e709fe500 100644
--- a/internal/tui3/questionkeys.go
+++ b/internal/tui3/questionkeys.go
@@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/Agent-Field/codeaf/internal/session"
+ "github.com/Agent-Field/codeaf/internal/standing"
)
// ── ONE KEY GRAMMAR, SPELLED ONCE ───────────────────────────────────────────
@@ -491,6 +492,15 @@ const questionKeyGap = " · "
func questionVerbWord(q questionShown, verb questionVerb) string {
switch verb.key {
case questionCommentKey:
+ if q.question.Kind == session.QuestionStanding {
+ // THE CORRECTION BUTTON SAYS WHAT IT IS. A rule's is about where
+ // the rule reaches. The head is the kind, which is how this row
+ // knows without a second copy of the words.
+ if q.question.Head == session.StandingHeadRule {
+ return session.StandingChangeWord(standing.Item{When: standing.When{Kind: standing.WhenHold}})
+ }
+ return standChangeWord
+ }
if q.question.Ask == session.AskRatify {
return "change"
}
diff --git a/internal/tui3/questionnarrow.go b/internal/tui3/questionnarrow.go
index a8c205f83a..10645231ae 100644
--- a/internal/tui3/questionnarrow.go
+++ b/internal/tui3/questionnarrow.go
@@ -276,10 +276,18 @@ func (a *app) questionNarrowBody(body string, width int) []string {
// consequence the card draws beside it is dropped here: a band is a target and
// reads at a glance, and a word cut off mid-reach says less than a short one.
func (a *app) questionBandWord(word, key string, width int) string {
- if room := width - len(questionBandPad) - ansi.StringWidth(key) - 4; ansi.StringWidth(word) > room {
- return fit(word, room)
+ room := width - len(questionBandPad) - ansi.StringWidth(key) - 4
+ if ansi.StringWidth(word) <= room {
+ return word
}
- return word
+ // A YES THAT CARRIES A CADENCE LOSES THE CADENCE BEFORE ANY CHARACTER IS
+ // CUT. "Set it up · every 3 hours" becomes "Set it up", and only then is
+ // the stem truncated.
+ plain := session.StandingPlainLabel(word)
+ if ansi.StringWidth(plain) <= room {
+ return plain
+ }
+ return fit(plain, room)
}
// questionBandRow is one answer, drawn as a row: the pointer where the pointer
diff --git a/internal/tui3/quicktask_test.go b/internal/tui3/quicktask_test.go
index c802595085..01b6229dd7 100644
--- a/internal/tui3/quicktask_test.go
+++ b/internal/tui3/quicktask_test.go
@@ -71,12 +71,12 @@ func TestAQuickRowSaysWhatItIsDoingAndNamesNoBranch(t *testing.T) {
}
}
-// AND A QUICK NODE STARTED FROM A TASK HANGS UNDER THAT TASK. Nesting is
+// AND A QUICK NODE STARTED FROM A TASK IS FILED UNDER THAT TASK. Nesting is
// [session.TaskNotice.Parent] and nothing else, so a quick node the engine
-// files under its caller is drawn on the caller's stem with no surface change —
-// which is worth pinning because it is the whole of what "it shows on the rail
-// like any task" buys.
-func TestAQuickNodeUnderATaskIsDrawnOnItsParentsStem(t *testing.T) {
+// files under its caller carries its caller with no surface change. The column
+// draws what each piece of work is doing rather than a tree, so the quick node
+// is its own one-line row in the running group beside its caller.
+func TestAQuickNodeUnderATaskIsFiledUnderItsParent(t *testing.T) {
a, _, _ := taskApp(t)
a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{}))
notice := quickNotice()
@@ -87,19 +87,14 @@ func TestAQuickNodeUnderATaskIsDrawnOnItsParentsStem(t *testing.T) {
t.Fatalf("the quick node's parent is %q, want the task it was started from", got)
}
rows := railText(a, a.viewHeight())
- child, ok := railRowFor(a, a.viewHeight(), "compare files")
- if !ok {
- t.Fatalf("the quick node has no row:\n%s", strings.Join(rows, "\n"))
- }
- // THE STEM IS THE CLAIM. A row drawn flat beside its caller says the two
- // pieces of work are unrelated, which is the one thing the rail knows they
- // are not.
- if !strings.Contains(child, "└─") && !strings.Contains(child, "├─") {
- t.Fatalf("the quick node was drawn flat rather than under its parent:\n%s", strings.Join(rows, "\n"))
- }
- parent, ok := railRowFor(a, a.viewHeight(), "Ship the port")
- if !ok || strings.Contains(parent, "└─") || strings.Contains(parent, "├─") {
- t.Fatalf("the parent is not the root of the family:\n%s", strings.Join(rows, "\n"))
+ for _, title := range []string{"compare files", "Ship the port"} {
+ row, ok := railRowFor(a, a.viewHeight(), title)
+ if !ok {
+ t.Fatalf("%q has no row:\n%s", title, strings.Join(rows, "\n"))
+ }
+ if strings.Contains(row, "└─") || strings.Contains(row, "├─") {
+ t.Fatalf("the column drew a tree:\n%s", strings.Join(rows, "\n"))
+ }
}
}
diff --git a/internal/tui3/railaway_test.go b/internal/tui3/railaway_test.go
index 99cb7c3938..dbb2ad5a4c 100644
--- a/internal/tui3/railaway_test.go
+++ b/internal/tui3/railaway_test.go
@@ -41,7 +41,7 @@ func TestTheTaskColumnClosesAndReopensOnItsKey(t *testing.T) {
t.Fatal("a session with five nodes drew no column to close")
}
full := a.width
- if a.bodyWidth() != full-railCols {
+ if a.bodyWidth() != full-sideColsFor(full) {
t.Fatalf("the open column is not charged against the conversation: body=%d", a.bodyWidth())
}
@@ -54,7 +54,7 @@ func TestTheTaskColumnClosesAndReopensOnItsKey(t *testing.T) {
// glyph of state, and no task's name anywhere (task.go's [app.railGripRows])
// — which is a different thing from the column and is tested as one below.
edge := plain(strings.Join(a.railRows(a.viewHeight()), "\n"))
- for _, gone := range []string{"Ship the port", "Read the law", railStowHint} {
+ for _, gone := range []string{"Ship the port", "Read the law", sideTasksWord} {
if strings.Contains(edge, gone) {
t.Fatalf("a closed column still drew %q:\n%q", gone, edge)
}
@@ -77,7 +77,7 @@ func TestTheTaskColumnClosesAndReopensOnItsKey(t *testing.T) {
if !a.railShowing() {
t.Fatal("ctrl+g did not bring the column back")
}
- if a.bodyWidth() != full-railCols {
+ if a.bodyWidth() != full-sideColsFor(full) {
t.Fatalf("the reopened column is not charged against the conversation: body=%d", a.bodyWidth())
}
rail := strings.Join(railText(a, a.viewHeight()), "\n")
@@ -88,60 +88,41 @@ func TestTheTaskColumnClosesAndReopensOnItsKey(t *testing.T) {
}
}
-// THE TOP CONTROL NAMES THE KEY, AND THE LINE IS A BUTTON. A door that
-// only the keyboard can open is a door half this surface cannot find.
+// THE HEADER NAMES THE KEY, AND THE KEY IS A BUTTON. A door that only the
+// keyboard can open is a door half this surface cannot find.
func TestTheColumnDrawsItsOwnDoorAndThePressClosesIt(t *testing.T) {
a, _, _ := taskApp(t)
a.profileDir = t.TempDir()
railRun(a)
rows := railText(a, a.viewHeight())
- if !strings.Contains(strings.Join(rows, "\n"), railStowHint) {
- t.Fatalf("the column drew no way out of itself:\n%s", strings.Join(rows, "\n"))
- }
- // AND THE CHEVRON IS ON IT, pointing the way the column goes. It is the half
- // of that line the pointer presses, and the words are the half the keyboard
- // reads (task.go's [app.railDoorLine]).
- if !strings.Contains(strings.Join(rows, "\n"), railGripOpenGlyph+" "+railStowHint) {
- t.Fatalf("the column's door carries no chevron:\n%s", strings.Join(rows, "\n"))
+ if !strings.HasSuffix(strings.TrimRight(rows[0], " "), sideHideKey) {
+ t.Fatalf("the column's header names no way out of it: %q", rows[0])
}
// The paint is asked of the RAW rows: railText strips ANSI for reading, and
// an assertion about a palette run on stripped text passes only where the
// palette paints nothing.
painted := strings.Join(a.railRows(a.viewHeight()), "\n")
- if !strings.Contains(painted, a.pal.data(railStowKey)) ||
- !strings.Contains(painted, a.pal.dim(" hide")) {
- t.Fatalf("the close door does not use the shared hint palette:\n%q", painted)
+ if !strings.Contains(painted, a.pal.dim(sideHideKey)) {
+ t.Fatalf("the key is not in the quiet ink of a hint:\n%q", painted)
}
- // Hiding stays at the top while the task action follows the list.
+ // The header stays at the top while the task action follows the list.
lines, _ := a.railView(a.viewHeight())
- door, task := -1, -1
+ task := -1
for i, line := range lines {
- if line.stow {
- door = i
- }
if line.door == marginTaskType {
task = i
}
}
- if door != 0 || task <= door+1 {
- t.Fatalf("hide row %d is not pinned above the list and task action %d", door, task)
+ if lines[0].side == nil || lines[0].side.key != sideHeadKey || task <= 1 {
+ t.Fatalf("the header is not pinned above the list and task action %d", task)
}
- pressed := false
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if line, ok := a.railLineAt(y); ok && line.stow {
- drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
- pressed = true
- break
- }
- }
- if !pressed {
- t.Fatal("the column's door was drawn on no pressable line")
- }
+ x, y := sideDoorOf(t, a, sideActHide)
+ drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})
if !a.railAway || a.railShowing() {
- t.Fatal("a press on the door did not close the column")
+ t.Fatal("a press on the key did not close the column")
}
}
@@ -161,7 +142,7 @@ func TestAClosedColumnStillSaysWhereTheWorkIs(t *testing.T) {
if !a.railAway {
t.Fatal("ctrl+g did not close the empty column")
}
- if a.hintWord() == railBackHint {
+ if a.hintWord() == a.sideBackHint() {
t.Fatal("a session with no work at all offered the way back to a roster of nothing")
}
drive(t, a, ctrlG())
@@ -178,8 +159,8 @@ func TestAClosedColumnStillSaysWhereTheWorkIs(t *testing.T) {
if !strings.Contains(strip, "Ship the port") {
t.Fatalf("the strip named no running node:\n%s", strip)
}
- if got := a.hintWord(); got != railBackHint {
- t.Fatalf("the legend's hint reads %q, want %q", got, railBackHint)
+ if got := a.hintWord(); got != a.sideBackHint() {
+ t.Fatalf("the legend's hint reads %q, want %q", got, a.sideBackHint())
}
// AND THE STRIP'S OWN DOOR BRINGS THE COLUMN BACK. Asking for the whole
@@ -197,7 +178,7 @@ func TestAClosedColumnStillSaysWhereTheWorkIs(t *testing.T) {
func TestTheKeyFallsThroughWhereNoRosterIsOnTheFrame(t *testing.T) {
a, _, _ := taskApp(t)
a.profileDir = t.TempDir()
- // Under railSlimFloor there is no column to lend (task.go's [railColsFor]).
+ // Under railSlimFloor there is no column to lend (sidecol.go's [sideColsFor]).
a.width = railSlimFloor - 20
railRun(a)
@@ -308,7 +289,7 @@ func TestTheClosedColumnLeavesAnEdgeYouCanClick(t *testing.T) {
if a.railAway || !a.railShowing() {
t.Fatal("pressing the edge did not bring the column back")
}
- if a.bodyWidth() != full-railCols {
+ if a.bodyWidth() != full-sideColsFor(full) {
t.Fatalf("the reopened column is not charged against the conversation: body=%d", a.bodyWidth())
}
}
@@ -378,34 +359,20 @@ func TestTheChevronClosesAndOpensTheColumnByPointerAlone(t *testing.T) {
t.Fatal("a session with five nodes drew no column")
}
- // THE OPEN LEG: the door line lights under the pointer and closes on a press.
- door := -1
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if line, ok := a.railLineAt(y); ok && line.stow {
- door = y
- }
- }
- if door < 0 {
- t.Fatal("the standing column drew its door on no pressable line")
+ // THE OPEN LEG: the header's key lights under the pointer and closes the
+ // column on a press.
+ x, y := sideDoorOf(t, a, sideActHide)
+ a.setHover(x, y)
+ if a.hot.kind != hoverSide || a.hot.key != sideHeadKey || a.hot.index < 0 {
+ t.Fatalf("the pointer over the column's key lit nothing: %+v", a.hot)
}
- at := a.bodyWidth() + ansi.StringWidth(railSeam)
- a.setHover(at, door)
- if !a.hoveringRailDoor() {
- t.Fatalf("the pointer over the column's door lit nothing: %+v", a.hot)
+ if lit := strings.Join(a.railRows(a.viewHeight()), "\n"); !strings.Contains(lit, a.pal.cursor(a.pal.ink(sideHideKey), 0)) {
+ t.Fatalf("the key did not take its hover ground:\n%q", lit)
}
- lit := ""
- for _, row := range railText(a, a.viewHeight()) {
- if strings.Contains(row, railStowHint) {
- lit = row
- }
- }
- if !strings.Contains(lit, railGripOpenGlyph) {
- t.Fatalf("the lit door lost its chevron: %q", lit)
- }
- drive(t, a, tea.MouseClickMsg{X: at, Y: door, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: at, Y: door, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})
if !a.railAway || a.railShowing() {
- t.Fatal("pressing the chevron did not close the column")
+ t.Fatal("pressing the key did not close the column")
}
// THE CLOSED LEG: the same control, the other way.
@@ -416,9 +383,6 @@ func TestTheChevronClosesAndOpensTheColumnByPointerAlone(t *testing.T) {
if !strings.Contains(edge, railGripGlyph) {
t.Fatalf("the edge carries no chevron:\n%q", edge)
}
- if strings.Contains(edge, railGripOpenGlyph) {
- t.Fatalf("the edge carries the chevron of the other state:\n%q", edge)
- }
drive(t, a, tea.MouseClickMsg{X: a.bodyWidth(), Y: a.bodyTop(), Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth(), Y: a.bodyTop(), Button: tea.MouseLeft})
if a.railAway || !a.railShowing() {
diff --git a/internal/tui3/railclick_test.go b/internal/tui3/railclick_test.go
index 7048e9fd37..319c7e0252 100644
--- a/internal/tui3/railclick_test.go
+++ b/internal/tui3/railclick_test.go
@@ -71,13 +71,14 @@ func railFamily(a *app) {
unverifiedNotice("finished, but the key table still drops the last key")))
railKinship(a, 1, 2, 3, 5, 6)
railKinship(a, 3, 4)
- // AND ONE FINISHED ROW WITH NO FAMILY AROUND IT. It is the second row that
- // could fold — a landed flat row tucks its own block away
- // ([app.railTucks]) — so it is the second row whose leading cell used to
- // swallow the press.
+ // AND ONE FINISHED ROW WITH NO FAMILY AROUND IT, the other shape a row
+ // used to come in.
a.taskUpdate(update(9, "Collect the fixture sources", session.TaskDone, session.TaskNotice{
Elapsed: 8 * time.Second, Merge: mergeWordMerged,
}))
+ // EVERY GROUP OPEN, so every row these tests press is drawn: the folds are
+ // [TestAFoldIsRememberedForTheSession]'s.
+ railOpenAll(a)
a.paints = 0
}
@@ -107,80 +108,25 @@ func railClick(t *testing.T, a *app, x, y int) {
// ── DEFECT 2: THE STATE CELL IS NOT A CONTROL ───────────────────────────────
-// THE DEFECT ITSELF, ON BOTH ROWS THAT COULD FOLD. With no pointer registered on
-// the row — which is every frame after a room is opened, and every frame on a
-// terminal that is not reporting motion — the leading cell draws the node's
-// STATE, and pressing a state opens the work it is the state of.
-func TestTheStateCellOpensTheTaskWhenNoDisclosureIsDrawnOnIt(t *testing.T) {
+// THE STATE CELL OPENS THE WORK IT IS THE STATE OF, on every row. No row of
+// the list draws a disclosure any more (the folds are the group headings'),
+// so the leading cell of a running row and of a finished one is the row's
+// door like every other cell on it.
+func TestTheStateCellOpensTheTask(t *testing.T) {
a, _, _ := roomApp(t)
railFamily(a)
- glyph := a.bodyWidth() + ansi.StringWidth(railSeam)
-
- // Nothing on the column is drawn as a disclosure, which is the precondition
- // the whole test rests on: the pointer is nowhere near it.
- if drawn := strings.Join(railText(a, a.viewHeight()), "\n"); strings.Contains(drawn, glyphOpen) ||
- strings.Contains(drawn, glyphShut) {
- t.Fatalf("the column drew a disclosure with no pointer on it:\n%s", drawn)
- }
-
- // A FAMILY ROOT. Its leading cell is the spinner, so the press is a press on
- // a running task and it opens that task.
- rootY := railRowY(t, a, 1)
- railClick(t, a, glyph, rootY)
- if !a.roomOpen() || a.room.id != 1 {
- t.Fatalf("the root's state cell did not open its room: open=%v id=%d",
- a.roomOpen(), roomID(a))
- }
- if _, ok := railRowFor(a, a.viewHeight(), "Read the parser law"); !ok {
- t.Fatalf("the press folded the family instead of opening it:\n%s",
- strings.Join(railText(a, a.viewHeight()), "\n"))
- }
-
- // A FINISHED FLAT ROW. It has a block tucked under it, so it was the other
- // row whose leading cell folded rather than opening ([app.railTucks]).
- if e, ok := railEntryFor(a, 9); !ok || !a.railTucks(e) {
- t.Fatalf("the fixture's finished row has nothing tucked under it, so it "+
- "is not the row this test is about: found=%v", ok)
- }
- doneY := railRowY(t, a, 9)
- railClick(t, a, glyph, doneY)
- if !a.roomOpen() || a.room.id != 9 {
- t.Fatalf("the finished row's state cell did not open its room: open=%v id=%d",
- a.roomOpen(), roomID(a))
- }
-}
-
-// AND THE FOLD IS STILL THERE, on the cell that is drawn as one. The affordance
-// did not move: it appeared under the pointer before this change and it appears
-// under the pointer now, and now it is the ONLY thing the press reads.
-func TestTheDisclosureUnderThePointerStillFoldsAndTheRestOfTheRowStillOpens(t *testing.T) {
- a, _, _ := roomApp(t)
- railFamily(a)
- glyph := a.bodyWidth() + ansi.StringWidth(railSeam)
- rootY := railRowY(t, a, 1)
-
- a.setHover(glyph, rootY)
- if !strings.Contains(mustRailRow(t, a, "Ship the streaming"), glyphOpen) {
- t.Fatalf("the pointer on the root revealed no disclosure:\n%q",
- mustRailRow(t, a, "Ship the streaming"))
- }
- railClick(t, a, glyph, rootY)
- if a.roomOpen() {
- t.Fatalf("the disclosure walked into the room behind it: id=%d", roomID(a))
- }
- if _, ok := railRowFor(a, a.viewHeight(), "Read the parser law"); ok {
- t.Fatalf("the disclosure did not fold the family:\n%s",
- strings.Join(railText(a, a.viewHeight()), "\n"))
+ glyph := a.bodyWidth() + ansi.StringWidth(railSeam) + 2
+ if drawn := strings.Join(railText(a, a.viewHeight()), "\n"); strings.Contains(drawn, " "+glyphOpen) ||
+ strings.Contains(drawn, " "+glyphShut) {
+ t.Fatalf("a task row drew a disclosure:\n%s", drawn)
}
-
- // ONE CELL WIDE AND NO WIDER. The cell after it is the row's air, and the row
- // is the node's door — a fold that ate the space beside it would be the same
- // defect one column over.
- a.setHover(glyph+1, rootY)
- railClick(t, a, glyph+1, rootY)
- if !a.roomOpen() || a.room.id != 1 {
- t.Fatalf("the cell beside the disclosure did not open the room: open=%v id=%d",
- a.roomOpen(), roomID(a))
+ for _, id := range []uint64{1, 9} {
+ a.closeRoom()
+ railClick(t, a, glyph, railRowY(t, a, id))
+ if !a.roomOpen() || a.room.id != id {
+ t.Fatalf("node %d's state cell did not open its room: open=%v id=%d",
+ id, a.roomOpen(), roomID(a))
+ }
}
}
@@ -193,18 +139,17 @@ func TestTheRailsLeftEdgeOpensTheRowAtEveryWidthTheHandleCannotAct(t *testing.T)
for _, tc := range []struct {
width int
handle bool
- cols int
}{
- {100, false, railSlimCols},
- {119, false, railSlimCols},
- {120, true, railCols},
- {160, true, railCols},
+ {100, false},
+ {119, false},
+ {120, true},
+ {160, true},
} {
a, _, _ := roomApp(t)
a.width = tc.width
railFamily(a)
- if got := a.railWidth(); got != tc.cols {
- t.Fatalf("at %d columns the rail is %d wide, want %d", tc.width, got, tc.cols)
+ if got := a.railWidth(); got != sideColsFor(tc.width) {
+ t.Fatalf("at %d columns the rail is %d wide, want %d", tc.width, got, sideColsFor(tc.width))
}
if got := a.railCanWiden(); got != tc.handle {
t.Fatalf("at %d columns the handle claims can-widen=%v, want %v",
@@ -315,17 +260,23 @@ func TestAPressBelowTheRosterIsNotTheRostersToSwallow(t *testing.T) {
// ── THE COLUMN AS A MAP: SWITCHING, SCROLLING, AND THE SHAPES ───────────────
-// EVERY ROW IS A DOOR AND THE DOOR IS THE ROW UNDER THE POINTER. Root, child,
-// grandchild, a row waiting on a person, a finished row, and a flat row — pressed
-// on the state cell, on the title, and out in the row's trailing air.
+// EVERY ROW IS A DOOR AND THE DOOR IS THE ROW UNDER THE POINTER. Running,
+// queued and finished rows in the list, and the row waiting on a person in
+// the band over it, pressed on the state cell, on the title, and out in the
+// row's trailing air.
func TestEveryVisibleRowOfTheColumnOpensTheTaskItDraws(t *testing.T) {
a, _, _ := roomApp(t)
railFamily(a)
seam := a.bodyWidth() + ansi.StringWidth(railSeam)
- for _, id := range []uint64{1, 3, 4, 5, 6, 2, 9} {
+ for _, id := range []uint64{1, 3, 4, 5, 2, 9, 6} {
for _, at := range []int{0, 4, a.railRoom() - 1} {
a.closeRoom()
- y := railRowY(t, a, id)
+ var y int
+ if id == 6 {
+ _, y = sideRowOn(t, a, "task/6")
+ } else {
+ y = railRowY(t, a, id)
+ }
railClick(t, a, seam+at, y)
if !a.roomOpen() || a.room.id != id {
t.Fatalf("column %d of node %d's row opened %d, want %d:\n%s",
@@ -350,7 +301,7 @@ func TestPressingAnotherRowWhileARoomIsOpenSwitchesToIt(t *testing.T) {
if !a.roomStandingOn(a.tasks[1]) {
t.Fatal("the roster does not mark the row the reader walked through")
}
- for _, next := range []uint64{3, 9, 6, 1} {
+ for _, next := range []uint64{3, 9, 4, 1} {
// THE ROW IS FOUND WITH THE PAGE STILL OPEN, which is the case this is
// about: the room pins a header above the body, so the roster's rows are
// not where they were before anybody walked in (view.go's
@@ -456,9 +407,8 @@ func TestTheStowedColumnsEdgeBringsItBackAndOpensNothing(t *testing.T) {
// ── THE ROW ITSELF ──────────────────────────────────────────────────────────
// ONE GLYPH LEADS EVERY ROW OF THIS COLUMN AND IT IS THE STATE. The identity ◆
-// is furniture that says "this row is a task" (taskident.go) — which is nothing
-// this column has to tell apart, since it holds nothing but tasks — and the two
-// cells buy name on the narrowest surface here.
+// is furniture that says "this row is a task" (taskident.go), which is nothing
+// this column has to tell apart, since its list holds nothing but tasks.
func TestTheColumnLeadsWithStateAndSpendsNoCellOnIdentity(t *testing.T) {
a, _, _ := taskApp(t)
railFamily(a)
@@ -469,49 +419,19 @@ func TestTheColumnLeadsWithStateAndSpendsNoCellOnIdentity(t *testing.T) {
t.Fatalf("row %d spends a cell on the identity mark:\n%q", i, row)
}
}
- // A FLAT ROW AND A FAMILY ROOT NOW LEAD THE SAME WAY, and in the same number
- // of cells: the seam, one state glyph, and the air after it. That is what
- // lets the column be read downward as one column of states.
- flat, root := mustRailRow(t, a, "Collect the fixture"), mustRailRow(t, a, "Ship the streaming")
- want := ansi.StringWidth(railSeam) + 2
- for _, probe := range []struct{ row, title string }{
- {flat, "Collect the fixture"}, {root, "Ship the streaming"},
- } {
- if got := leadCells(t, probe.row, probe.title); got != want {
- t.Fatalf("the row leads in %d cells, want %d:\n%q", got, want, probe.row)
- }
- }
-
- // AND THE UNDER-BLOCK IS SQUARE UNDER THE TITLE IT BELONGS TO. It is indented
- // [app.railUnderCols] — two cells for a flat row — which is exactly the lead
- // now that the ◆ is gone. It was two cells to the left of the title while the
- // marker was there.
- a.taskUpdate(update(11, "Fix the loader nil-map", session.TaskRunning, session.TaskNotice{}))
- a.tasks[11].tool, a.tasks[11].toolBegan = "bash go test ./...", a.now().Add(-30*time.Second)
- rows = railText(a, a.viewHeight())
- at := -1
- for i, row := range rows {
- if strings.Contains(row, "Fix the loader") {
- at = i
+ // EVERY ROW LEADS THE SAME WAY, in the same number of cells: the seam,
+ // the indent under its heading, one state glyph, and the air after it.
+ // That is what lets the column be read downward as one column of states.
+ want := ansi.StringWidth(railSeam) + 4
+ for _, title := range []string{"Collect the fixture", "Ship the streaming", "Cut the goldens"} {
+ if got := leadCells(t, mustRailRow(t, a, title), title); got != want {
+ t.Fatalf("the row leads in %d cells, want %d:\n%q", got, want, mustRailRow(t, a, title))
}
}
- if at < 0 || at+1 >= len(rows) {
- t.Fatalf("the running flat row has no under-row:\n%s", strings.Join(rows, "\n"))
- }
- head, under := rows[at], rows[at+1]
- if !strings.Contains(under, "go test") {
- t.Fatalf("the row under the title is not the call it is in:\n%q", under)
- }
- body := strings.TrimPrefix(under, railSeam)
- underLead := ansi.StringWidth(railSeam) + len(body) - len(strings.TrimLeft(body, " "))
- if got := leadCells(t, head, "Fix the loader"); got != underLead {
- t.Fatalf("the title starts at cell %d and its call at cell %d:\nhead %q\nunder %q",
- got, underLead, head, under)
- }
}
-// leadCells is how many cells a drawn row spends before its name — the seam and
-// whatever [app.railLead] put in front of the title.
+// leadCells is how many cells a drawn row spends before its name: the seam,
+// the indent and the state glyph in front of the title.
func leadCells(t *testing.T, row, title string) int {
t.Helper()
at := strings.Index(row, title)
@@ -524,13 +444,13 @@ func leadCells(t *testing.T, row, title string) int {
// ── THE WHOLE WAY THROUGH THE TERMINAL ──────────────────────────────────────
// AND ONE PRESS AS A TERMINAL ACTUALLY SENDS IT: the SGR bytes, through Bubble
-// Tea's decoder and its program loop, onto the leading cell of a family root.
-// That cell is the one defect 2 was about, and nothing between the wire and
+// Tea's decoder and its program loop, onto the leading cell of a row. That
+// cell is the one defect 2 was about, and nothing between the wire and
// [app.railPress] gets a chance to make it look right.
func TestRawTerminalBytesOnARootsStateCellOpenThatTask(t *testing.T) {
a, _, _ := roomApp(t)
railFamily(a)
- x, y := a.bodyWidth()+ansi.StringWidth(railSeam), railRowY(t, a, 1)
+ x, y := a.bodyWidth()+ansi.StringWidth(railSeam)+2, railRowY(t, a, 1)
input, keyboard := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
@@ -569,16 +489,3 @@ func TestRawTerminalBytesOnARootsStateCellOpenThatTask(t *testing.T) {
}
}
}
-
-// ── small readers ───────────────────────────────────────────────────────────
-
-// railEntryFor is the roster's entry for one node, which is what the questions
-// about a row's shape ([app.railTucks]) are asked of.
-func railEntryFor(a *app, id uint64) (railEntry, bool) {
- for _, e := range a.railEntries() {
- if e.node != nil && e.node.id == id {
- return e, true
- }
- }
- return railEntry{}, false
-}
diff --git a/internal/tui3/railgroups_test.go b/internal/tui3/railgroups_test.go
new file mode 100644
index 0000000000..9323a02513
--- /dev/null
+++ b/internal/tui3/railgroups_test.go
@@ -0,0 +1,325 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// ── THE ROSTER'S GROUPS ─────────────────────────────────────────────────────
+//
+// The column files every task under what it is doing (task.go's
+// [app.railEntries]): the running work always open, and the queued, waiting
+// and finished work folded to one heading until the person opens it, and then
+// remembered open for the session. Every task is one line; what a row used to
+// say under it is the hint line's. These are the whole of that claim: the
+// shape, the order, the fold and its memory, the two hands that reach it.
+
+// railKinship hands a node to a parent, in the alphabet the seam speaks
+// (taskstrip.go's [taskNode.ParentID]).
+func railKinship(a *app, parent uint64, kids ...uint64) {
+ for _, id := range kids {
+ a.tasks[id].parent = itoa(int(parent))
+ }
+}
+
+// railRun plants one adaptive run: a root the person started, and the tree its
+// planner spawned under it. The column does not draw the tree; it draws what
+// each node is doing, newest first in each group:
+//
+// 1 Ship the port running
+// ├─ 2 Read the law done
+// ├─ 3 Write the tree running
+// │ └─ 4 Cut goldens queued
+// └─ 5 Wire the seam queued
+func railRun(a *app) {
+ a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{}))
+ a.taskUpdate(update(2, "Read the law", session.TaskDone, session.TaskNotice{}))
+ a.taskUpdate(update(3, "Write the tree", session.TaskRunning, session.TaskNotice{}))
+ a.taskUpdate(update(4, "Cut the goldens", session.TaskQueued, session.TaskNotice{}))
+ a.taskUpdate(update(5, "Wire the seam", session.TaskQueued, session.TaskNotice{}))
+ railKinship(a, 1, 2, 3, 5)
+ railKinship(a, 3, 4)
+ // The spinner is on the frame clock (tokens.Spinner), so a golden has to say
+ // which frame it was taken on.
+ a.paints = 0
+}
+
+// railText is the roster as a reader sees it, row by row.
+func railText(a *app, height int) []string {
+ rows := a.railRows(height)
+ out := make([]string, len(rows))
+ for i, row := range rows {
+ out[i] = plain(row)
+ }
+ return out
+}
+
+// mustRailRow is [railRowFor] over the whole visible column, for the tests that
+// would be lying if the row were missing.
+func mustRailRow(t *testing.T, a *app, title string) string {
+ t.Helper()
+ row, ok := railRowFor(a, a.viewHeight(), title)
+ if !ok {
+ t.Fatalf("the column has no row for %q:\n%s", title,
+ strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ return row
+}
+
+// railRowFor is the drawn row a node's title is on, and whether there is one.
+func railRowFor(a *app, height int, title string) (string, bool) {
+ for _, row := range railText(a, height) {
+ if strings.Contains(row, title) {
+ return row, true
+ }
+ }
+ return "", false
+}
+
+// railFocusOn walks the roster's cursor onto a node, however far down it is.
+func railFocusOn(t *testing.T, a *app, id uint64) {
+ t.Helper()
+ railOpenAll(a)
+ for i := 0; i < 40 && a.railWhere.id != id; i++ {
+ a.railMove(1)
+ }
+ if a.railWhere.id != id {
+ t.Fatalf("the cursor never reached node %d", id)
+ }
+}
+
+// railOpenAll opens every group that folds, so each row the tests press is
+// drawn.
+func railOpenAll(a *app) {
+ for g := range a.side.open {
+ a.side.open[g] = true
+ }
+}
+
+// railHeadY is the screen row group g's heading is drawn on, failing the test
+// when the column draws none.
+func railHeadY(t *testing.T, a *app, g railGroup) int {
+ t.Helper()
+ for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
+ if e, ok := a.railEntryAt(y); ok && e.head && e.group == g {
+ return y
+ }
+ }
+ t.Fatalf("group %s has no heading on the column:\n%s", railHeadWords[g],
+ strings.Join(railText(a, a.viewHeight()), "\n"))
+ return -1
+}
+
+// ONE LINE PER TASK, UNDER THE WORD FOR WHAT IT IS DOING. The header, then
+// the running work open, newest first, then the groups that fold, each one
+// line with its count and its ▸. No blank row, no connectors, no id.
+func TestTheRosterIsOneLinePerTaskUnderItsGroups(t *testing.T) {
+ a, _, _ := taskApp(t)
+ railRun(a)
+ spin := a.railTreeGlyph(a.tasks[3])
+ rows := railText(a, 12)
+ idle := a.railGroupOf(a.tasks[4])
+ want := []string{
+ "│ Running 2",
+ "│ " + plain(spin) + " Write the tree",
+ "│ " + plain(spin) + " Ship the port",
+ "│ " + railHeadWords[idle] + " 2 " + glyphShut,
+ "│ Done 1 " + glyphShut,
+ }
+ if len(rows) < len(want)+1 {
+ t.Fatalf("the roster drew %d rows:\n%s", len(rows), strings.Join(rows, "\n"))
+ }
+ if !strings.HasPrefix(rows[0], "│ "+sideTasksWord+" 5") || !strings.HasSuffix(strings.TrimRight(rows[0], " "), sideHideKey) {
+ t.Fatalf("the header is %q", rows[0])
+ }
+ for i, w := range want {
+ // A running row ends in its age, in the muted ink at the right.
+ got := strings.TrimSuffix(strings.TrimRight(rows[i+1], " "), " 0s")
+ got = strings.TrimRight(got, " ")
+ if got != w {
+ t.Fatalf("row %d is\n\t%q\nwant\n\t%q\nwhole column:\n%s", i+1, got, w, strings.Join(rows, "\n"))
+ }
+ }
+ // EVERY ROW IS INSIDE THE COLUMN, and none of them is a tree.
+ for i, row := range rows {
+ if w := ansi.StringWidth(row); w > a.railWidth() {
+ t.Fatalf("row %d is %d cells wide, want at most %d:\n%q", i, w, a.railWidth(), row)
+ }
+ for _, stem := range []string{"├─", "└─", "#1", "#3"} {
+ if strings.Contains(row, stem) {
+ t.Fatalf("row %d still draws the forest's %q:\n%q", i, stem, row)
+ }
+ }
+ }
+}
+
+// THE FOLD IS THE PERSON'S AND IT IS REMEMBERED. A press on a folded heading
+// opens it, the next update does not close it, a new task does not close it,
+// and a second press folds it again. The running work has no fold at all.
+func TestAFoldIsRememberedForTheSession(t *testing.T) {
+ a, _, _ := roomApp(t)
+ railRun(a)
+ a.height = 30
+ if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok {
+ t.Fatalf("the finished work came up open:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ x := a.bodyWidth() + ansi.StringWidth(railSeam) + 2
+ railClick(t, a, x, railHeadY(t, a, railDone))
+ if a.roomOpen() {
+ t.Fatalf("a press on a heading opened a room: id=%d", roomID(a))
+ }
+ if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); !ok {
+ t.Fatalf("a press on the heading did not open the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ if !strings.Contains(mustRailRow(t, a, "Done 1"), glyphOpen) {
+ t.Fatalf("the open group does not wear ▾: %q", mustRailRow(t, a, "Done 1"))
+ }
+ a.taskUpdate(update(2, "Read the law", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
+ a.taskUpdate(update(6, "Port the loader", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
+ for _, title := range []string{"Read the law", "Port the loader"} {
+ if _, ok := railRowFor(a, a.viewHeight(), title); !ok {
+ t.Fatalf("the group the person opened closed itself on an update:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ }
+ railClick(t, a, x, railHeadY(t, a, railDone))
+ if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok {
+ t.Fatalf("a second press did not fold the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ // THE RUNNING WORK DOES NOT FOLD, and its heading says so by wearing no
+ // mark and taking no press.
+ run := mustRailRow(t, a, "Running ")
+ if strings.Contains(run, glyphOpen) || strings.Contains(run, glyphShut) {
+ t.Fatalf("the running heading wears a fold it does not have: %q", run)
+ }
+ railClick(t, a, x, railHeadY(t, a, railRunning))
+ if _, ok := railRowFor(a, a.viewHeight(), "Ship the port"); !ok {
+ t.Fatalf("a press folded the running work:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ // AND THE KEYBOARD REACHES THE SAME FOLD: enter on a heading.
+ drive(t, a, altT())
+ a.railWhere = railSpot{group: int(railDone) + 1}
+ drive(t, a, key("enter"))
+ if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); !ok {
+ t.Fatalf("enter on the heading did not open the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ if a.roomOpen() {
+ t.Fatal("enter on a heading opened a room")
+ }
+}
+
+// THE HEADING'S HOVER GROUND IS ITS PRESS. The pointer on a folded heading
+// lights it and names what a press does; the pointer on the running heading
+// lights nothing, because a press there does nothing.
+func TestAHeadingLightsOnlyWhereItFolds(t *testing.T) {
+ a, _, _ := taskApp(t)
+ railRun(a)
+ x := a.bodyWidth() + ansi.StringWidth(railSeam) + 2
+ a.setHover(x, railHeadY(t, a, railDone))
+ if a.hot.kind != hoverRailGroup || railGroup(a.hot.index) != railDone {
+ t.Fatalf("the pointer on the Done heading resolved to %+v", a.hot)
+ }
+ if got := a.sideHoverWords(); !strings.Contains(got, "Show what is done") {
+ t.Fatalf("the hint over the folded heading reads %q", got)
+ }
+ a.setHover(x, railHeadY(t, a, railRunning))
+ if a.hot.kind == hoverRailGroup {
+ t.Fatalf("the running heading lit as a fold: %+v", a.hot)
+ }
+}
+
+// A ROW IS ONE LINE WHATEVER THE WORK IS DOING. The call a running task is in
+// used to hang under its row; it is the hint line's now, with the whole name.
+func TestARunningRowIsOneLineAndItsCallIsOnTheHint(t *testing.T) {
+ a, _, advance := taskApp(t)
+ railRun(a)
+ a.tasks[3].tool, a.tasks[3].toolBegan = "bash go test ./...", a.now().Add(-24*time.Second)
+ advance(0)
+ rows := railText(a, 14)
+ for _, row := range rows {
+ if strings.Contains(row, "go test") {
+ t.Fatalf("the call is drawn on the column:\n%s", strings.Join(rows, "\n"))
+ }
+ }
+ if hint := railHint(a, 3); !strings.Contains(hint, "Write the tree") || !strings.Contains(hint, "go test") {
+ t.Fatalf("the hint over the running row reads %q", hint)
+ }
+}
+
+// THE COLUMN WIDENS ON DEMAND. alt+w takes the wide tier while the column
+// holds the keyboard, the column is charged against the conversation, the
+// footer's hint is a button that does the same, and /new gives it back.
+func TestTheWidenChordAndHintToggleTheWideTier(t *testing.T) {
+ a, _, _ := taskApp(t)
+ railRun(a)
+ cols := sideColsFor(a.width)
+ drive(t, a, altT())
+ if !strings.Contains(strings.Join(railText(a, 14), "\n"), railWideHint) {
+ t.Fatalf("the held column did not offer the wide tier:\n%s", strings.Join(railText(a, 14), "\n"))
+ }
+ drive(t, a, key(railWidenChord))
+ if !a.railWide || a.railWidth() != cols+railWideGain {
+ t.Fatalf("%s did not widen the column: wide=%v width=%d", railWidenChord, a.railWide, a.railWidth())
+ }
+ if a.bodyWidth() != a.width-cols-railWideGain {
+ t.Fatalf("the wide column is not charged against the conversation: body=%d", a.bodyWidth())
+ }
+ drive(t, a, key(railWidenChord))
+ if a.railWide || a.railWidth() != cols {
+ t.Fatalf("%s did not give the columns back: wide=%v width=%d", railWidenChord, a.railWide, a.railWidth())
+ }
+ for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
+ if line, ok := a.railLineAt(y); ok && line.hint {
+ drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
+ break
+ }
+ }
+ if !a.railWide {
+ t.Fatalf("a press on the hint did not widen the column")
+ }
+ a.dropTasks()
+ if a.railWide {
+ t.Fatal("/new kept the wide column")
+ }
+}
+
+// THE FULLSCREEN ROSTER IS THE SAME COLUMN. Under a hundred columns there is
+// no column to lend, so alt+t draws it over the body: the same header, the
+// same groups and the same folds, at the width the frame has.
+func TestTheFullscreenRosterDrawsTheSameGroups(t *testing.T) {
+ a, _, _ := taskApp(t)
+ a.width = 80
+ a.touch()
+ railRun(a)
+ drive(t, a, altT())
+ if !a.railFull() {
+ t.Fatal("alt+t did not raise the roster over the body")
+ }
+ rows := railText(a, a.viewHeight())
+ body := strings.Join(rows, "\n")
+ for _, want := range []string{sideTasksWord + " 5", "Running 2", "Ship the port", "Done 1"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("the fullscreen roster is missing %q:\n%s", want, body)
+ }
+ }
+ for i, row := range rows {
+ if strings.HasPrefix(row, railSeam) {
+ t.Fatalf("fullscreen row %d opens with the rail's seam:\n%q", i, row)
+ }
+ if w := ansi.StringWidth(row); w > a.width {
+ t.Fatalf("row %d is %d cells wide on an %d-column frame:\n%q", i, w, a.width, row)
+ }
+ }
+ a.railWhere = railSpot{group: int(railDone) + 1}
+ drive(t, a, key("enter"))
+ if !strings.Contains(strings.Join(railText(a, a.viewHeight()), "\n"), "Read the law") {
+ t.Fatalf("enter on the heading did not open the group over the body:\n%s",
+ strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+}
diff --git a/internal/tui3/railheader_test.go b/internal/tui3/railheader_test.go
index 47ebfbb722..3291155b6a 100644
--- a/internal/tui3/railheader_test.go
+++ b/internal/tui3/railheader_test.go
@@ -8,6 +8,10 @@ import (
"github.com/Agent-Field/codeaf/internal/session"
)
+// THE HEADER HOLDS STILL AND THE TASK ACTION FOLLOWS THE LIST. The column's
+// first row is its header, with its key at the right, whatever the column
+// holds, however far it is scrolled and however short the frame; the `+ /task`
+// action is the row after the last task.
func TestSidebarKeepsHideAboveNewTasksAndTaskActionBelow(t *testing.T) {
for _, width := range []int{100, 120, 180} {
t.Run(fmt.Sprint(width), func(t *testing.T) {
@@ -18,14 +22,17 @@ func TestSidebarKeepsHideAboveNewTasksAndTaskActionBelow(t *testing.T) {
a.taskUpdate(update(uint64(count), fmt.Sprintf("Task number %d", count), session.TaskRunning, session.TaskNotice{}))
}
rows, _ := a.railView(20)
- if len(rows) != 20 || !rows[0].stow || !strings.Contains(plain(rows[0].text), railStowHint) {
- t.Fatalf("hide moved after %d tasks: %+v", count, rows)
+ if len(rows) != 20 || rows[0].side == nil || rows[0].side.key != sideHeadKey ||
+ !strings.HasSuffix(strings.TrimRight(plain(rows[0].text), " "), sideHideKey) {
+ t.Fatalf("the header moved after %d tasks: %+v", count, rows)
}
entries, last, action := a.railEntries(), 0, -1
var ids []uint64
for i, row := range rows {
- if row.entry >= 0 && row.head {
- ids = append(ids, entries[row.entry].node.id)
+ if row.entry >= 0 {
+ if e := entries[row.entry]; !e.head {
+ ids = append(ids, e.node.id)
+ }
last = i
}
if row.door == marginTaskType {
@@ -35,25 +42,21 @@ func TestSidebarKeepsHideAboveNewTasksAndTaskActionBelow(t *testing.T) {
if len(ids) != count || action != last+1 {
t.Fatalf("tasks or action are misplaced: ids=%v last=%d action=%d", ids, last, action)
}
+ // NEWEST FIRST, which is what a person arriving at the column
+ // wants to read first.
for i, id := range ids {
- if id != uint64(i+1) {
- t.Fatalf("tasks are not in creation order: %v", ids)
+ if id != uint64(count-i) {
+ t.Fatalf("tasks are not newest first: %v", ids)
}
}
}
- a.taskUpdate(update(1, "Task number 1", session.TaskDone, session.TaskNotice{}))
- for i, entry := range a.railEntries() {
- if entry.node.id != uint64(i+1) {
- t.Fatal("completion reordered the task list")
- }
- }
for id := uint64(4); id <= 30; id++ {
a.taskUpdate(update(id, fmt.Sprintf("Task number %d", id), session.TaskRunning, session.TaskNotice{}))
}
a.railTop = 20
for _, height := range []int{1, 4, 8, 20} {
rows, _ := a.railView(height)
- if len(rows) != height || !rows[0].stow || rows[0].fade != 0 {
+ if len(rows) != height || rows[0].side == nil || rows[0].side.key != sideHeadKey || rows[0].fade != 0 {
t.Fatalf("scrolling or resizing displaced the header at height %d: %+v", height, rows)
}
}
diff --git a/internal/tui3/railmain_test.go b/internal/tui3/railmain_test.go
index d170f3a17c..59723b8e8a 100644
--- a/internal/tui3/railmain_test.go
+++ b/internal/tui3/railmain_test.go
@@ -22,7 +22,7 @@ func TestRailMainReturnsDirectlyFromDeepTask(t *testing.T) {
a.openRailRoom(a.tasks[19])
a.railTop = 100
rows := a.railRows(a.viewHeight())
- if len(rows) < 2 || !strings.Contains(plain(rows[0]), railStowHint) || !strings.Contains(plain(rows[1]), railMainWord) {
+ if len(rows) < 2 || !strings.Contains(plain(rows[0]), sideTasksWord) || !strings.Contains(plain(rows[1]), railMainWord) {
t.Fatalf("return door is not pinned: %v", rows)
}
x, y := a.bodyWidth()+ansi.StringWidth(railSeam)+6, a.bodyTop()+1
diff --git a/internal/tui3/railsize_test.go b/internal/tui3/railsize_test.go
index 0755c91d51..c1c1162be3 100644
--- a/internal/tui3/railsize_test.go
+++ b/internal/tui3/railsize_test.go
@@ -16,12 +16,12 @@ func TestRailSeamClickTogglesTheWideTier(t *testing.T) {
drive(t, a, tea.MouseClickMsg{X: a.railLeft(), Y: y, Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: a.railLeft(), Y: y, Button: tea.MouseLeft})
- if !a.railWide || a.railWidth() != railWideCols {
+ if !a.railWide || a.railWidth() != sideColsFor(a.width)+railWideGain {
t.Fatalf("seam press did not widen the rail: wide=%v width=%d", a.railWide, a.railWidth())
}
drive(t, a, tea.MouseClickMsg{X: a.railLeft(), Y: y, Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: a.railLeft(), Y: y, Button: tea.MouseLeft})
- if a.railWide || a.railWidth() != railCols {
+ if a.railWide || a.railWidth() != sideColsFor(a.width) {
t.Fatalf("second seam press did not narrow the rail: wide=%v width=%d", a.railWide, a.railWidth())
}
}
diff --git a/internal/tui3/railtree_test.go b/internal/tui3/railtree_test.go
deleted file mode 100644
index cdd1a5cc08..0000000000
--- a/internal/tui3/railtree_test.go
+++ /dev/null
@@ -1,548 +0,0 @@
-package tui3
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/Agent-Field/codeaf/internal/session"
- "github.com/Agent-Field/codeaf/internal/tui2/tokens"
-)
-
-// ── THE ROSTER'S FOREST ─────────────────────────────────────────────────────
-//
-// The column used to file every node under one of five state headings, which
-// scattered one adaptive run across four sections of itself. It draws families
-// now (task.go's [app.railEntries]), and these are the whole of that claim: the
-// shape, the order, the fold and its default, the two hands that reach it.
-
-// railKinship hands a node to a parent, in the alphabet the seam speaks
-// (taskstrip.go's [taskNode.ParentID]).
-func railKinship(a *app, parent uint64, kids ...uint64) {
- for _, id := range kids {
- a.tasks[id].parent = itoa(int(parent))
- }
-}
-
-// railRun plants one adaptive run: a root the person started, and the tree its
-// planner spawned under it.
-//
-// THE NODES ARRIVE IN ID ORDER AND THE COLUMN DOES NOT DRAW THEM IN IT. A
-// family's members keep their creation order:
-//
-// 1 Ship the port running
-// ├─ 2 Read the law done
-// ├─ 3 Write the tree running
-// │ └─ 4 Cut goldens queued
-// └─ 5 Wire the seam queued
-func railRun(a *app) {
- a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{}))
- a.taskUpdate(update(2, "Read the law", session.TaskDone, session.TaskNotice{}))
- a.taskUpdate(update(3, "Write the tree", session.TaskRunning, session.TaskNotice{}))
- a.taskUpdate(update(4, "Cut the goldens", session.TaskQueued, session.TaskNotice{}))
- a.taskUpdate(update(5, "Wire the seam", session.TaskQueued, session.TaskNotice{}))
- railKinship(a, 1, 2, 3, 5)
- railKinship(a, 3, 4)
- // The spinner is on the frame clock (tokens.Spinner), so a golden has to say
- // which frame it was taken on.
- a.paints = 0
-}
-
-// railText is the roster as a reader sees it, row by row.
-func railText(a *app, height int) []string {
- rows := a.railRows(height)
- out := make([]string, len(rows))
- for i, row := range rows {
- out[i] = plain(row)
- }
- return out
-}
-
-// mustRailRow is [railRowFor] over the whole visible column, for the tests that
-// would be lying if the row were missing.
-func mustRailRow(t *testing.T, a *app, title string) string {
- t.Helper()
- row, ok := railRowFor(a, a.viewHeight(), title)
- if !ok {
- t.Fatalf("the column has no row for %q:\n%s", title,
- strings.Join(railText(a, a.viewHeight()), "\n"))
- }
- return row
-}
-
-// railRowFor is the drawn row a node's title is on, and whether there is one.
-func railRowFor(a *app, height int, title string) (string, bool) {
- for _, row := range railText(a, height) {
- if strings.Contains(row, title) {
- return row, true
- }
- }
- return "", false
-}
-
-// railFocusOn walks the roster's cursor onto a node, however far down it is.
-func railFocusOn(t *testing.T, a *app, id uint64) {
- t.Helper()
- for i := 0; i < 40 && a.railWhere.id != id; i++ {
- a.railMove(1)
- }
- if a.railWhere.id != id {
- t.Fatalf("the cursor never reached node %d", id)
- }
-}
-
-// A FAMILY IS DRAWN WHOLE AND THE CONNECTORS ARE IN THE ROW. One row per node,
-// children indented under the thing that spawned them, whatever state each of
-// them is in — the settled rows are what make the unsettled ones legible.
-//
-// It is a byte comparison on purpose: a tree is alignment, and a test that only
-// asked "does it contain ├─" would pass on a tree whose second level had
-// drifted a cell.
-func TestAFamilyIsDrawnWholeUnderItsRoot(t *testing.T) {
- a, _, _ := taskApp(t)
- railRun(a)
- spin := tokens.Spinner(0)
- want := []string{
- "│ " + railGripOpenGlyph + " " + railStowHint,
- "│ ",
- "│ " + spin + " Ship the port #1",
- "│ ├─ " + glyphDone + " Read the law #2",
- "│ ├─ " + spin + " Write the tree #3",
- "│ │ └─ " + glyphQueued + " Cut the goldens #4",
- "│ └─ " + glyphQueued + " Wire the seam #5",
- }
- got := railText(a, 12)
- if len(got) < len(want) {
- t.Fatalf("the family drew %d rows:\n%s", len(got), strings.Join(got, "\n"))
- }
- for i := range want {
- if got[i] != want[i] {
- t.Fatalf("row %d is\n\t%q\nwant\n\t%q\nwhole column:\n%s", i, got[i], want[i], strings.Join(got, "\n"))
- }
- }
- // EVERY ROW IS INSIDE THE COLUMN, connectors and all.
- for i, row := range got {
- if w := ansi.StringWidth(row); w > railCols {
- t.Fatalf("row %d is %d cells wide, want at most %d:\n%q", i, w, railCols, row)
- }
- }
- // EVERY ROW ON THIS COLUMN WEARS ITS STATE AND NOTHING ELSE: the column of
- // glyphs is read downward, and the identity ◆ is not spent on any row of it —
- // tree or flat (task.go's [app.railLead]).
- for i, row := range got {
- if strings.Contains(row, plain(a.taskMark(identFor(2)))) {
- t.Fatalf("row %d carries the identity cell as well as the state:\n%q", i, row)
- }
- }
-}
-
-// State changes must not reorder families somebody is reading.
-func TestAFamilyKeepsItsCreationOrderAcrossStates(t *testing.T) {
- a, _, _ := taskApp(t)
- // A settled family, then a running one, then a family with a conflicted branch in
- // it — all remain in the order they were created.
- a.taskUpdate(update(1, "Cut the trailer", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- a.taskUpdate(update(2, "Trim silence", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- a.taskUpdate(update(3, "Ship the port", session.TaskRunning, session.TaskNotice{}))
- a.taskUpdate(update(4, "Write the tree", session.TaskRunning, session.TaskNotice{}))
- a.taskUpdate(update(5, "Port the parser", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- a.taskUpdate(update(6, "Render titles", session.TaskFailed, session.TaskNotice{
- Merge: mergeWordConflicted, Branch: "task/render",
- }))
- railKinship(a, 1, 2)
- railKinship(a, 3, 4)
- railKinship(a, 5, 6)
- // Every family open, so the order can be read off the rows themselves.
- for _, id := range []uint64{1, 3, 5} {
- a.railSetOpen(a.tasks[id], true)
- }
-
- rail := strings.Join(railText(a, 20), "\n")
- at := -1
- for _, want := range []string{"Cut the trailer", "Trim silence", "Ship the port", "Write the tree",
- "Port the parser", "Render titles"} {
- found := strings.Index(rail, want)
- if found < 0 {
- t.Fatalf("the roster has no %q row:\n%s", want, rail)
- }
- if found < at {
- t.Fatalf("%q is out of order:\n%s", want, rail)
- }
- at = found
- }
-}
-
-// THE FOLD HAS A DEFAULT WORTH HAVING AND THE PERSON MAY OVERRULE IT. A family
-// with anything live in it opens; a family that has entirely settled is one row
-// with a count on it; and whatever a person says about either one sticks.
-func TestAFamilyOpensWhileItIsLiveAndFoldsOnceItSettles(t *testing.T) {
- a, _, _ := taskApp(t)
- railRun(a)
- if _, ok := railRowFor(a, 12, "Cut the goldens"); !ok {
- t.Fatalf("a live family came up folded:\n%s", strings.Join(railText(a, 12), "\n"))
- }
-
- // The whole run lands. Nothing under it is going anywhere, so it stands as one
- // row — a hundred and forty-eight settled nodes are a fact, not a hundred and
- // forty-eight rows.
- for _, id := range []uint64{1, 2, 3, 4, 5} {
- a.taskUpdate(update(id, a.tasks[id].title, session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- }
- rows := railText(a, 12)
- if _, ok := railRowFor(a, 12, "Cut the goldens"); ok {
- t.Fatalf("a settled family stayed open:\n%s", strings.Join(rows, "\n"))
- }
- if _, ok := railRowFor(a, 12, "Ship the port"); !ok {
- t.Fatalf("the folded family lost its own row:\n%s", strings.Join(rows, "\n"))
- }
-
- // AND THE PERSON'S ANSWER STICKS. Opened by hand, it stays open through the
- // next update — the default is the design and the map is the correction of it.
- a.railSetOpen(a.tasks[1], true)
- a.taskUpdate(update(2, "Read the law", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- if _, ok := railRowFor(a, 12, "Read the law"); !ok {
- t.Fatalf("the fold a person opened closed itself again:\n%s", strings.Join(railText(a, 12), "\n"))
- }
- a.railSetOpen(a.tasks[1], false)
- if _, ok := railRowFor(a, 12, "Read the law"); ok {
- t.Fatalf("the fold a person closed stayed open:\n%s", strings.Join(railText(a, 12), "\n"))
- }
-}
-
-// A FOLDED FAMILY WEARS THE WORST THING UNDER IT AND SAYS HOW MUCH IT IS
-// STANDING FOR. The row is the whole subtree now, so its one cell is the
-// subtree's news and its trailing slot is the count rather than the handle.
-func TestAFoldedFamilyWearsItsWorstGlyphAndCountsWhatItHides(t *testing.T) {
- a, _, _ := taskApp(t)
- railRun(a)
- // Everything settles except one node, which did not come off.
- for _, id := range []uint64{1, 2, 3, 5} {
- a.taskUpdate(update(id, a.tasks[id].title, session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
- }
- a.taskUpdate(update(4, "Cut the goldens", session.TaskFailed, session.TaskNotice{
- Report: "the tests did not build",
- }))
- row, ok := railRowFor(a, 12, "Ship the port")
- if !ok {
- t.Fatalf("the folded family has no row:\n%s", strings.Join(railText(a, 12), "\n"))
- }
- // FOUR NODES HIDDEN, and the failure is what the one cell says — the root
- // itself merged clean.
- if !strings.Contains(row, glyphShut+" +4") {
- t.Fatalf("the folded row does not count what it hides:\n%q", row)
- }
- if !strings.HasPrefix(row, "│ "+glyphBad+" ") {
- t.Fatalf("the folded row does not wear the worst glyph under it:\n%q", row)
- }
- if strings.Contains(row, "#1") {
- t.Fatalf("the folded row spent its trailing slot twice:\n%q", row)
- }
- // AND IT IS ONE ROW. A folded family that still said what it was doing would
- // be a fold that hid nothing.
- if rows := railText(a, 12); strings.Contains(strings.Join(rows[:1], ""), mergeWordMerged) {
- t.Fatalf("the folded row kept its under-block:\n%s", strings.Join(rows, "\n"))
- }
-}
-
-// →← ARE THE TREE'S OWN GRAMMAR: → opens a folded family and then steps into it,
-// ← folds an open one and walks up out of a leaf.
-func TestTheTreeGrammarOpensStepsInFoldsAndWalksUp(t *testing.T) {
- a, _, _ := roomApp(t)
- railRun(a)
- drive(t, a, altT())
- railFocusOn(t, a, 1)
-
- // ← on an open root folds it and leaves the cursor where the family now is.
- drive(t, a, key("left"))
- if _, ok := railRowFor(a, 12, "Read the law"); ok {
- t.Fatalf("← did not fold the family:\n%s", strings.Join(railText(a, 12), "\n"))
- }
- if a.railWhere.id != 1 {
- t.Fatalf("← left the cursor on %+v, want the root it folded", a.railWhere)
- }
- // → on a folded root opens it, and → again steps onto the first child.
- drive(t, a, key("right"))
- if _, ok := railRowFor(a, 12, "Read the law"); !ok {
- t.Fatalf("→ did not open the family:\n%s", strings.Join(railText(a, 12), "\n"))
- }
- if a.railWhere.id != 1 {
- t.Fatalf("→ opened the family and moved the cursor to %+v", a.railWhere)
- }
- drive(t, a, key("right"))
- if a.railWhere.id != 2 {
- t.Fatalf("→ stepped to %+v, want the first child created", a.railWhere)
- }
-
- // ← FROM A LEAF JUMPS TO THE PARENT ROW. A cursor left pointing at nothing is
- // a position that means nothing.
- railFocusOn(t, a, 4)
- drive(t, a, key("left"))
- if a.railWhere.id != 3 {
- t.Fatalf("← from the grandchild landed on %+v, want its parent", a.railWhere)
- }
- // And enter is the one activating key: it opens that node's room.
- drive(t, a, key("enter"))
- if !a.roomOpen() || a.room.id != 3 {
- t.Fatalf("enter did not open the focused node's room: open=%v", a.roomOpen())
- }
-}
-
-// THE DISCLOSURE IS THE FOLD AND THE REST OF THE ROW IS THE DOOR. One row, two
-// targets, and which one a press meant is a question about the column it landed
-// in AND about what the frame actually drew there (room.go's [app.railPress]).
-func TestPressingTheGlyphCellFoldsAndPressingTheTitleOpensTheRoom(t *testing.T) {
- a, _, _ := roomApp(t)
- railRun(a)
- rootY := -1
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if node := a.railNodeAt(y); node != nil && node.id == 1 {
- rootY = y
- break
- }
- }
- if rootY < 0 {
- t.Fatalf("the family's root is not on screen:\n%s", strings.Join(railText(a, a.viewHeight()), "\n"))
- }
- glyph := a.bodyWidth() + ansi.StringWidth(railSeam)
-
- // THE POINTER IS ON THE ROW FIRST, and that is not fixture ceremony: it is the
- // only state in which that cell is a disclosure at all. At rest it draws the
- // root's STATE, and a state is not a control — which is what
- // [TestTheStateCellOpensTheTaskWhenNoDisclosureIsDrawnOnIt] holds the other
- // end of.
- a.setHover(glyph, rootY)
- if !strings.Contains(mustRailRow(t, a, "Ship the port"), glyphOpen) {
- t.Fatalf("the cell about to be pressed is not drawn as a disclosure:\n%q",
- mustRailRow(t, a, "Ship the port"))
- }
-
- // The disclosure folds the family and opens no room.
- drive(t, a, tea.MouseClickMsg{X: glyph, Y: rootY, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: glyph, Y: rootY, Button: tea.MouseLeft})
- if a.roomOpen() {
- t.Fatal("a press on the disclosure cell walked into the room behind it")
- }
- if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok {
- t.Fatalf("a press on the disclosure cell did not fold the family:\n%s",
- strings.Join(railText(a, a.viewHeight()), "\n"))
- }
- // THE ▸ +N EXPANDS IN PLACE, which is the other half of the same affordance.
- line, ok := a.railLineAt(rootY)
- if !ok || !line.badge.pressable() {
- t.Fatalf("the folded row recorded no badge to press: %+v", line)
- }
- drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + ansi.StringWidth(railSeam) + line.badge.from,
- Y: rootY, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + ansi.StringWidth(railSeam) + line.badge.from,
- Y: rootY, Button: tea.MouseLeft})
- if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); !ok {
- t.Fatalf("a press on the count did not expand the family:\n%s",
- strings.Join(railText(a, a.viewHeight()), "\n"))
- }
- if a.roomOpen() {
- t.Fatal("a press on the count opened a room as well")
- }
-
- // And anywhere else on the row is that node's door.
- drive(t, a, tea.MouseClickMsg{X: glyph + 6, Y: rootY, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: glyph + 6, Y: rootY, Button: tea.MouseLeft})
- if !a.roomOpen() || a.room.id != 1 {
- t.Fatalf("a press on the title did not open the room: open=%v", a.roomOpen())
- }
-}
-
-// THE DISCLOSURE IS THE POINTER'S. Nothing on the column says "fold me" at rest
-// — a triangle on every root would be a column of widgets — and the moment the
-// pointer is over a family root, its state cell becomes the triangle.
-func TestTheDisclosureIsRevealedUnderThePointerAndOnlyOnRoots(t *testing.T) {
- a, _, _ := taskApp(t)
- railRun(a)
- rows := strings.Join(railText(a, 12), "\n")
- if strings.Contains(rows, glyphOpen) || strings.Contains(rows, glyphShut) {
- t.Fatalf("the column drew a disclosure nobody was pointing at:\n%s", rows)
- }
-
- rootY, leafY := -1, -1
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- switch node := a.railNodeAt(y); {
- case node == nil:
- case node.id == 1:
- rootY = y
- case node.id == 2:
- leafY = y
- }
- }
- if rootY < 0 || leafY < 0 {
- t.Fatalf("the family is not on screen: root=%d leaf=%d", rootY, leafY)
- }
- a.setHover(a.bodyWidth()+4, rootY)
- if a.hot.kind != hoverRail || a.hot.id != 1 {
- t.Fatalf("the pointer over the roster resolved to %+v", a.hot)
- }
- row, _ := railRowFor(a, a.viewHeight(), "Ship the port")
- if !strings.Contains(row, glyphOpen) {
- t.Fatalf("the root under the pointer revealed no disclosure:\n%q", row)
- }
- // A LEAF HAS NOTHING TO DISCLOSE, and it still takes the hover step, because
- // every row of this column is a door.
- a.setHover(a.bodyWidth()+4, leafY)
- row, _ = railRowFor(a, a.viewHeight(), "Read the law")
- if strings.Contains(row, glyphOpen) || strings.Contains(row, glyphShut) {
- t.Fatalf("a leaf offered a fold it does not have:\n%q", row)
- }
- background := "\x1b[48;5;" + itoa(int(hueCursor.idx)) + "m"
- if !strings.Contains(a.railRows(a.viewHeight())[leafY-a.bodyTop()], background) {
- t.Fatalf("the row under the pointer took no hover step:\n%q", row)
- }
- // A FOLDED ROOT SHOWS ITS COUNT AT REST AND ITS TRIANGLE UNDER THE POINTER.
- a.railSetOpen(a.tasks[1], false)
- a.setHover(a.bodyWidth()+4, rootY)
- row, _ = railRowFor(a, a.viewHeight(), "Ship the port")
- if !strings.Contains(row, glyphShut) {
- t.Fatalf("the folded root under the pointer did not point at what it hides:\n%q", row)
- }
-}
-
-// THE UNDER-BLOCK HANGS FROM THE STEM IT BELONGS TO. A node's telemetry sits
-// between that node's row and its next sibling's, so a block indented with plain
-// spaces would put a gap in the line the eye is following down the family.
-func TestUnderRowsCarryTheStemTheyHangFrom(t *testing.T) {
- a, _, advance := taskApp(t)
- railRun(a)
- a.tasks[3].tool, a.tasks[3].toolBegan = "bash go test ./...", a.now().Add(-24*time.Second)
- advance(0)
-
- rows := railText(a, 14)
- at := -1
- for i, row := range rows {
- if strings.Contains(row, "Write the tree") {
- at = i
- }
- }
- if at < 0 || at+1 >= len(rows) {
- t.Fatalf("the running child has no under-row:\n%s", strings.Join(rows, "\n"))
- }
- under := rows[at+1]
- if !strings.Contains(under, "go test") {
- t.Fatalf("the under-row is not the call the node is in:\n%q", under)
- }
- // The row hangs under "├─ ", so the stem continues where the elbow was, and
- // the node's own stem carries on down to its child.
- if want := "│ │ │ "; !strings.HasPrefix(under, want) {
- t.Fatalf("the under-row opens %q, want the stems %q:\n%s", under, want, strings.Join(rows, "\n"))
- }
- // A SETTLED NODE IN A TREE IS ONE LINE: the merge word under every landed row
- // would be a column of history inside a shape somebody is reading for shape.
- for _, row := range rows {
- if strings.Contains(row, mergeWordMerged) {
- t.Fatalf("a settled row in a family kept its under-block:\n%s", strings.Join(rows, "\n"))
- }
- }
-}
-
-// THE COLUMN WIDENS ON DEMAND AND OFFERS IT ONLY WHEN IT WOULD HELP. The hint is
-// earned by a title the indent cut, it disappears the moment the column is wide,
-// and it is pressable because a hint only one hand can use is half a hint.
-func TestTheWidenHintIsEarnedByTheIndentAndTogglesTheWideTier(t *testing.T) {
- a, _, _ := taskApp(t)
- railRun(a)
- // Nothing is cut yet: the names in this run fit their indent.
- if strings.Contains(strings.Join(railText(a, 14), "\n"), railWideHint) {
- t.Fatalf("the column offered the wide tier with nothing to gain:\n%s",
- strings.Join(railText(a, 14), "\n"))
- }
-
- // A deep node with a long name is a title the indent cuts.
- a.taskUpdate(update(6, "Cut the goldens", session.TaskRunning, session.TaskNotice{}))
- railKinship(a, 4, 6)
- // The roster draws the NAME (taskident.go cuts it), so the long one is written
- // onto the node rather than sent through the notice.
- a.tasks[6].title = "Cut the goldens for the tree"
- rail := strings.Join(railText(a, 14), "\n")
- if !strings.Contains(rail, railWideHint) {
- t.Fatalf("a title cut by its own indent did not offer the wide tier:\n%s", rail)
- }
-
- // alt+w takes it, and the column is charged against the conversation like the
- // other two tiers. It is a chord and no longer the bare letter `w`, because a
- // bare letter beside a message box is a letter out of somebody's sentence
- // (chordfocus.go).
- drive(t, a, altT(), key(railWidenChord))
- if !a.railWide || a.railWidth() != railWideCols {
- t.Fatalf("%s did not widen the column: wide=%v width=%d", railWidenChord, a.railWide, a.railWidth())
- }
- if a.bodyWidth() != a.width-railWideCols {
- t.Fatalf("the wide column is not charged against the conversation: body=%d", a.bodyWidth())
- }
- wide := strings.Join(railText(a, 14), "\n")
- if !strings.Contains(wide, "Cut the goldens for the tree") {
- t.Fatalf("the wide column still cut the name it was widened for:\n%s", wide)
- }
- if strings.Contains(wide, railWideHint) {
- t.Fatalf("the wide column went on offering to widen:\n%s", wide)
- }
- drive(t, a, key(railWidenChord))
- if a.railWide || a.railWidth() != railCols {
- t.Fatalf("%s did not give the columns back: wide=%v width=%d", railWidenChord, a.railWide, a.railWidth())
- }
-
- // AND THE HINT IS A BUTTON. A press on that line widens the column.
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if line, ok := a.railLineAt(y); ok && line.hint {
- drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 4, Y: y, Button: tea.MouseLeft})
- break
- }
- }
- if !a.railWide {
- t.Fatalf("a press on the hint did not widen the column")
- }
- // AND IT GOES WITH THE SESSION. A column widened for a tree that no longer
- // exists is a charge on a conversation that never asked for it.
- a.dropTasks()
- if a.railWide {
- t.Fatal("/new kept the wide column")
- }
-}
-
-// THE FULLSCREEN ROSTER IS THE SAME FOREST. Under a hundred columns there is no
-// column to lend, so ctrl+t draws the whole thing over the body — the same
-// families, the same folds, at the width the frame actually has.
-func TestTheFullscreenRosterDrawsTheSameTree(t *testing.T) {
- a, _, _ := taskApp(t)
- a.width = 80
- a.touch()
- railRun(a)
- drive(t, a, altT())
- if !a.railFull() {
- t.Fatal("ctrl+t did not raise the roster over the body")
- }
- rows := railText(a, a.viewHeight())
- body := strings.Join(rows, "\n")
- for _, want := range []string{"Ship the port", "├─ ", "└─ ", "Cut the goldens"} {
- if !strings.Contains(body, want) {
- t.Fatalf("the fullscreen roster is missing %q:\n%s", want, body)
- }
- }
- // THE SEAM IS A SEAM AND NOT A BORDER, so it is not drawn where there is
- // nothing on the other side of it (task.go's [app.railRows]).
- for i, row := range rows {
- if strings.HasPrefix(row, railSeam) {
- t.Fatalf("fullscreen row %d opens with the rail's seam:\n%q", i, row)
- }
- }
- for i, row := range rows {
- if w := ansi.StringWidth(row); w > a.width {
- t.Fatalf("row %d is %d cells wide on an %d-column frame:\n%q", i, w, a.width, row)
- }
- }
- // The fold is the same fold, from the same key.
- drive(t, a, key("left"))
- if strings.Contains(strings.Join(railText(a, a.viewHeight()), "\n"), "Cut the goldens") {
- t.Fatalf("← did not fold the family over the body:\n%s",
- strings.Join(railText(a, a.viewHeight()), "\n"))
- }
-}
diff --git a/internal/tui3/railview_test.go b/internal/tui3/railview_test.go
index 8ccc2017dc..e68e3ff71a 100644
--- a/internal/tui3/railview_test.go
+++ b/internal/tui3/railview_test.go
@@ -5,7 +5,6 @@ import (
"testing"
tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/x/ansi"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/standing"
@@ -21,8 +20,8 @@ import (
//
// Three laws come out of that, and these are them:
//
-// - A ROW THAT HAS LANDED IS ONE LINE, and what it had to say is tucked behind
-// the fold the column already had rather than thrown away.
+// - A ROW THAT HAS LANDED IS ONE LINE, and what it had to say is on the hint
+// line when the pointer is on it rather than thrown away.
// - THE SECTIONS UNDER THE ROSTER ARE RESERVED, not stacked. A list that can
// grow without limit starves anything below it, every time.
// - THE WHEEL MOVES THE LIST UNDER THE POINTER, which is the oldest thing a
@@ -64,11 +63,12 @@ func railBuild(i int) string {
}
// railLanded fills a session with landed jobs, which is the shape the column was
-// drowning in.
+// drowning in, with the finished group opened so that every one is drawn.
func railLanded(a *app, n int) {
for i := 1; i <= n; i++ {
a.taskUpdate(railLandedTask(uint64(i), railBuild(i)))
}
+ railOpenAll(a)
}
// ── 1. a landed row is one line ─────────────────────────────────────────────
@@ -87,22 +87,21 @@ func TestALandedRowSpendsOneLineOnTheColumn(t *testing.T) {
}
}
// AND THE HISTORY UNDER THEM IS NOT DRAWN. The log path is what a settled job
- // used to spend its second row on; it is behind the fold now (see below), and
+ // used to spend its second row on; it is the hint line's now (see below), and
// a column that still drew it would not have fitted the thirteen rows above.
if strings.Contains(text, railReportOf(1)) {
t.Fatalf("a landed row still spends a line on its own history:\n%s", text)
}
}
-// WORK THAT IS STILL GOING IS UNTOUCHED, which is what says the rule above is
-// about what is OVER and not about the column having gone quiet. The fold is
-// offered on a row that has landed and on no other: a person cannot tuck away
-// the one thing they opened the column to watch.
+// WORK THAT IS STILL GOING IS ONE LINE TOO, and the row is its door: the
+// column draws one row for one running node under its heading, and the hint
+// line over it names it whole.
//
// It used to be said about a running background JOB, whose second line was the
// path its output was going to. That line is gone from this column with the jobs
-// themselves — a job's log is on the job's own page now (jobpage.go) — so what
-// is left to pin is the rule the job row was only ever an example of.
+// themselves (a job's log is on the job's own page now, jobpage.go), so what is
+// left to pin is the rule the job row was only ever an example of.
func TestARunningRowKeepsWhatItIsDoing(t *testing.T) {
a, _, _ := taskApp(t)
a.taskUpdate(update(4, "Collect the sources", session.TaskRunning, session.TaskNotice{
@@ -110,96 +109,49 @@ func TestARunningRowKeepsWhatItIsDoing(t *testing.T) {
}))
entries := a.railEntries()
- if len(entries) != 1 {
- t.Fatalf("the column drew %d rows for one running node", len(entries))
+ if len(entries) != 2 || !entries[0].head || entries[1].node == nil || entries[1].node.id != 4 {
+ t.Fatalf("the column drew %d rows for one running node: %+v", len(entries), entries)
}
- if a.railTucks(entries[0]) {
- t.Fatal("a row that is still going offers the fold that is meant for work that is over")
+ if hint := railHint(a, 4); !strings.Contains(hint, "Collect the sources") {
+ t.Fatalf("the hint over the running row reads %q", hint)
}
}
-// AND A ROW THAT HAS NOT STARTED KEEPS ITS SENTENCE TOO. `waits:` is the only
-// place this surface says what is in the way of a flat node, and queued is not
-// settled.
+// AND A ROW THAT HAS NOT STARTED KEEPS ITS SENTENCE. `waits:` is the only
+// place this surface says what is in the way of a flat node, and it is on the
+// hint line over the row.
func TestAQueuedRowStillSaysWhatItWaitsOn(t *testing.T) {
a, _, _ := taskApp(t)
a.taskUpdate(update(1, "Collect sources", session.TaskRunning, session.TaskNotice{}))
a.taskUpdate(update(2, "Mix audio", session.TaskQueued, session.TaskNotice{DependsOn: []uint64{1}}))
- if text := strings.Join(railText(a, 20), "\n"); !strings.Contains(text, "waits: Collect sources") {
- t.Fatalf("a queued row lost the sentence saying what is in its way:\n%s", text)
+ if hint := railHint(a, 2); !strings.Contains(hint, "waits: Collect sources") {
+ t.Fatalf("a queued row lost the sentence saying what is in its way: %q", hint)
}
}
-// NOTHING IS THROWN AWAY — IT IS TUCKED. → opens a landed row's own block, ←
-// puts it back, and both are the keys a family already folds on: one fold
-// vocabulary down the whole column.
-func TestALandedRowGivesItsBlockBackWhenItIsOpened(t *testing.T) {
+// NOTHING IS THROWN AWAY. What a landed row used to spend its second line on
+// is on the hint line when the pointer is on it, and only that row's.
+func TestALandedRowGivesItsBlockToTheHintLine(t *testing.T) {
a, _, _ := taskApp(t)
railLanded(a, 3)
- a.railTake(true)
- railFocusOn(t, a, 2)
-
- a.railOut()
- text := strings.Join(railText(a, 20), "\n")
- if !strings.Contains(text, railReportOf(2)) {
- t.Fatalf("→ on a landed row disclosed nothing:\n%s", text)
- }
- // AND ONLY THAT ROW'S. The gesture is per row, exactly as a family's fold is
- // per family.
- if strings.Contains(text, railReportOf(1)) || strings.Contains(text, railReportOf(3)) {
- t.Fatalf("opening one row opened its neighbours:\n%s", text)
- }
-
- a.railIn()
- if text := strings.Join(railText(a, 20), "\n"); strings.Contains(text, railReportOf(2)) {
- t.Fatalf("← did not tuck the block back away:\n%s", text)
+ hint := railHint(a, 2)
+ if !strings.Contains(hint, dollars(railCostOf(2))) {
+ t.Fatalf("the hint over a landed row does not say what it cost: %q", hint)
}
-}
-
-// THE POINTER IS OFFERED THE SAME FOLD, in the cell the state was in — which is
-// the affordance law this column already keeps for a family root: the triangle
-// arrives when there is a hand on the row and never before.
-func TestALandedRowOffersItsDisclosureUnderThePointer(t *testing.T) {
- a, _, _ := taskApp(t)
- railLanded(a, 3)
-
- line, y := marginLine(t, a, func(l railLine) bool {
- return strings.Contains(plain(l.text), railBuild(2))
- })
- if strings.Contains(plain(line.text), glyphShut) {
- t.Fatalf("a row nobody is pointing at already wears the disclosure:\n%q", plain(line.text))
+ if strings.Contains(hint, dollars(railCostOf(1))) || strings.Contains(hint, dollars(railCostOf(3))) {
+ t.Fatalf("one row's hint carried its neighbours': %q", hint)
}
-
- x := a.railLeft() + 3
- drive(t, a, tea.MouseMotionMsg{X: x, Y: y})
- lit, _ := marginLine(t, a, func(l railLine) bool {
+ // AND THE POINTER READS THE SAME HINT, on the row the pointer is on.
+ _, y := marginLine(t, a, func(l railLine) bool {
return strings.Contains(plain(l.text), railBuild(2))
})
- if !strings.Contains(plain(lit.text), glyphShut) {
- t.Fatalf("the row under the pointer offers no way into what it is holding:\n%q", plain(lit.text))
- }
-
- // AND THE PRESS ON THAT CELL IS THE FOLD, which is hover.go's own law: the
- // set that lights is the set that acts.
- drive(t, a, tea.MouseClickMsg{X: a.railLeft() + ansi.StringWidth(railSeam), Y: y, Button: tea.MouseLeft})
- if text := strings.Join(railText(a, 20), "\n"); !strings.Contains(text, railReportOf(2)) {
- t.Fatalf("a press on the disclosure opened nothing:\n%s", text)
- }
-}
-
-// A ROW WITH NOTHING BEHIND IT OFFERS NOTHING. A triangle that answered a press
-// with silence would teach a person that the cell means nothing.
-func TestALandedRowWithNothingToSayOffersNoDisclosure(t *testing.T) {
- a, _, _ := taskApp(t)
- a.taskUpdate(update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{}))
-
- entries := a.railEntries()
- if len(entries) != 1 {
- t.Fatalf("the column drew %d rows for one node", len(entries))
+ drive(t, a, tea.MouseMotionMsg{X: a.railLeft() + 3, Y: y})
+ if a.hot.kind != hoverRail || a.hot.id != 2 {
+ t.Fatalf("the pointer on the row resolved to %+v", a.hot)
}
- if a.railTucks(entries[0]) {
- t.Fatal("a row with no block behind it still offers the fold")
+ if got := a.sideHoverWords(); got != hint {
+ t.Fatalf("the pointer's hint %q is not the row's %q", got, hint)
}
}
@@ -239,15 +191,17 @@ func TestTheStandingSectionIsNotStarvedByALongRoster(t *testing.T) {
}
}
-// The blank spacer under the hide control stays while tasks scroll.
-func TestTheTasksSpacerStaysWhileTheRosterScrolls(t *testing.T) {
+// THE HEADER STAYS WHILE THE TASKS SCROLL, and the first row of the list is
+// right under it with no spacer between.
+func TestTheHeaderStaysWhileTheRosterScrolls(t *testing.T) {
a, _, _ := taskApp(t)
railLanded(a, 40)
a.railScroll(12)
rows := railText(a, 20)
- if len(rows) < 2 || !strings.Contains(rows[0], railStowHint) || strings.TrimSpace(strings.TrimPrefix(rows[1], "│")) != "" {
- t.Fatalf("the spacer changed while scrolling:\n%s", strings.Join(rows, "\n"))
+ if len(rows) < 2 || !strings.Contains(rows[0], sideTasksWord+" 40") ||
+ strings.TrimSpace(strings.TrimPrefix(rows[1], "│")) == "" {
+ t.Fatalf("the header moved while scrolling:\n%s", strings.Join(rows, "\n"))
}
}
diff --git a/internal/tui3/railwork_test.go b/internal/tui3/railwork_test.go
index 2e110ca2d3..43b7f97a8d 100644
--- a/internal/tui3/railwork_test.go
+++ b/internal/tui3/railwork_test.go
@@ -68,6 +68,7 @@ func TestAWorkerIsDrawnOnceByTheFamilyThatOwnsIt(t *testing.T) {
// The same two hands, as the engine announced them: a row each, under 7.
a.taskUpdate(update(8, "Read the law", session.TaskQueued, session.TaskNotice{Parent: 7}))
a.taskUpdate(update(9, "Write the tests", session.TaskRunning, session.TaskNotice{Parent: 7}))
+ railOpenAll(a)
text := strings.Join(railText(a, a.viewHeight()), "\n")
for _, title := range []string{"Build the rail", "Read the law", "Write the tests"} {
diff --git a/internal/tui3/railworkername_test.go b/internal/tui3/railworkername_test.go
index ce3aa68ca5..6758be802d 100644
--- a/internal/tui3/railworkername_test.go
+++ b/internal/tui3/railworkername_test.go
@@ -50,7 +50,7 @@ func TestARunSaysItIsFormingWhileItHasNothingToShowYet(t *testing.T) {
a.taskUpdate(update(1, "competitive intelligence on the three vendors",
session.TaskRunning, session.TaskNotice{Doing: "forming the work"}))
- if drawn := strings.Join(railText(a, a.viewHeight()), "\n"); !strings.Contains(drawn, "forming the work") {
+ if drawn := strings.Join(railText(a, a.viewHeight()), "\n") + "\n" + railHint(a, 1); !strings.Contains(drawn, "forming the work") {
t.Fatalf("the run's row says nothing while its workers are being formed:\n%s", drawn)
}
@@ -61,7 +61,7 @@ func TestARunSaysItIsFormingWhileItHasNothingToShowYet(t *testing.T) {
a.taskUpdate(update(2, "pricing sheet", session.TaskRunning, session.TaskNotice{Parent: 1}))
railKinship(a, 1, 2)
- drawn := strings.Join(railText(a, a.viewHeight()), "\n")
+ drawn := strings.Join(railText(a, a.viewHeight()), "\n") + "\n" + railHint(a, 1)
if strings.Contains(drawn, "forming the work") {
t.Fatalf("the run still says it is forming with a worker on the board:\n%s", drawn)
}
diff --git a/internal/tui3/render.go b/internal/tui3/render.go
index 5f36dc2add..a139aefe71 100644
--- a/internal/tui3/render.go
+++ b/internal/tui3/render.go
@@ -1272,6 +1272,11 @@ func (a *app) renderEntry(i int, e *entry, width int) []string {
body := wrap(e.text, room)
if e.block {
body = noteBlockLines(e.text, room)
+ } else if e.sheet {
+ // /help is a column. A wrap that starts the next row at the margin
+ // makes the sentence look like a new key. Continuations keep the
+ // column the first row's sentence already sits in.
+ body = wrapSheet(e.text, room)
}
out := make([]string, 0, len(body))
walk := factWalk{words: e.facts}
@@ -3739,6 +3744,12 @@ func (a *app) footHint(width int) string {
if hint := a.hintWord(); hint != "" {
return hint
}
+ // A WORD OF THE HEAD UNDER THE POINTER says what it opens and its key
+ // (topnav.go's [app.headHint]), under a state's own keys and over every
+ // resting sentence.
+ if hint := a.headHint(); hint != "" {
+ return hint
+ }
// AND UNDER THE STATES, BUT OVER EVERY TIP AND DOOR: THE CHORD THAT DID NOT
// ARRIVE. A Mac whose Option key is composing accents answers the switcher's
// chord with the character `˚`, and the legend's own door would go on naming
@@ -4008,7 +4019,7 @@ func (a *app) hintWord() string {
// It costs no rows, for the reason the line above it costs none: this is
// the legend, which is on the frame in every state.
return spellOutHint
- case a.railAway && a.railAvail():
+ case a.railAway && a.railAvail() && a.headHint() == "":
// THE COLUMN IS AWAY AND THIS SESSION HAS RUN SOMETHING (task.go's
// [app.railStow]). It ranks LAST, under every state above it, because it is
// the only line here that is not about the next keystroke — it is where the
@@ -4021,8 +4032,10 @@ func (a *app) hintWord() string {
// there. This is that sign. With nothing run at all it stays quiet — the
// column a person closed was empty, ctrl+g still brings it back, and a
// standing hint about a roster of nothing is the emptiness law broken in
- // the one slot a person reads most.
- return railBackHint
+ // the one slot a person reads most. A word of the head under the pointer
+ // outranks it too ([app.footHint] asks [app.headHint] next): the pointer
+ // is on a word, and the line says that word.
+ return a.sideBackHint()
}
return ""
}
@@ -4138,6 +4151,82 @@ func wrap(text string, width int) []string {
return out
}
+// wrapSheet is [wrap] for a column sheet such as /help. Each source line keeps
+// its own column: a row that already starts in spaces stays there, and a row
+// whose sentence begins after a gap of spaces continues under that sentence.
+// A continuation that fell back to column 0 read as a new key.
+func wrapSheet(text string, width int) []string {
+ if width < 4 {
+ width = 4
+ }
+ text = strings.ReplaceAll(text, "\t", " ")
+ var out []string
+ for _, para := range strings.Split(text, "\n") {
+ if para == "" {
+ out = append(out, "")
+ continue
+ }
+ out = append(out, wrapSheetLine(para, width)...)
+ }
+ return out
+}
+
+// wrapSheetLine wraps one sheet row so every piece starts at the same column
+// as the sentence on the first piece.
+func wrapSheetLine(line string, width int) []string {
+ at := sheetColumn(line)
+ if at <= 0 || at >= width-4 {
+ return strings.Split(ansi.Wrap(line, width, ""), "\n")
+ }
+ head, rest := splitCells(line, at)
+ if strings.TrimSpace(rest) == "" {
+ return []string{line}
+ }
+ body := strings.Split(ansi.Wrap(rest, width-at, ""), "\n")
+ if len(body) == 0 {
+ return []string{line}
+ }
+ pad := strings.Repeat(" ", at)
+ out := make([]string, len(body))
+ out[0] = head + body[0]
+ for i := 1; i < len(body); i++ {
+ out[i] = pad + body[i]
+ }
+ return out
+}
+
+// sheetColumn is where a sheet row's sentence starts, in cells. A row that
+// already begins with spaces is hanging there. Otherwise it is the cell after
+// the first gap of two or more spaces, which is the column the key's sentence
+// is padded to. Zero means the row has no column to keep.
+func sheetColumn(line string) int {
+ lead := 0
+ for _, r := range line {
+ if r != ' ' {
+ break
+ }
+ lead++
+ }
+ if lead > 0 {
+ return lead
+ }
+ gap := 0
+ col := 0
+ for _, r := range line {
+ if r == ' ' {
+ gap++
+ col++
+ continue
+ }
+ if gap >= 2 {
+ return col
+ }
+ gap = 0
+ col += ansi.StringWidth(string(r))
+ }
+ return 0
+}
+
// noteBlockLines is [wrap]'s opposite number for a block whose own line
// structure is the meaning: the text's own lines, each one FITTED to the width
// and none of them re-flowed.
diff --git a/internal/tui3/rewindsheet.go b/internal/tui3/rewindsheet.go
index b419ac0fb8..a173e1f166 100644
--- a/internal/tui3/rewindsheet.go
+++ b/internal/tui3/rewindsheet.go
@@ -82,7 +82,7 @@ const (
// which is the task page's own law about its foot ([app.taskSheetKeysLine]).
rewindSheetKeys = "esc close · ↑↓ move · enter picks the point"
rewindSheetPickedKeys = "esc close · ↑↓ move · enter again rewinds here"
- rewindSheetSearchKeys = "esc clears the search · ↑↓ move · enter picks the point"
+ rewindSheetSearchKeys = "esc clear the search · ↑↓ move · enter picks the point"
// rewindSheetSearchWord opens the line that says what was typed, and
// rewindSheetSearchNone is what that line adds when the search has taken every
// row off the page. A filtered page with nothing on it and nothing said is a
diff --git a/internal/tui3/room.go b/internal/tui3/room.go
index 8e6a2bf89b..6c3f81b826 100644
--- a/internal/tui3/room.go
+++ b/internal/tui3/room.go
@@ -2347,13 +2347,9 @@ func (a *app) goHome() {
//
// A ROW IS A DOOR AND ONLY A DRAWN CONTROL IS ANYTHING ELSE. Anywhere on a
// node's row opens that node's room, which is what every row of this column has
-// always done, and the cells that mean something else are the ones the frame put
-// there to be pressed and no others: a folded root's `▸ +N`, where the count is
-// what says something is hidden, and the glyph cell ON THE FRAMES WHERE IT IS
-// DRAWN AS A DISCLOSURE, which is while the pointer is on a row that can fold.
-// Both come from spans the layout recorded (task.go's [app.railEntryRows]), so
-// the target is always exactly what is on screen; the press does not ask what
-// KIND of row it hit, because a row that could fold is not a fold control.
+// always done; a group's heading opens or folds its group; and the side
+// column's own rows answer through the doors their layout recorded
+// (sidecol.go), so the target is always exactly what is on screen.
//
// The press moves the roster's cursor to what was pressed but does NOT take the
// keyboard: clicks focus what was clicked, and the draft is where this surface
@@ -2396,16 +2392,18 @@ func (a *app) railPress(x, y int) (tea.Cmd, bool) {
a.roomPanelTake(line.roomAction)
return nil, true
}
+ // THE SIDE COLUMN'S OWN ROWS ANSWER FOR THEMSELVES: the header's words and
+ // its key, a band item, a row of the Traffic and the doors on each
+ // (sidecol.go). A row with nothing to press takes the press to do nothing.
+ if line.side != nil {
+ if !line.side.pressable() && line.side.doorAt(a.sideLineCol(x)) < 0 {
+ return nil, true
+ }
+ return a.sidePress(line.side, a.sideLineCol(x))
+ }
// THE FOOTER'S ONE OFFER IS PRESSABLE, because a hint that names a key and
// cannot be pressed is a hint that is only for one of the two hands
// (task.go's [app.railFootRows]).
- // AND THE LAST LINE IS THE COLUMN'S DOOR, for the same reason one rung up: a
- // line that names ctrl+g and cannot be clicked is an affordance for one of the
- // two hands (task.go's [railStowHint]).
- if line.stow {
- a.railStow(true)
- return nil, true
- }
// AND THE DOOR ONTO THE TASK PAGE IS THE THIRD OF THEM, on the same terms: it
// names a chord, so it has to answer to the hand that does not type chords
// ([taskSheetPastHint], taskview.go). It leaves the column exactly as it is —
@@ -2444,30 +2442,22 @@ func (a *app) railPress(x, y int) (tea.Cmd, bool) {
return a.openRailPlan(line.plan, nil), true
}
e, ok := a.railEntryAt(y)
- if !ok || e.node == nil {
+ if !ok {
return nil, true
}
a.railWhere = railSpotOf(e)
- at := x - a.railLeft() - ansi.StringWidth(railSeam)
- // THE WHOLE ROW IS THE NODE'S DOOR AND THE TWO EXCEPTIONS ARE DRAWN. A press
- // falls through to the room unless it landed on something the frame put there
- // to be pressed — the `▸ +N` a folded root wears at rest, and the disclosure
- // the glyph cell becomes under the pointer — and BOTH are read from spans the
- // layout recorded rather than from a question about what kind of row this is
- // (task.go's [app.railEntryRows]). Asking the row's kind was the bug: a family
- // root and a landed row with a block tucked under it CAN fold, so their
- // leading cells folded on every press, while the cell they folded from was
- // drawing the row's state on every frame where the pointer was not already on
- // it. A person aiming at a task got a list that jumped instead of a page.
- switch {
- case line.badge.holds(at):
- a.railSetOpen(e.node, true)
- case line.glyph.holds(at):
- a.railToggle(e.node)
- default:
- return a.openRailRoom(e.node), true
+ // A GROUP'S HEADING OPENS THE GROUP OR FOLDS IT, anywhere on the row, and
+ // the running work's heading, which does not fold, takes the press to do
+ // nothing. EVERY OTHER ROW IS A NODE, and the whole row is its door.
+ if e.head {
+ a.sideToggleGroup(e.group)
+ return nil, true
+ }
+ if e.node == nil {
+ return nil, true
}
- return a.takeRoomPump(), true
+ a.sideAck(e.node)
+ return a.openRailRoom(e.node), true
}
// railSeamAt reports whether a pointer is on the visible two-cell handle — which
diff --git a/internal/tui3/roomclick_test.go b/internal/tui3/roomclick_test.go
index 33c0508fc2..d9e58f6014 100644
--- a/internal/tui3/roomclick_test.go
+++ b/internal/tui3/roomclick_test.go
@@ -34,13 +34,26 @@ func bandSeq() string { return "\x1b[48;5;" + itoa(int(hueSelected.idx)) + "m" }
// walked into, not which row it happened to be on.
func clickRailNode(t *testing.T, a *app, id uint64) {
t.Helper()
+ // THE ROW IS PRESSED WHERE IT IS DRAWN: under its group, opened first, or
+ // in the band when it needs the person (sidecol.go).
+ railOpenAll(a)
+ for _, key := range []string{"task/" + itoa(int(id)), "fail/" + itoa(int(id))} {
+ if a.sideRowOf(key) == nil {
+ a.railRows(a.viewHeight())
+ }
+ if a.sideRowOf(key) != nil {
+ x, y := sideRowOn(t, a, key)
+ drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})
+ return
+ }
+ }
top := a.bodyTop()
for y := top; y < top+a.viewHeight(); y++ {
if node := a.railNodeAt(y); node != nil && node.id == id {
// Past the seam and past the state cell, on the title. The two cells
- // before it are the column's handle at this width, and the one after
- // them is the fold while the pointer is on it (task.go's
- // [app.railLead]) — this press wants neither.
+ // before it are the column's handle at this width, and this press
+ // does not want it.
drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + ansi.StringWidth(railSeam) + 6,
Y: y, Button: tea.MouseLeft})
drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + ansi.StringWidth(railSeam) + 6,
@@ -188,8 +201,8 @@ func roomID(a *app) uint64 {
return a.room.id
}
-// AND THE COLUMN'S OWN DOOR IS STILL PRESSABLE FROM INSIDE A ROOM — the last
-// line of the roster, which names ctrl+g (task.go's [railStowHint]).
+// AND THE COLUMN'S OWN DOOR IS STILL PRESSABLE FROM INSIDE A ROOM: the key
+// at the right of its header, which names alt+l (sidecol.go's [sideHideKey]).
func TestTheColumnsStowLineStillAnswersFromInsideARoom(t *testing.T) {
a, _, _ := roomApp(t)
a.profileDir = t.TempDir()
@@ -198,18 +211,9 @@ func TestTheColumnsStowLineStillAnswersFromInsideARoom(t *testing.T) {
if !a.roomOpen() {
t.Fatal("the rail did not open a room")
}
- at := -1
- for y := a.bodyTop(); y < a.bodyTop()+a.viewHeight(); y++ {
- if line, ok := a.railLineAt(y); ok && line.stow {
- at = y
- break
- }
- }
- if at < 0 {
- t.Fatal("the column drew no door out of itself")
- }
- drive(t, a, tea.MouseClickMsg{X: a.bodyWidth() + 2, Y: at, Button: tea.MouseLeft})
- drive(t, a, tea.MouseReleaseMsg{X: a.bodyWidth() + 2, Y: at, Button: tea.MouseLeft})
+ x, y := sideDoorOf(t, a, sideActHide)
+ drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})
if a.railShowing() {
t.Fatal("a press on the column's own door did not put it away")
}
diff --git a/internal/tui3/roomcrumb_contract_test.go b/internal/tui3/roomcrumb_contract_test.go
index 3c2ce80c88..733bea7fdd 100644
--- a/internal/tui3/roomcrumb_contract_test.go
+++ b/internal/tui3/roomcrumb_contract_test.go
@@ -474,7 +474,7 @@ func TestTheConversationDrawsNoTrailRowOfItsOwn(t *testing.T) {
// THE CONVERSATION'S HEAD IS THE PLACES' HEAD — pulse, strip, rule, blank —
// and the four rows are charged together (head.go). A room opened from here
// would lay its trail on the first row under them.
- want := placeHeadRows
+ want := chatHeadRows
if a.headHeight() != want || a.bodyTop() != want || a.roomHeadRow() != want {
t.Fatalf("the strip is drawn but not budgeted: head=%d top=%d row=%d want=%d",
a.headHeight(), a.bodyTop(), a.roomHeadRow(), want)
diff --git a/internal/tui3/roomorch_transcript_test.go b/internal/tui3/roomorch_transcript_test.go
index c7a064a340..c11ffe2a33 100644
--- a/internal/tui3/roomorch_transcript_test.go
+++ b/internal/tui3/roomorch_transcript_test.go
@@ -70,8 +70,9 @@ func TestRunRailNodeDoorsOpenItsFocusedCard(t *testing.T) {
t.Fatalf("the focused node card is not on the run page:\n%s", got)
}
+ // The newest row is first under its heading: the node, then its run.
a = adaptiveRailApp(t)
- clickRailDoor(t, a, 1)
+ clickRailDoor(t, a, 0)
if !a.orchShowing("r1") || a.orchOf().card != "rfcs" {
t.Fatalf("click opened %+v, want run r1 focused on rfcs", a.orchOf())
}
@@ -79,7 +80,7 @@ func TestRunRailNodeDoorsOpenItsFocusedCard(t *testing.T) {
func TestRunRailRootDoorOpensTheRunPage(t *testing.T) {
a := adaptiveRailApp(t)
- clickRailDoor(t, a, 0)
+ clickRailDoor(t, a, 1)
if !a.orchShowing("r1") {
t.Fatalf("root click did not open run r1: %+v", a.orchOf())
}
diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go
index 2cc3b80be1..afd8753c84 100644
--- a/internal/tui3/roompanel.go
+++ b/internal/tui3/roompanel.go
@@ -38,7 +38,6 @@ func (a *app) roomPanelView(height int) ([]railLine, int) {
width := a.railRoom()
entries := a.railEntries()
focus := a.railFocusIndex(entries)
- a.railCramped = false
lines := a.railLines(entries, width)
controls := a.roomControlRows(width)
foot, marks := a.railFootRows(width, height)
diff --git a/internal/tui3/roompanel_test.go b/internal/tui3/roompanel_test.go
index cbced1b2c1..6b8613f8f4 100644
--- a/internal/tui3/roompanel_test.go
+++ b/internal/tui3/roompanel_test.go
@@ -227,7 +227,6 @@ func TestDeepTaskPanelKeepsAncestorClicksAndControlScope(t *testing.T) {
n := &taskNode{id: id, title: name, label: name, parent: fmt.Sprint(parent), state: session.TaskRunning, model: "z-ai/glm-5.3"}
a.tasks[id] = n
a.taskOrder = append(a.taskOrder, id)
- a.railSetOpen(a.tasks[parent], true)
parent = id
}
for _, width := range []int{100, 120, 160} {
@@ -244,7 +243,7 @@ func TestDeepTaskPanelKeepsAncestorClicksAndControlScope(t *testing.T) {
lines, _ := a.railView(a.viewHeight())
found := false
for row, line := range lines {
- if line.head && line.entry >= 0 && entries[line.entry].node.id == 129 {
+ if line.head && line.entry >= 0 && entries[line.entry].node != nil && entries[line.entry].node.id == 129 {
found = true
drive(t, a, tea.MouseClickMsg{X: width - 2, Y: a.topHeight() + row, Button: tea.MouseLeft})
if a.room.id != 129 {
diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go
index e14e740df9..e5bcd38a49 100644
--- a/internal/tui3/settings.go
+++ b/internal/tui3/settings.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
tea "charm.land/bubbletea/v2"
@@ -18,6 +19,7 @@ import (
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/standing"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
)
// THE SETTINGS PANEL: /settings, or ctrl+, — the FIRST of the three fullscreen
@@ -101,6 +103,12 @@ const (
// tabTasks is how work you can walk away from is run — how it starts, how it
// is checked, how much of it happens at once, and on whose hands.
tabTasks = "Tasks"
+ // tabTeams is what every team inherits when it says nothing of its own:
+ // who answers a member's question, what a team may spend in a day, what a
+ // new sub-team is given, and how deep teams may nest. A team's own
+ // overrides are on its card on the teams page; these are the defaults its
+ // `· from Settings` points at.
+ tabTeams = "Teams"
// tabProviders is which model answers what.
tabProviders = "Providers"
)
@@ -112,23 +120,27 @@ const (
// Spending, Safety and Tasks stand between Display and Providers, and Spending
// leads the three: "what may it spend" is asked before "on which machine", and
// before either of the two questions that used to share its tab.
+//
+// Teams follows Tasks: both are about work you hand off, and its defaults are
+// read after the question of how one task runs.
var settingTabs = []string{tabSession, tabContext, tabWorkspace, tabDisplay,
- tabSpending, tabSafety, tabTasks, tabProviders, tabConnections}
+ tabSpending, tabSafety, tabTasks, tabTeams, tabProviders, tabConnections}
-// settingTabCategory is the ONE-TO-ONE map between the three new tabs and the
-// three registry categories behind them, and it is the seam that keeps the skin
+// settingTabCategory is the ONE-TO-ONE map between the four newer tabs and the
+// four registry categories behind them, and it is the seam that keeps the skin
// honest about the one source of truth.
//
// The other tabs are a reading of the ROWS and not of the categories — "session
// ceiling" is a dollar figure that answers "what may THIS conversation do" — and
-// that stays true of them. These three are different: the registry's own words
-// for them (`spending`, `safety`, `tasks`) are already the product's words for
+// that stays true of them. These four are different: the registry's own words
+// for them (`spending`, `safety`, `tasks`, `teams`) are already the product's words for
// them, so a row that is filed under one and drawn under another would be two
// answers to one question. chrome_test.go pins the map in both directions.
var settingTabCategory = map[string]string{
tabSpending: config.CategorySpending,
tabSafety: config.CategorySafety,
tabTasks: config.CategoryTasks,
+ tabTeams: config.CategoryTeams,
}
// settingWidget is how a row is ANSWERED, which is not quite how it reads.
@@ -770,6 +782,34 @@ var settingUI = map[string]settingMeta{
"context window and goes lean under 32,000 tokens; lean and full say so yourself, " +
"for a provider that reports a window its model does not really have.",
},
+ // ── Teams ───────────────────────────────────────────────────────────────
+ // The five defaults every team inherits, in the order a person reaches
+ // for them (DESIGN.md section 8, the settings tab's Teams group).
+ config.KeyTeamsQuestionsUp: {
+ tab: tabTeams, label: "questions go to the manager", widget: widgetToggle,
+ about: "a member's clarifying question goes to its manager first; you are asked only " +
+ "what no manager can answer. Permission prompts always come to you.",
+ },
+ config.KeyTeamsWake: {
+ tab: tabTeams, label: "team messages wake", widget: widgetToggle,
+ about: "a manager's directive starts an idle member's turn, and a member's reply " +
+ "starts the manager's. Off, messages wait for the next turn.",
+ },
+ config.KeyTeamsCapUSDDay: {
+ tab: tabTeams, label: "daily cap per team", widget: widgetText,
+ about: "what a team and the teams under it may spend in a day before its manager " +
+ "asks you whether to go on. Blank or 0 is no cap.",
+ },
+ config.KeyTeamsSubSharePct: {
+ tab: tabTeams, label: "sub-team share", widget: widgetText,
+ about: "the share of its parent's cap a new sub-team starts with. Teams that " +
+ "already exist keep theirs.",
+ },
+ config.KeyTeamsDepthLimit: {
+ tab: tabTeams, label: "team depth", widget: widgetText,
+ about: "how many levels of teams a manager may build, the top team counting as one. " +
+ "1 means no sub-teams.",
+ },
}
func init() {
@@ -957,6 +997,19 @@ func (i sheetItem) restful() bool { return i.head == "" && i.read == nil }
// frame nothing.
type sheet struct {
tab int
+ // host is the machine the session runs on over --host, "" otherwise.
+ host string
+ // teamDefaultsWrite says the teams seam can change that machine's `teams.`
+ // rows ([TeamsSeam.ApplyDefault]). False over --host against an engine
+ // without the door, and the Teams tab stays read-only.
+ teamDefaultsWrite bool
+ // farTeams is that machine's five defaults, once the seam has answered.
+ // Nil until then, and nil on a local launch.
+ farTeams *teamstore.Defaults
+ // hostNote is the settings sentence already written into the transcript
+ // for this visit. The same words are not written again when the tab
+ // moves between two tabs that share a disk.
+ hostNote string
registry *config.Settings
// profileDir is retained only for live explanations derived from several
@@ -1272,16 +1325,6 @@ func (a *app) openSettings() { a.showPage(pageSettings) }
// raiseSettings builds the panel. It is [placeSettings]'s `open` and nothing
// else calls it, which is what makes the router the one road in.
func (a *app) raiseSettings() {
- // THE PANEL OPENS AND SAYS WHOSE ROWS THESE ARE. Over --host it edits this
- // machine's profile, and only some of these rows are about this machine: the
- // mouse, the timestamps, the draft and the history are the surface's own and
- // apply; the tool gate, the spend rail and the auxiliary models are the
- // SESSION's, and the session reads them from the profile on the other machine.
- // Closing the panel would take the working half away; opening it silently
- // would let somebody turn a gate off and watch it stay on (host.go).
- if a.hosted() {
- a.note(settingsRemoteWord)
- }
// The day's own figure, read once for the whole of this visit (see
// [app.spentTodayUSD]).
a.readDayCost()
@@ -1291,9 +1334,15 @@ func (a *app) raiseSettings() {
// [app.spentThisSessionUSD], issue #269). It is a tail read
// ([session.UsageCache]) and it happens once per visit, never on a draw.
a.readTreeSpend()
+ write := false
+ if a.hosted() && a.teamsDisk.door.present() && a.teamsDisk.door.ApplyDefault != nil && a.teamsDisk.door.Defaults != nil {
+ write = true
+ }
a.sheet = sheet{
- registry: a.registry(),
- profileDir: a.profileDir,
+ host: a.host,
+ teamDefaultsWrite: write,
+ registry: a.registry(),
+ profileDir: a.profileDir,
// AND WHAT THIS PROJECT DOES WITH A QUESTION WHILE NOBODY IS THERE. The
// rows are the engine's rather than the registry's (settingsautonomy.go),
// and a page that asked the engine per frame would be paying for an
@@ -1315,9 +1364,32 @@ func (a *app) raiseSettings() {
}
a.sheet.rows = a.sheet.registry.Rows()
a.sheet.build()
+ // WHOSE ROWS, SAID FOR THE TAB ON SHOW. Over --host the note is
+ // [settingsHostNote]: this machine on every tab but Teams, and the far
+ // machine on Teams when the seam can write it. It is said after the sheet
+ // exists, because the sentence is a reading of that sheet. Closing the
+ // panel would take the working rows away; opening it silently would let
+ // somebody turn a gate off and watch it stay on (host.go).
+ a.saySettingsHost()
a.touch()
}
+// saySettingsHost writes the hosted settings sentence when it is not the one
+// already in the transcript. A local launch says nothing. Moving between two
+// tabs that share a disk says nothing again.
+func (a *app) saySettingsHost() {
+ if !a.hosted() {
+ return
+ }
+ onTeams := a.sheet.tab >= 0 && a.sheet.tab < len(settingTabs) && settingTabs[a.sheet.tab] == tabTeams
+ word := settingsHostNote(onTeams, a.host, a.sheet.teamDefaultsWrite, a.sheet.farTeams != nil)
+ if word == a.sheet.hostNote {
+ return
+ }
+ a.sheet.hostNote = word
+ a.note(word)
+}
+
// closeSettings is the DOOR out of the panel, and it goes through the router.
func (a *app) closeSettings() {
if a.at(pageSettings) {
@@ -1743,8 +1815,12 @@ func (s *sheet) changed(item sheetItem) bool {
case config.SettingModel, config.SettingPercent:
return false
}
+ value := item.row.Value()
+ if raw, ok := s.farTeamValue(item.row.Key); ok {
+ value = raw
+ }
was, known := s.defaults[item.row.Key]
- return known && was != item.row.Value()
+ return known && was != value
}
// ── the roles ───────────────────────────────────────────────────────────────
@@ -2094,10 +2170,12 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
// only in the message one (editundo.go).
if editorUndo(&s.query, msg.String()) {
s.build()
+ a.saySettingsHost()
return nil, true
}
if editorWordKill(&s.query, msg.String()) {
s.build()
+ a.saySettingsHost()
return nil, true
}
@@ -2112,6 +2190,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
if s.searching() {
s.query.reset()
s.build()
+ a.saySettingsHost()
return nil, true
}
if a.connEsc() {
@@ -2198,6 +2277,7 @@ func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
s.build()
}
}
+ a.saySettingsHost()
return nil, true
}
@@ -2257,6 +2337,14 @@ func (a *app) activate() tea.Cmd {
return a.startModelConnect(modelConnectionStatus(source, true), true)
}
s.msg = ""
+ // OVER --host THE TEAMS DEFAULTS ARE THAT MACHINE'S. With the seam's write
+ // door they are edited there, the same keys the local tab writes. Without
+ // it an edit here would change this laptop's file and no team anybody is
+ // running, so the row says so instead.
+ if s.host != "" && item.meta.tab == tabTeams && !s.teamDefaultsWrite {
+ s.msg = s.hostTeamsLockedWord()
+ return nil
+ }
if item.autonomy != nil {
return a.autonomyRowNext(item.autonomy)
}
@@ -2277,9 +2365,16 @@ func (a *app) activate() tea.Cmd {
switch item.meta.widget {
case widgetToggle:
next := "on"
- if item.row.Value() == "on" {
+ cur := item.row.Value()
+ if raw, ok := s.farTeamValue(item.row.Key); ok {
+ cur = raw
+ }
+ if cur == "on" {
next = "off"
}
+ if cmd := a.writeHostTeamDefault(item, next); cmd != nil || s.hostTeamRow(item) {
+ return cmd
+ }
a.applySetting(item, next)
case widgetCycle:
@@ -2335,6 +2430,9 @@ func (a *app) activate() tea.Cmd {
default:
value := item.row.Value()
+ if raw, ok := s.farTeamValue(item.row.Key); ok {
+ value = raw
+ }
if value == item.row.EmptyLabel {
// The empty label is what the row SAYS when it holds nothing
// ("none", "follows the conversation"). Putting that word in the box
@@ -2519,29 +2617,34 @@ func (a *app) applySetting(item sheetItem, raw string) {
// sheetEditKey drives the text submenu. enter saves, an empty box clears the
// row, esc leaves it exactly as it was.
-func (a *app) sheetEditKey(msg tea.KeyPressMsg) {
+func (a *app) sheetEditKey(msg tea.KeyPressMsg) tea.Cmd {
s := &a.sheet
edit := s.edit
// The word and line jumps are the surface's, said once (editkeys.go).
if editorMotion(&edit.box, msg.String()) {
- return
+ return nil
}
// AND ctrl+z TAKES BACK WHAT WAS TYPED, in every box on this surface and not
// only in the message one (editundo.go).
if editorUndo(&edit.box, msg.String()) {
- return
+ return nil
}
switch msg.String() {
case "esc":
s.edit = nil
case "enter":
row, ok := s.registry.Row(edit.key)
+ raw := edit.box.String()
s.edit = nil
if !ok {
- return
+ return nil
}
meta, _ := settingMetaFor(row)
- a.applySetting(sheetItem{row: row, meta: meta}, edit.box.String())
+ item := sheetItem{row: row, meta: meta}
+ if cmd := a.writeHostTeamDefault(item, raw); cmd != nil || s.hostTeamRow(item) {
+ return cmd
+ }
+ a.applySetting(item, raw)
case "backspace":
edit.box.deleteBackward()
case "delete":
@@ -2569,6 +2672,146 @@ func (a *app) sheetEditKey(msg tea.KeyPressMsg) {
edit.box.insert(text)
}
}
+ return nil
+}
+
+// hostTeamRow reports whether this row is a Teams default edited on the far
+// machine. The local registry is not the writer then.
+func (s *sheet) hostTeamRow(item sheetItem) bool {
+ return s.host != "" && s.teamDefaultsWrite && item.meta.tab == tabTeams
+}
+
+// hostTeamsLockedWord is the one line an older engine gets: the tab can be
+// read and cannot be changed over this connection.
+func (s *sheet) hostTeamsLockedWord() string {
+ if s.farTeams != nil {
+ return "changing them is not available over this connection"
+ }
+ return "the teams on " + s.host + " inherit that machine's Settings; change them there"
+}
+
+// farTeamValue is one Teams row as the registry's Value would read it from the
+// far machine's defaults. The bool is false until that read has landed, and
+// false for every other row.
+func (s *sheet) farTeamValue(key string) (string, bool) {
+ if s.farTeams == nil {
+ return "", false
+ }
+ d := s.farTeams
+ switch key {
+ case config.KeyTeamsQuestionsUp:
+ if d.QuestionsUp {
+ return "on", true
+ }
+ return "off", true
+ case config.KeyTeamsWake:
+ if d.Wake {
+ return "on", true
+ }
+ return "off", true
+ case config.KeyTeamsCapUSDDay:
+ if d.CapUSDDay == 0 {
+ return "no cap", true
+ }
+ return "$" + strconv.FormatFloat(d.CapUSDDay, 'f', -1, 64), true
+ case config.KeyTeamsDepthLimit:
+ return strconv.Itoa(d.DepthLimit), true
+ case config.KeyTeamsSubSharePct:
+ return strconv.Itoa(int(d.SubShare*100 + 0.5)), true
+ }
+ return "", false
+}
+
+// hostedTeamReading is that value as the row draws it, with the unit the
+// registry would add and the provenance the team card uses for a value that
+// comes from these rows ([teamstore.Origin.Words], `from Settings`).
+func (s *sheet) hostedTeamReading(item sheetItem) (string, bool) {
+ raw, ok := s.farTeamValue(item.row.Key)
+ if !ok {
+ return "", false
+ }
+ value := raw
+ switch item.row.Key {
+ case config.KeyTeamsDepthLimit:
+ unit := "levels"
+ if raw == "1" {
+ unit = "level"
+ }
+ value = raw + " " + unit
+ case config.KeyTeamsSubSharePct:
+ value = raw + "%"
+ }
+ if words := (teamstore.Origin{Kind: teamstore.OriginSettings}).Words(); words != "" {
+ value += " · " + words
+ }
+ return value, true
+}
+
+// readHostTeamDefaults asks the seam for the far machine's `teams.` rows, off
+// the loop. It is nil locally and against an engine that cannot answer.
+func (a *app) readHostTeamDefaults() tea.Cmd {
+ if !a.hosted() {
+ return nil
+ }
+ door := a.teamsDisk.door
+ if !door.present() || door.Defaults == nil {
+ return nil
+ }
+ read := door.Defaults
+ return a.besideLine(func() func(bool) tea.Cmd {
+ d, err := read()
+ return func(here bool) tea.Cmd {
+ if !here || !a.at(pageSettings) {
+ return nil
+ }
+ if err != nil {
+ a.sheet.msg = err.Error()
+ a.touch()
+ return nil
+ }
+ a.sheet.farTeams = &d
+ a.sheet.build()
+ a.saySettingsHost()
+ a.touch()
+ return nil
+ }
+ })
+}
+
+// writeHostTeamDefault sends one Teams row through the seam, off the loop.
+// A nil command with [sheet.hostTeamRow] false means this row is local and
+// the caller writes it the usual way.
+func (a *app) writeHostTeamDefault(item sheetItem, raw string) tea.Cmd {
+ if !a.sheet.hostTeamRow(item) {
+ return nil
+ }
+ if a.sheet.farTeams == nil {
+ return nil
+ }
+ door := a.teamsDisk.door.ApplyDefault
+ if door == nil {
+ a.sheet.msg = a.sheet.hostTeamsLockedWord()
+ return nil
+ }
+ key := item.row.Key
+ return a.offLoop(func() func(bool) tea.Cmd {
+ d, err := door(key, raw)
+ return func(here bool) tea.Cmd {
+ if !here || !a.at(pageSettings) {
+ return nil
+ }
+ if err != nil {
+ a.sheet.msg = err.Error()
+ } else {
+ copied := d
+ a.sheet.farTeams = &copied
+ a.sheet.msg = ""
+ }
+ a.sheet.build()
+ a.touch()
+ return nil
+ }
+ })
}
// sheetSelectKey drives the model picker while a slot row owns it. Only the two
@@ -2693,6 +2936,7 @@ func (a *app) sheetPress(x, y int) tea.Cmd {
a.sheet.cursor, a.sheet.top, a.sheet.msg = 0, 0, ""
a.sheet.conn.armed, a.sheet.conn.entry = false, nil
a.sheet.build()
+ a.saySettingsHost()
a.touch()
}
case sheetHitRow:
@@ -2754,9 +2998,7 @@ func (a *app) sheetFrame(width, height int) ([]string, []sheetHit, int, int) {
// row is gone — the place tab bar above says `settings` — and so is its keys
// line, which is the one hint every place shares now. ITS OWN TAB BAR STAYS,
// as the first row of its body, and the two bars are not a repetition: the
- // upper one is the seven places and the lower one is this place's sections.
- // The panel is where [placeTabBar] was lifted from, so they are drawn by the
- // same geometry and read as one object at two scales.
+ // nav at the top is the places and this one is this place's sections.
s := &a.sheet
pal := a.pal
lines, hits, caretX, caretY := placeFrame(a, width, height,
@@ -2857,9 +3099,9 @@ func tabChipCols(title string) int { return ansi.StringWidth(title) + tabPadCols
// account, were also unreachable by eye: nobody discovers a tab they have never
// seen.
//
-// SCROLLING, NOT COLLAPSING, AND HERE IS WHY. The place bar solves the same
-// squeeze by giving words up in a stated order until only the word you are
-// standing in is left ([app.placeTabBar]'s width ladder), and that is right
+// SCROLLING, NOT COLLAPSING, AND HERE IS WHY. The nav solves the same squeeze
+// by folding words into `more ▾` in a stated order until only the word you are
+// standing in is left (topnav.go's width ladder), and that is right
// THERE because its words are rooms — each one is a door you reach by name, the
// bar is a list of the ones worth naming, and a room with something new in it
// earns its cells over a room with nothing. These nine are not a list of doors;
@@ -3205,6 +3447,9 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo
// beside the default, rather than spelled again by every surface that draws
// a number ([config.Setting.Reading]).
value := item.row.Reading()
+ if hosted, ok := s.hostedTeamReading(item); ok {
+ value = hosted
+ }
if value == "" {
value = "—"
}
@@ -3369,6 +3614,21 @@ func (s *sheet) footNote() string {
if s.onConnections() {
return s.connFootNote()
}
+ // THE TEAMS TAB IS DEFAULTS, and says where the exceptions live: a team's
+ // own overrides are on its card on the teams page (teamsheet.go). Over
+ // --host it says whose defaults the teams there really read.
+ if settingTabs[s.tab] == tabTeams {
+ if s.host != "" && !s.teamDefaultsWrite {
+ if s.farTeams != nil {
+ return "on " + s.host + " the teams inherit that machine's Settings · changing them is not available over this connection"
+ }
+ return "on " + s.host + " the teams inherit that machine's Settings · these are this one's, shown and not edited"
+ }
+ if s.host != "" {
+ return "a team can override any of these on its card · saved on " + s.host
+ }
+ return "a team can override any of these on its card · saved to your profile"
+ }
if item, ok := s.current(); ok {
if name, pinned := item.row.PinnedBy(); pinned {
return "held by " + name + " — unset it to change this here"
diff --git a/internal/tui3/settingspend_test.go b/internal/tui3/settingspend_test.go
index e84ee601f8..8864d679a1 100644
--- a/internal/tui3/settingspend_test.go
+++ b/internal/tui3/settingspend_test.go
@@ -127,7 +127,7 @@ func TestSafetyAndTasksHoldWhatSpendingLetGoAndWorkspaceNamesNoMoney(t *testing.
}
// AND THE BAR READS IN THE DESIGN'S ORDER.
want2 := []string{tabSession, tabContext, tabWorkspace, tabDisplay,
- tabSpending, tabSafety, tabTasks, tabProviders, tabConnections}
+ tabSpending, tabSafety, tabTasks, tabTeams, tabProviders, tabConnections}
if strings.Join(settingTabs, " ") != strings.Join(want2, " ") {
t.Fatalf("the tab bar reads %v", settingTabs)
}
diff --git a/internal/tui3/settingsteams_host_test.go b/internal/tui3/settingsteams_host_test.go
new file mode 100644
index 0000000000..94a6d3429d
--- /dev/null
+++ b/internal/tui3/settingsteams_host_test.go
@@ -0,0 +1,139 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// AN EDIT OVER A HOST SEAM LANDS IN THE FAR PROFILE and nowhere on this
+// machine. The row says where the value came from in the same words the team
+// card uses for a value inherited from these defaults.
+func TestTeamsSettingsOverAHostSeamLandInTheFarProfile(t *testing.T) {
+ a := placeApp(t)
+ laptop := a.profileDir
+ far := t.TempDir()
+ a.host = "spark"
+ a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch)
+ before := teamstore.DefaultsAt(laptop)
+ drive(t, a, key(placeChord(pageSettings)))
+ openTeamsTab(t, a)
+ if a.sheet.farTeams == nil || !a.sheet.teamDefaultsWrite {
+ t.Fatal("the Teams tab did not take the far defaults as editable")
+ }
+ if note := a.sheet.footNote(); !strings.Contains(note, "saved on spark") {
+ t.Fatalf("the Teams tab says %q", note)
+ }
+ if screen := placeFrameText(a); !strings.Contains(screen, "from Settings") {
+ t.Fatalf("the row does not say where the value came from:\n%s", screen)
+ }
+ if item, ok := a.sheet.current(); !ok || item.row.Key != config.KeyTeamsQuestionsUp {
+ t.Fatalf("the cursor is not on questions: %+v", item.row.Key)
+ }
+ drive(t, a, key("enter"))
+ got := teamstore.DefaultsAt(far)
+ if got.QuestionsUp {
+ t.Fatal("the far profile still has questions going up")
+ }
+ if after := teamstore.DefaultsAt(laptop); after != before {
+ t.Fatalf("the laptop profile changed: %+v", after)
+ }
+ if words := (&teamstore.File{}).Effective("missing", got).QuestionsUpFrom.Words(); words != "from Settings" {
+ t.Fatalf("provenance %q", words)
+ }
+
+ drive(t, a, key("down"), key("down"), key("enter"), key("4"), key("enter"))
+ if cap := teamstore.DefaultsAt(far).CapUSDDay; cap != 4 {
+ t.Fatalf("the far cap is %v", cap)
+ }
+ if teamstore.DefaultsAt(laptop) != before {
+ t.Fatal("the cap write touched the laptop profile")
+ }
+}
+
+// AN OLDER ENGINE KEEPS THE TAB READ-ONLY and says so in one line. The far
+// profile is not written, and neither is this machine's.
+func TestTeamsSettingsOverAnOlderEngineStayReadOnly(t *testing.T) {
+ a := placeApp(t)
+ laptop := a.profileDir
+ far := t.TempDir()
+ door := localTeams(far, &a.teamsDisk.watch)
+ door.ApplyDefault = nil
+ a.host = "spark"
+ a.teamsDisk.door = door
+ beforeLap := teamstore.DefaultsAt(laptop)
+ beforeFar := teamstore.DefaultsAt(far)
+ drive(t, a, key(placeChord(pageSettings)))
+ openTeamsTab(t, a)
+ if a.sheet.teamDefaultsWrite {
+ t.Fatal("an older engine's Teams tab is editable")
+ }
+ if a.sheet.farTeams == nil {
+ t.Fatal("the tab did not show the far defaults it can still read")
+ }
+ if note := a.sheet.footNote(); !strings.Contains(note, "not available over this connection") {
+ t.Fatalf("the Teams tab says %q", note)
+ }
+ before := a.sheet.items[a.sheet.cursor].row.Value()
+ drive(t, a, key("enter"))
+ if a.sheet.edit != nil || !strings.Contains(a.sheet.msg, "not available over this connection") {
+ t.Fatalf("a Teams row took an edit (msg %q)", a.sheet.msg)
+ }
+ if after := a.sheet.items[a.sheet.cursor].row.Value(); after != before {
+ t.Fatalf("the row changed from %q to %q", before, after)
+ }
+ if teamstore.DefaultsAt(far) != beforeFar || teamstore.DefaultsAt(laptop) != beforeLap {
+ t.Fatal("a refused edit was written")
+ }
+}
+
+// THE NOTE FOLLOWS THE TAB. A local tab says the rows are this machine's. The
+// Teams tab, over a seam that can write, says they are saved on the far
+// machine and does not repeat the local claim.
+func TestSettingsNoteFollowsTheTabOverAHostSeam(t *testing.T) {
+ a := placeApp(t)
+ far := t.TempDir()
+ a.host = "spark"
+ a.teamsDisk.door = localTeams(far, &a.teamsDisk.watch)
+ drive(t, a, key(placeChord(pageSettings)))
+ local := lastNote(t, a)
+ if local != settingsLocalWord {
+ t.Fatalf("local tab note = %q", local)
+ }
+ if strings.Contains(local, "spark") {
+ t.Fatalf("the local tab named the far machine: %q", local)
+ }
+ for settingTabs[a.sheet.tab] != tabTeams {
+ drive(t, a, key("right"))
+ }
+ if !a.sheet.teamDefaultsWrite {
+ t.Fatal("the seam did not make the Teams tab writable")
+ }
+ got := lastNote(t, a)
+ if got != "these rows are saved on spark." {
+ t.Fatalf("Teams tab note = %q", got)
+ }
+ if strings.Contains(got, "this machine") {
+ t.Fatalf("the Teams tab still claims this machine: %q", got)
+ }
+ drive(t, a, key("left"))
+ if settingTabs[a.sheet.tab] == tabTeams {
+ t.Fatal("left did not leave the Teams tab")
+ }
+ if again := lastNote(t, a); again != settingsLocalWord {
+ t.Fatalf("back on a local tab the note is %q", again)
+ }
+}
+
+func openTeamsTab(t *testing.T, a *app) {
+ t.Helper()
+ for i, title := range settingTabs {
+ if title == tabTeams {
+ a.sheet.tab = i
+ }
+ }
+ a.sheet.cursor = 0
+ a.sheet.build()
+}
diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go
new file mode 100644
index 0000000000..ebe39e8ea6
--- /dev/null
+++ b/internal/tui3/sidecol.go
@@ -0,0 +1,993 @@
+package tui3
+
+import (
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE SIDE COLUMN: ONE COLUMN, TWO SOURCES ────────────────────────────────
+//
+// The right of every conversation is one column with one header, and the
+// header is two words:
+//
+// │ Tasks 14 · Traffic 3 new alt+l
+// │ ? @security asks: ship at p99 180ms? the needs-you band
+// │ ✗ parser bench failed a failure, in ink
+// │ ────────────────────────────────────────────
+// │ Running 4
+// │ ⠙ rebase onto dev 2m
+// │ Queued 3 ▸
+// │ Done 7 ▸
+//
+// THERE USED TO BE TWO COLUMNS AND A SPECIAL CASE BETWEEN THEM. An ordinary
+// chat had the task column; a chat with a team manager in front had the
+// Traffic instead, with the manager's own tasks behind a `Traffic · Tasks 2`
+// word. Now there is this one component and it has two sources: the
+// conversation's own work ([sideTasks], task.go's roster) and what passes in
+// its team ([sideTraffic], sidetraffic.go). A chat that is in no team has only
+// the first, and its header is the one word.
+//
+// THE CURRENT WORD IS BOLD INK AND THE OTHER IS DIM, and each is a door with a
+// hover ground and a hint. The other word keeps its count, so what arrived in
+// the Traffic while the tasks are in front is still on screen (`Traffic 3
+// new`). Which word is in front is remembered for the session per kind of chat
+// ([sideState.view]): a manager chat opens on the Traffic, every other chat on
+// the tasks.
+//
+// THE BAND IS WHAT NEEDS THE PERSON NOW, from both sources ([app.sideBand]):
+// a question or a packet waiting on them, a task whose next step is theirs,
+// and a failure nobody has looked at yet. Amber is ONLY for the first kind,
+// the items blocked on the person; a failure is a ✗ in ordinary ink. The band
+// shows [sideBandCap] rows and then `+N more`, is gone entirely when there is
+// nothing in it, and an item in it is not drawn again in the list under it.
+// It pushes the list down and never the conversation: the column's width does
+// not move for anything drawn in it.
+//
+// NOTHING HERE MOVES THE CONVERSATION. The column's width is decided by the
+// frame's width and the person's own widen answer and by nothing it shows
+// ([app.railColumns]), so switching words, folding a group, a row arriving
+// and the band appearing all happen inside the column. Nothing here takes the
+// keyboard either: alt+t hands it to the column, as it always has, and esc
+// gives it back (task.go's [app.railKey]).
+//
+// THE FRAME DRAWS MEMORY. The Traffic is the cache teamtraffic.go's clock
+// keeps, the tasks are the roster's nodes, and the rows the Traffic view lays
+// are kept between frames ([sideTrafficCache]) until what they are drawn from
+// moves.
+
+// The two sources, as the column's view.
+const (
+ sideTasks = 1
+ sideTraffic = 2
+)
+
+// The three kinds of chat, which is what the view is remembered by.
+const (
+ sideKindPlain = iota
+ sideKindMember
+ sideKindManager
+ sideKinds
+)
+
+// Geometry.
+const (
+ // The column takes a quarter of the frame, between sideColsMin and
+ // sideColsMax, and never leaves the conversation under sideBodyFloor. It
+ // is the same at every view and in every kind of chat: at 110 columns it
+ // is 28, at 120 it is 30 (the task column's old width), at 160 it is 40.
+ // A quarter and not the Traffic's old three tenths, because this is now
+ // the column of every chat and the conversation beside it is the page.
+ sideColsMin = 28
+ sideColsMax = 40
+ sideBodyFloor = 56
+ // sideBandCap is how many rows the band draws before `+N more`.
+ sideBandCap = 3
+)
+
+// The header's words.
+const (
+ sideTasksWord = "Tasks"
+ sideTrafficWord = "Traffic"
+ sideNewWord = "new"
+ sideWordSep = " · "
+ // sideHideKey is the column's own key, named at the right of its header.
+ // ctrl+g ([railStowKey]) is the same key under its older name.
+ sideHideKey = trafficKey
+)
+
+// sideState is the column's memory for the session: which word is in front
+// per kind of chat, which task groups and work threads the person opened,
+// which failures they have looked at, and where the Traffic's `new` line
+// stands. It is this window's and in memory only.
+type sideState struct {
+ view [sideKinds]int
+ // open is each foldable task group the person opened.
+ open [railGroupCount]bool
+ // threads is each work thread laid open, by [sideThreadKey], and moved
+ // counts the presses that changed it, which the rows' cache keys on.
+ threads map[string]bool
+ moved int
+ // acked is every failed task the person has opened since it failed.
+ acked map[uint64]bool
+ // bandAll says the band draws every item instead of [sideBandCap].
+ bandAll bool
+ // divider is, per team, the newest entry the person had seen when the
+ // Traffic came into view, which is where the `new` line is drawn; up is
+ // the team whose Traffic is in view now, so the line holds still while it
+ // is being read.
+ divider map[string]string
+ up string
+ // asks is the band's reading of the Traffic, kept until the log moves.
+ asks sideAskCache
+ // traffic is the Traffic view's rows as last laid.
+ traffic sideTrafficCache
+ // last is the band and Traffic rows the last layout drew, for the hint
+ // line, and pinned how many rows at its top were the header, the band and
+ // the way back to the main chat, which nothing is laid above.
+ last []*sideRow
+ pinned int
+}
+
+// ── A ROW AND WHAT A PRESS ON IT DOES ───────────────────────────────────────
+
+// The acts a row or a door of the column does.
+const (
+ sideActNone = iota
+ // sideActOpen opens task id.
+ sideActOpen
+ // sideActJump takes the person to Traffic entry entry, in member key's
+ // conversation or, with no key, in the one in front (teamjump.go).
+ sideActJump
+ // sideActThread lays work thread key open or folds it.
+ sideActThread
+ // sideActBand shows every band item, or folds them back to three.
+ sideActBand
+ // sideActView brings word view to the front.
+ sideActView
+ // sideActHide puts the column away.
+ sideActHide
+ // sideActTeams opens the teams page, where a packet is answered.
+ sideActTeams
+)
+
+// sideAct is what a press does.
+type sideAct struct {
+ kind int
+ id uint64
+ key string
+ entry string
+ view int
+}
+
+// sideDoor is a target narrower than its row: a word of the header, a handle,
+// a thread's ▸. span is in the row's own columns.
+type sideDoor struct {
+ span hudSpan
+ hint string
+ act sideAct
+}
+
+// sideRow is one row of the band or the Traffic view: its identity, which is
+// what the hover and the keyboard hold, what the hint line says over it, what
+// a press on it does, and its doors.
+type sideRow struct {
+ key string
+ hint string
+ act sideAct
+ doors []sideDoor
+}
+
+// doorAt is the door under column x of the row, -1 for none.
+func (r *sideRow) doorAt(x int) int {
+ for i, d := range r.doors {
+ if d.span.holds(x) {
+ return i
+ }
+ }
+ return -1
+}
+
+// pressable reports whether a press anywhere on the row does something.
+func (r *sideRow) pressable() bool { return r.act.kind != sideActNone }
+
+// ── WHICH CHAT, WHICH WORD, HOW WIDE ────────────────────────────────────────
+
+// sideColsFor is the column the frame lends at width, 0 when the
+// conversation would be left too narrow for one.
+func sideColsFor(width int) int {
+ if width < railSlimFloor {
+ return 0
+ }
+ cols := min(max(width/4, sideColsMin), sideColsMax)
+ if width-cols < sideBodyFloor {
+ return 0
+ }
+ return cols
+}
+
+// sideTeam is the team the chat in front is in, as the column reads it: the
+// team it manages, or the managed team it is a member of with its handle.
+// Plain for a chat in no managed team, and on a window that cannot read the
+// engine's teams ([app.teamsOff]). Frame-safe and allocation free.
+func (a *app) sideTeam() (team, int, string) {
+ if a.teamsOff() || !a.wall.loaded || len(a.wall.teams) == 0 {
+ return team{}, sideKindPlain, ""
+ }
+ if t, ok := a.teamFrontManaged(); ok {
+ return t, sideKindManager, ""
+ }
+ front := a.frontTabKey()
+ for _, t := range a.wall.teams {
+ if t.Manager == "" || t.Manager == front {
+ continue
+ }
+ if m, ok := t.Member(front); ok && m.Handle != "" {
+ return t, sideKindMember, m.Handle
+ }
+ }
+ return team{}, sideKindPlain, ""
+}
+
+// sideKind is the kind of chat in front.
+func (a *app) sideKind() int {
+ _, kind, _ := a.sideTeam()
+ return kind
+}
+
+// sideView is the word in front: the person's own choice for this kind of
+// chat, else the Traffic in a manager chat and the tasks everywhere else. A
+// chat in no team has only the tasks.
+func (a *app) sideView() int {
+ kind := a.sideKind()
+ if kind == sideKindPlain {
+ return sideTasks
+ }
+ if v := a.side.view[kind]; v != 0 {
+ return v
+ }
+ if kind == sideKindManager {
+ return sideTraffic
+ }
+ return sideTasks
+}
+
+// sideSetView brings view to the front and remembers it for this kind of
+// chat. It moves nothing but what the column draws: the keyboard stays where
+// it was, and a cursor on a row the other view does not have goes to the
+// view's first row.
+func (a *app) sideSetView(view int) {
+ kind := a.sideKind()
+ if kind == sideKindPlain || a.sideView() == view {
+ return
+ }
+ a.side.view[kind] = view
+ a.side.up = ""
+ a.dropHover()
+ if a.railHold {
+ a.railWhere = railSpot{}
+ if spots := a.railSpots(); len(spots) > 0 {
+ a.railWhere = spots[0]
+ }
+ }
+ a.touch()
+}
+
+// sideStep switches to the other word, which is what ← and → do while the
+// column holds the keyboard.
+func (a *app) sideStep() {
+ if a.sideView() == sideTasks {
+ a.sideSetView(sideTraffic)
+ return
+ }
+ a.sideSetView(sideTasks)
+}
+
+// sideAway reports whether the person has put the column away: the saved
+// answer, and on the teams page the page's own, which starts folded because
+// the page has a rail of its own on the left (teamspagehost.go).
+func (a *app) sideAway() bool {
+ if a.teamsHosting() {
+ return !a.tp.traffic
+ }
+ return a.railAway
+}
+
+// sideToggle is alt+l: the column goes away or comes back where the frame
+// lends it one, and on a frame too narrow for one it is laid over the body
+// and taken off again ([app.railFull]).
+func (a *app) sideToggle() bool {
+ if a.railShowing() || a.railStowed() {
+ a.railStow(a.railShowing())
+ return true
+ }
+ if a.railFull() {
+ a.railTake(false)
+ return true
+ }
+ if a.railAvail() && !a.railQuiet() {
+ a.railTake(true)
+ return a.railHold
+ }
+ return false
+}
+
+// ── THE HEADER ──────────────────────────────────────────────────────────────
+
+// sideHeadRow is the column's first line, width wide, and its doors: the two
+// words, and the key that puts the column away at the right.
+func (a *app) sideHeadRow(width int) (string, *sideRow) {
+ t, kind, handle := a.sideTeam()
+ view := a.sideView()
+ row := &sideRow{key: sideHeadKey}
+ hot := -1
+ if a.hot.kind == hoverSide && a.hot.key == sideHeadKey {
+ hot = a.hot.index
+ }
+ var b strings.Builder
+ used := 0
+ // word paints one word of the header: its label bold ink when it is in
+ // front and dim when it is not, its count the same except that a zero is
+ // always dim and a count of new rows (fresh) is always ink, so what arrived
+ // behind the other word is still read.
+ word := func(label, count string, fresh bool, current bool, act sideAct, hint string) {
+ text := label + " " + count
+ if fresh {
+ text += " " + sideNewWord
+ }
+ var painted string
+ switch {
+ case current:
+ painted = a.pal.bold(a.pal.ink(label + " "))
+ default:
+ painted = a.pal.dim(label + " ")
+ }
+ switch {
+ case count == "0":
+ painted += a.pal.dim(count)
+ case fresh:
+ painted += a.pal.ink(count + " " + sideNewWord)
+ case current:
+ painted += a.pal.bold(a.pal.ink(count))
+ default:
+ painted += a.pal.dim(count)
+ }
+ w := ansi.StringWidth(text)
+ if act.kind != sideActNone {
+ if hot == len(row.doors) {
+ painted = a.pal.cursor(painted, 0)
+ }
+ row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + w}, hint: hint, act: act})
+ }
+ b.WriteString(painted)
+ used += w
+ }
+ team := kind != sideKindPlain
+ tasksAct, trafficAct := sideAct{}, sideAct{}
+ if team {
+ tasksAct = sideAct{kind: sideActView, view: sideTasks}
+ trafficAct = sideAct{kind: sideActView, view: sideTraffic}
+ }
+ word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click")
+ if team {
+ n, fresh := a.sideTrafficCount(t, kind, handle)
+ // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new`
+ // line in the view says where the unread rows end.
+ if view == sideTraffic {
+ fresh = 0
+ }
+ count, isNew := itoa(n), fresh > 0
+ if isNew {
+ count = itoa(fresh)
+ }
+ // A NARROW COLUMN LOSES THE WORD `new` FIRST, and keeps the count.
+ full := ansi.StringWidth(sideWordSep + sideTrafficWord + " " + count + " " + sideNewWord)
+ if isNew && used+full > width {
+ isNew = false
+ }
+ b.WriteString(a.pal.dim(sideWordSep))
+ used += ansi.StringWidth(sideWordSep)
+ hint := "Show the team's traffic"
+ if kind == sideKindMember {
+ hint = "Show the traffic with @" + handle
+ }
+ if fresh > 0 {
+ hint += hintSegment + itoa(fresh) + " new"
+ }
+ word(sideTrafficWord, count, isNew, view == sideTraffic, trafficAct, hint+hintSegment+"click")
+ }
+ // THE WAY OUT IS THE LAST WORD, the key itself, and it is a door.
+ // Spelled the way this keyboard's caps say it: `opt+l` on a Mac.
+ key := a.chords.say(sideHideKey)
+ // It keeps one cell of air from the words, which is what lets `Tasks 14 ·
+ // Traffic 8 alt+l` stand in the twenty-eight columns of a 110 frame.
+ if gap := width - used - ansi.StringWidth(key); gap >= 1 {
+ b.WriteString(strings.Repeat(" ", gap))
+ used += gap
+ hint := "Hide this column" + hintSegment + key
+ if a.railFull() {
+ hint = "Close this column" + hintSegment + key
+ }
+ door := len(row.doors)
+ painted := a.pal.dim(key)
+ if hot == door {
+ painted = a.pal.cursor(a.pal.ink(key), 0)
+ }
+ row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + ansi.StringWidth(key)}, hint: hint, act: sideAct{kind: sideActHide}})
+ b.WriteString(painted)
+ }
+ return fit(b.String(), width), row
+}
+
+// sideHeadKey is the header row's identity for the hover.
+const sideHeadKey = "head"
+
+// sideTrafficCount is the Traffic word's count, and how many of those rows
+// arrived since the person last had the Traffic in front of them: every drawn
+// message of team t, or in a member's chat the ones involving that member.
+func (a *app) sideTrafficCount(t team, kind int, handle string) (int, int) {
+ rows := a.traffic.rows[t.ID]
+ seen := a.traffic.seen[t.ID]
+ n, fresh := 0, 0
+ for i := range rows {
+ e := &rows[i]
+ if !trafficShown(*e) || e.Wake() {
+ continue
+ }
+ if kind == sideKindMember && !sideInvolves(*e, handle) {
+ continue
+ }
+ n++
+ if e.ID > seen {
+ fresh++
+ }
+ }
+ return n, fresh
+}
+
+// sideInvolves reports whether an entry is to or from a member's handle, or
+// to the whole team.
+func sideInvolves(e teamstore.Entry, handle string) bool {
+ if e.From == handle || e.To == teamstore.ToEveryone {
+ return true
+ }
+ if e.To == handle {
+ return true
+ }
+ if e.To == teamstore.ToSeveral {
+ for _, h := range e.Handles {
+ if h == handle {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// ── THE BAND ────────────────────────────────────────────────────────────────
+
+// sideBandItem is one thing that needs the person now.
+type sideBandItem struct {
+ key string
+ // ask says the item is blocked on the person, which is what the amber is
+ // for; every other item is a failure. mark is its one cell: a task's own
+ // state glyph, and the needs-a-person `?` for a question from the team.
+ ask bool
+ mark string
+ words string
+ age string
+ act sideAct
+ hint string
+}
+
+// sideBandFails reports whether a task is a failure the band carries: work
+// this window watched fail on its own, that the person has not opened since.
+// A stop the person made is not a failure waiting on them, and a failure a
+// reopened conversation replayed is history, not news.
+func (a *app) sideBandFails(node *taskNode) bool {
+ return node != nil && node.state == session.TaskFailed && !node.stopped && !node.restored && !a.side.acked[node.id]
+}
+
+// sideBand is every item that needs the person now, blocked items first:
+// the team's questions waiting on them, packets put to them, tasks whose next
+// step is theirs, then failures nobody has looked at. Newest first in each.
+func (a *app) sideBand() []sideBandItem {
+ var out []sideBandItem
+ t, kind, handle := a.sideTeam()
+ if kind != sideKindPlain {
+ for _, e := range a.sideAsks(t, kind, handle) {
+ // THE BAND READS `from → to` TOO: the member that asked, the
+ // manager's mark, then the question. In this member's own chat
+ // the member is `you`. The whole row stays amber, because it is
+ // the one thing that needs the person.
+ from := a.trafficAddr(e.From)
+ if kind == sideKindMember && e.From == handle {
+ from = "you"
+ }
+ q := strings.TrimSpace(strings.TrimPrefix(strings.Join(strings.Fields(e.Text), " "), "asks:"))
+ words := from + " " + a.linearMark("→", "->") + " " + a.teamManagerMark() + " " + q
+ act := sideAct{kind: sideActJump, entry: e.ID}
+ hint := words
+ if m, ok := t.ByHandle(e.From); ok && m.Key != a.frontTabKey() {
+ act.key = m.Key
+ hint += hintSegment + "click opens @" + m.Handle + " at the question"
+ } else {
+ hint += hintSegment + "click shows the question"
+ }
+ out = append(out, sideBandItem{key: "ask/" + t.ID + "/" + e.ID, ask: true, mark: homeAskGlyph, words: words, age: a.trafficAge(e), act: act, hint: hint})
+ }
+ if kind == sideKindManager {
+ for _, p := range a.tp.packets {
+ if !p.Waiting() || p.Team != teamstore.Person || p.Origin != t.ID {
+ continue
+ }
+ words := "decide: " + strings.Join(strings.Fields(p.Question), " ")
+ out = append(out, sideBandItem{key: "packet/" + p.ID, ask: true, mark: homeAskGlyph, words: words,
+ act: sideAct{kind: sideActTeams}, hint: words + hintSegment + "click opens the teams page to decide"})
+ }
+ }
+ }
+ members := a.railMembers()
+ for _, node := range members[railAttention] {
+ status := a.taskStatus(node)
+ words := node.title
+ if word := status.RowWord(); word != "" {
+ words += railSep + word
+ }
+ // A RUN HELD AT ITS GATE WEARS THE GATE'S MARK here as in the list
+ // ([app.railTreeGlyph]).
+ mark := a.taskStateMark(node)
+ if node.Paused() {
+ mark = ansi.Strip(a.stripPausedGlyph())
+ }
+ out = append(out, sideBandItem{key: "task/" + itoa(int(node.id)), ask: true, mark: mark, words: words,
+ act: sideAct{kind: sideActOpen, id: node.id}})
+ }
+ for _, node := range members[railDone] {
+ if !a.sideBandFails(node) {
+ continue
+ }
+ words := node.title
+ if word := a.taskStatus(node).Word; word != "" {
+ words += " " + word
+ }
+ out = append(out, sideBandItem{key: "fail/" + itoa(int(node.id)), mark: a.taskStateMark(node), words: words,
+ act: sideAct{kind: sideActOpen, id: node.id}})
+ }
+ return out
+}
+
+// sideTaskOf is the task a band row's key names, 0 for a row that is not a
+// task's.
+func sideTaskOf(key string) uint64 {
+ for _, lead := range []string{"task/", "fail/"} {
+ if rest, ok := strings.CutPrefix(key, lead); ok {
+ id := uint64(0)
+ for _, c := range rest {
+ if c < '0' || c > '9' {
+ return 0
+ }
+ id = id*10 + uint64(c-'0')
+ }
+ return id
+ }
+ }
+ return 0
+}
+
+// sideBandRows lays the band width wide: at most [sideBandCap] items and a
+// `+N more` row, or every item when the person asked for them, and a rule
+// under them. Nothing at all when nothing needs the person.
+// fitClauses is words cut to width with the ellipsis where they run out, and
+// A CUT THAT LEAVES A CLAUSE ITS SEPARATOR AND NOTHING ELSE ends at the clause
+// before it instead: `Ship the price table…`, never `Ship the price table · …`,
+// which spends three cells on saying there was more.
+func fitClauses(words string, width int, tail string) (string, int) {
+ if ansi.StringWidth(words) <= width {
+ return words, ansi.StringWidth(words)
+ }
+ cut := ansi.Truncate(words, max(width, 0), tail)
+ body := strings.TrimRight(strings.TrimSuffix(cut, tail), " ·")
+ if at := strings.LastIndex(body, " · "); at > 0 && ansi.StringWidth(body[at+len(" · "):]) < 4 {
+ body = strings.TrimRight(body[:at], " ·")
+ }
+ if body == "" {
+ return cut, ansi.StringWidth(cut)
+ }
+ cut = body + tail
+ return cut, ansi.StringWidth(cut)
+}
+
+func (a *app) sideBandRows(width int) []railLine {
+ items := a.sideBand()
+ if len(items) == 0 || width < 8 {
+ return nil
+ }
+ shown := items
+ more := 0
+ if !a.side.bandAll && len(items) > sideBandCap+1 {
+ shown, more = items[:sideBandCap], len(items)-sideBandCap
+ }
+ out := make([]railLine, 0, len(shown)+2)
+ for _, item := range shown {
+ row := &sideRow{key: item.key, hint: item.hint, act: item.act}
+ // THE MARK IS THE ITEM'S OWN, and amber only for what is blocked on
+ // the person: a failure is its ✗ in ordinary ink.
+ mark, paint := item.mark, a.pal.ask
+ lead := a.pal.askBold(mark)
+ if !item.ask {
+ paint = a.pal.ink
+ lead = a.pal.ink(mark)
+ }
+ room := width - ansi.StringWidth(mark) - 1
+ // THE AGE IS ON THE HINT LINE AT EVERY WIDTH, as the Traffic's rows
+ // do: the arrow and the names need the cells the clock used to take.
+ if item.age != "" {
+ row.hint = sideHintWith(row.hint, item.age)
+ }
+ // A TRAFFIC ASK KEEPS `from → to` AND CUTS THE QUESTION AT A WORD. A task
+ // row has no arrow, and still cuts at a clause.
+ words := item.words
+ tail := a.linearMark("…", "~")
+ if at := strings.Index(item.words, " "); at > 0 && (strings.Contains(item.words[:at], "→") || strings.Contains(item.words[:at], "->")) {
+ prefix := item.words[:at+2]
+ words = prefix + fitAtWord(item.words[at+2:], max(room-ansi.StringWidth(prefix), 0), tail)
+ words = strings.TrimRight(words, " ")
+ } else {
+ words, _ = fitClauses(item.words, room, tail)
+ }
+ w := ansi.StringWidth(words)
+ line := lead + " " + paint(words) + strings.Repeat(" ", max(room-w, 0))
+ out = append(out, railLine{text: line, entry: -1, side: row})
+ }
+ if more > 0 || (a.side.bandAll && len(items) > sideBandCap+1) {
+ word := "+" + itoa(more) + " more"
+ hint := "Show all " + itoa(len(items)) + hintSegment + "click"
+ if more == 0 {
+ word, hint = "fewer", "Show three"+hintSegment+"click"
+ }
+ out = append(out, railLine{text: a.pal.dim(fit(word, width)), entry: -1,
+ side: &sideRow{key: "band/more", hint: hint, act: sideAct{kind: sideActBand}}})
+ }
+ out = append(out, railLine{text: a.pal.dim(strings.Repeat(a.linearMark("─", "-"), width)), entry: -1})
+ return out
+}
+
+// ── THE ASKS IN THE TRAFFIC ─────────────────────────────────────────────────
+
+// sideAskCache is the Traffic's questions waiting on the person, as last read.
+type sideAskCache struct {
+ team, handle, last string
+ rows, kind int
+ asks []teamstore.Entry
+}
+
+// sideAsks is every member of team t that asked the person something and has
+// not been answered, newest first: its latest asking event, until anything
+// later from it, or from the person to it, says it moved on. In a member's
+// chat, that member's only. Read from memory and kept until the log moves.
+func (a *app) sideAsks(t team, kind int, handle string) []teamstore.Entry {
+ rows := a.traffic.rows[t.ID]
+ last := ""
+ if len(rows) > 0 {
+ last = rows[len(rows)-1].ID
+ }
+ c := &a.side.asks
+ if c.team == t.ID && c.handle == handle && c.last == last && c.rows == len(rows) && c.kind == kind {
+ return c.asks
+ }
+ var pending map[string]int
+ for i := range rows {
+ e := &rows[i]
+ switch {
+ case e.Kind == teamstore.KindEvent && trafficAsking(*e) && e.From != teamstore.FromManager && e.From != teamstore.FromSystem:
+ if pending == nil {
+ pending = map[string]int{}
+ }
+ pending[e.From] = i
+ case e.Kind == teamstore.KindYou || e.From == teamstore.FromYou || teamstore.IsRuling(*e):
+ for _, h := range e.Recipients() {
+ delete(pending, h)
+ }
+ if e.To == teamstore.ToEveryone {
+ pending = nil
+ }
+ case e.Wake():
+ // A member woken again on its question is running once more.
+ delete(pending, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(e.Text), "woke "), "@"))
+ default:
+ delete(pending, e.From)
+ }
+ }
+ var asks []teamstore.Entry
+ for i := len(rows) - 1; i >= 0; i-- {
+ e := rows[i]
+ if at, ok := pending[e.From]; !ok || at != i {
+ continue
+ }
+ if kind == sideKindMember && e.From != handle {
+ continue
+ }
+ asks = append(asks, e)
+ }
+ *c = sideAskCache{team: t.ID, handle: handle, last: last, rows: len(rows), kind: kind, asks: asks}
+ return asks
+}
+
+// sideAsked reports whether entry id is one of the band's questions.
+func (a *app) sideAsked(t team, kind int, handle, id string) bool {
+ for _, e := range a.sideAsks(t, kind, handle) {
+ if e.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+// ── THE HEAD OF THE COLUMN, AS THE ROSTER LAYS IT ───────────────────────────
+
+// sideHead is the column's pinned rows: the header, and the band under it.
+// They stand at the top of every view and scroll with nothing.
+func (a *app) sideHead(width int) []railLine {
+ text, row := a.sideHeadRow(width)
+ out := []railLine{{text: text, entry: -1, side: row}}
+ return append(out, a.sideBandRows(width)...)
+}
+
+// sideRemember keeps the rows a layout drew, for the hint line.
+func (a *app) sideRemember(lines []railLine) {
+ a.side.last = a.side.last[:0]
+ for _, l := range lines {
+ if l.side != nil {
+ a.side.last = append(a.side.last, l.side)
+ }
+ }
+}
+
+// sideRowOf is the last drawn row with identity key, nil for none.
+func (a *app) sideRowOf(key string) *sideRow {
+ for _, r := range a.side.last {
+ if r.key == key {
+ return r
+ }
+ }
+ return nil
+}
+
+// ── UNDER THE HAND ──────────────────────────────────────────────────────────
+
+// sideLineCol is the column's own column under screen column x.
+func (a *app) sideLineCol(x int) int {
+ return x - a.railLeft() - ansi.StringWidth(railSeam)
+}
+
+// sideHoverAt is the hover the column's own rows answer with: a door of the
+// header, a row of the band or of the Traffic, or a door on one. A row with
+// nothing to press answers with nothing, so it does not light.
+func (a *app) sideHoverAt(x, y int) (hoverAt, bool) {
+ if !a.railAt(x, y) || a.railSeamAt(x, y) {
+ return hoverAt{}, false
+ }
+ line, ok := a.railLineAt(y)
+ if !ok {
+ return hoverAt{}, false
+ }
+ if line.side == nil {
+ if e, ok := a.railEntryAt(y); ok && e.head && e.group != railRunning {
+ return hoverAt{kind: hoverRailGroup, index: int(e.group)}, true
+ }
+ return hoverAt{}, false
+ }
+ if door := line.side.doorAt(a.sideLineCol(x)); door >= 0 {
+ return hoverAt{kind: hoverSide, key: line.side.key, index: door}, true
+ }
+ if line.side.pressable() {
+ return hoverAt{kind: hoverSide, key: line.side.key, index: -1}, true
+ }
+ return hoverAt{}, false
+}
+
+// sideHovering reports whether the pointer is on row key as a whole row.
+func (a *app) sideHovering(key string) bool {
+ return key != "" && a.hot.kind == hoverSide && a.hot.key == key && a.hot.index < 0
+}
+
+// sidePress answers a press on one of the column's own rows at the column's
+// column x.
+func (a *app) sidePress(row *sideRow, x int) (tea.Cmd, bool) {
+ a.railWhere = railSpot{key: row.key}
+ if door := row.doorAt(x); door >= 0 {
+ return a.sideDo(row.doors[door].act), true
+ }
+ return a.sideDo(row.act), true
+}
+
+// sideDo does one act. Nothing here takes the keyboard.
+func (a *app) sideDo(act sideAct) tea.Cmd {
+ switch act.kind {
+ case sideActOpen:
+ node := a.tasks[act.id]
+ if node == nil {
+ return nil
+ }
+ a.sideAck(node)
+ return a.openRailRoom(node)
+ case sideActJump:
+ a.railTake(false)
+ return a.trafficJump(act.key, act.entry)
+ case sideActThread:
+ a.sideToggleThread(act.key)
+ case sideActBand:
+ a.side.bandAll = !a.side.bandAll
+ a.dropHover()
+ a.touch()
+ case sideActView:
+ a.sideSetView(act.view)
+ case sideActHide:
+ a.sideToggle()
+ case sideActTeams:
+ a.railTake(false)
+ return a.showPage(pageTeams)
+ }
+ return nil
+}
+
+// sideAck records that the person opened a failed task, which takes it out
+// of the band.
+func (a *app) sideAck(node *taskNode) {
+ if node == nil || node.state != session.TaskFailed {
+ return
+ }
+ if a.side.acked == nil {
+ a.side.acked = map[uint64]bool{}
+ }
+ a.side.acked[node.id] = true
+}
+
+// sideGroupShut reports whether a task group is folded to its heading. The
+// running work is always open; the rest open when the person opens them, and
+// stay that way for the session.
+func (a *app) sideGroupShut(g railGroup) bool {
+ return g != railRunning && !a.side.open[g]
+}
+
+// sideToggleGroup opens a folded group or folds it again.
+func (a *app) sideToggleGroup(g railGroup) {
+ if g == railRunning || g >= railGroupCount {
+ return
+ }
+ a.side.open[g] = !a.side.open[g]
+ a.touch()
+}
+
+// sideToggleThread lays a work thread's replies open under it, or folds them.
+func (a *app) sideToggleThread(key string) {
+ if a.side.threads == nil {
+ a.side.threads = map[string]bool{}
+ }
+ if a.side.threads[key] {
+ delete(a.side.threads, key)
+ } else {
+ a.side.threads[key] = true
+ }
+ a.side.moved++
+ a.touch()
+}
+
+// sideRowAct is what enter does on the column's row key: a work thread's
+// replies are laid open or folded, which is the one thing a keyboard cannot
+// otherwise reach on it, and every other row does what a press on it does.
+func (a *app) sideRowAct(key string) tea.Cmd {
+ row := a.sideRowOf(key)
+ if row == nil {
+ return nil
+ }
+ for _, d := range row.doors {
+ if d.act.kind == sideActThread {
+ return a.sideDo(d.act)
+ }
+ }
+ return a.sideDo(row.act)
+}
+
+// sideSpots is every one of the column's own rows the keyboard walks, in the
+// order they are drawn: the band, then the Traffic's rows while it is in
+// front.
+func (a *app) sideSpots() []railSpot {
+ var out []railSpot
+ for _, l := range a.sideHead(a.railRoom()) {
+ if l.side != nil && l.side.pressable() {
+ out = append(out, railSpot{key: l.side.key})
+ }
+ }
+ if a.sideView() == sideTraffic {
+ lines, _ := a.sideTrafficView(a.viewHeight())
+ for _, l := range lines {
+ if l.side != nil && l.side.pressable() {
+ out = append(out, railSpot{key: l.side.key})
+ }
+ }
+ }
+ return out
+}
+
+// sideHoverWords is what the hint line says with the pointer on the column,
+// "" anywhere else.
+func (a *app) sideHoverWords() string {
+ switch a.hot.kind {
+ case hoverSide:
+ if a.hot.key == sideHeadKey {
+ width := a.railRoom()
+ _, row := a.sideHeadRow(width)
+ if i := a.hot.index; i >= 0 && i < len(row.doors) {
+ return row.doors[i].hint
+ }
+ return ""
+ }
+ row := a.sideRowOf(a.hot.key)
+ if row == nil {
+ return ""
+ }
+ if i := a.hot.index; i >= 0 && i < len(row.doors) {
+ return row.doors[i].hint
+ }
+ // A TASK'S ROW IN THE BAND SAYS WHAT ITS ROW IN THE LIST SAYS, read
+ // when the pointer is on it and not for every frame.
+ if row.hint == "" && row.act.kind == sideActOpen {
+ return a.sideTaskHint(a.tasks[row.act.id])
+ }
+ return row.hint
+ case hoverRailGroup:
+ g := railGroup(a.hot.index)
+ if g >= railGroupCount {
+ return ""
+ }
+ if a.sideGroupShut(g) {
+ return "Show what is " + railGroupWords[g] + hintSegment + "click"
+ }
+ return "Fold what is " + railGroupWords[g] + hintSegment + "click"
+ case hoverRail:
+ return a.sideTaskHint(a.tasks[a.hot.id])
+ case hoverRailGrip:
+ words := "Show this column" + hintSegment + a.chords.say(sideHideKey)
+ if t, kind, handle := a.sideTeam(); kind != sideKindPlain {
+ if _, fresh := a.sideTrafficCount(t, kind, handle); fresh > 0 {
+ words += hintSegment + itoa(fresh) + " new in the traffic"
+ }
+ }
+ return words
+ }
+ return ""
+}
+
+// sideTaskHint is the hint line over a task's row, in the list or in the
+// band: its whole title, then what the row had no room for (what it is doing,
+// what it spent), then what a press does.
+func (a *app) sideTaskHint(node *taskNode) string {
+ if node == nil {
+ return ""
+ }
+ words := strings.TrimSpace(node.label)
+ if words == "" {
+ words = node.title
+ }
+ for _, under := range a.railUnder(node, 400) {
+ if s := strings.TrimSpace(ansi.Strip(under)); s != "" {
+ words += hintSegment + s
+ }
+ }
+ return words + hintSegment + "click opens it"
+}
+
+// sideBackHint is what the legend's hint slot says while the column is away:
+// the key, and what it brings back.
+func (a *app) sideBackHint() string {
+ if a.sideKind() == sideKindManager {
+ return a.chords.say(sideHideKey) + " traffic"
+ }
+ return a.chords.say(sideHideKey) + " tasks"
+}
diff --git a/internal/tui3/sidecol_test.go b/internal/tui3/sidecol_test.go
new file mode 100644
index 0000000000..06880c384c
--- /dev/null
+++ b/internal/tui3/sidecol_test.go
@@ -0,0 +1,310 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// sideDoorOf is the screen cell of the first door on the column's own rows
+// that does act kind, failing the test when the column draws none.
+func sideDoorOf(t *testing.T, a *app, kind int) (int, int) {
+ t.Helper()
+ view, _ := a.railDrawnView(a.viewHeight())
+ for i, line := range view {
+ if line.side == nil {
+ continue
+ }
+ for _, d := range line.side.doors {
+ if d.act.kind == kind {
+ return a.railLeft() + ansi.StringWidth(railSeam) + d.span.from, a.topHeight() + i
+ }
+ }
+ }
+ t.Fatalf("the column draws no door that does act %d", kind)
+ return 0, 0
+}
+
+// sideRowOn is the screen cell of row key on the column, at a column of it no
+// door covers, failing the test when the column does not draw it.
+func sideRowOn(t *testing.T, a *app, key string) (int, int) {
+ t.Helper()
+ view, _ := a.railDrawnView(a.viewHeight())
+ for i, line := range view {
+ if line.side == nil || line.side.key != key {
+ continue
+ }
+ for x := 0; x < a.railRoom(); x++ {
+ if line.side.doorAt(x) < 0 {
+ return a.railLeft() + ansi.StringWidth(railSeam) + x, a.topHeight() + i
+ }
+ }
+ }
+ t.Fatalf("the column does not draw row %q", key)
+ return 0, 0
+}
+
+// sideBandFixture is a plain chat with two tasks waiting on the person, three
+// failures nobody has opened, one running task and one landed one.
+func sideBandFixture(t *testing.T) *app {
+ t.Helper()
+ a, _, _ := roomApp(t)
+ for _, id := range []uint64{1, 2} {
+ a.taskUpdate(update(id, "Port the parser "+itoa(int(id)), session.TaskUnverified,
+ session.TaskNotice{Merge: mergeWordAborted, Branch: "task/p" + itoa(int(id))}))
+ }
+ // Each failure is seen running first, as live work is: a node whose first
+ // news is a failure is one a reopened conversation replayed.
+ for _, id := range []uint64{3, 4, 5} {
+ a.taskUpdate(update(id, "Cut the goldens "+itoa(int(id)), session.TaskRunning, session.TaskNotice{}))
+ a.taskUpdate(update(id, "Cut the goldens "+itoa(int(id)), session.TaskFailed,
+ session.TaskNotice{Ending: session.TaskEndingSteps}))
+ }
+ a.taskUpdate(update(6, "Fix the loader", session.TaskRunning, session.TaskNotice{}))
+ a.taskUpdate(update(7, "Read the law", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged}))
+ a.height = 30
+ return a
+}
+
+// sideDrawn is the column's own rows as the frame lays them: the key of each
+// line that is one of them, "" for every other line, and the raw text.
+func sideDrawn(a *app) ([]string, []string) {
+ view, _ := a.railDrawnView(a.viewHeight())
+ raw := a.railRows(a.viewHeight())
+ keys := make([]string, len(view))
+ for i, line := range view {
+ if line.side != nil {
+ keys[i] = line.side.key
+ }
+ }
+ return keys, raw
+}
+
+// THE BAND IS WHAT NEEDS THE PERSON, AMBER ONLY FOR WHAT IS BLOCKED ON THEM.
+// Two tasks whose next step is the person's lead in amber, newest first; the
+// failures follow with a ✗ in ordinary ink; three rows and then `+N more`;
+// and nothing in the band is drawn again in the list under it.
+func TestTheBandCarriesAsksInAmberAndFailuresInInk(t *testing.T) {
+ a := sideBandFixture(t)
+ keys, raw := sideDrawn(a)
+ var band []int
+ for i, k := range keys {
+ if k != "" && k != sideHeadKey {
+ band = append(band, i)
+ }
+ }
+ want := []string{"task/2", "task/1", "fail/5", "band/more"}
+ if len(band) != len(want) {
+ t.Fatalf("the band drew %v, want %v:\n%s", keys, want, strings.Join(railText(a, a.viewHeight()), "\n"))
+ }
+ for i, at := range band {
+ if keys[at] != want[i] {
+ t.Fatalf("band row %d is %q, want %q (all %v)", i, keys[at], want[i], keys)
+ }
+ }
+ if more := plain(raw[band[3]]); !strings.Contains(more, "+2 more") {
+ t.Fatalf("the band's last row is %q, want +2 more", more)
+ }
+ // THE PAINT. The palette's warn ink is on the asks and on nothing else.
+ warn := a.pal.ask("Q")
+ warn = warn[:strings.Index(warn, "Q")]
+ if warn == "" {
+ t.Fatal("the test palette paints no warn ink, so this test would prove nothing")
+ }
+ for i, at := range band[:3] {
+ amber := strings.Contains(raw[at], warn)
+ if ask := i < 2; amber != ask {
+ t.Fatalf("band row %q amber=%v, want %v:\n%q", keys[at], amber, ask, raw[at])
+ }
+ }
+ if !strings.Contains(plain(raw[band[2]]), a.taskStateMark(a.tasks[5])+" Cut the goldens 5") {
+ t.Fatalf("the failure row is %q", plain(raw[band[2]]))
+ }
+ // A RULE CLOSES THE BAND, and the list starts under it.
+ if rule := plain(raw[band[3]+1]); !strings.Contains(rule, "────") {
+ t.Fatalf("no rule under the band: %q", rule)
+ }
+ // NOT REPEATED BELOW.
+ railOpenAll(a)
+ for _, e := range a.railEntries() {
+ if e.node != nil && e.node.id <= 5 {
+ t.Fatalf("band item %d is drawn again in the list", e.node.id)
+ }
+ }
+ // `+2 more` SHOWS THEM ALL, and `fewer` folds them back.
+ x, y := sideRowOn(t, a, "band/more")
+ railClick(t, a, x, y)
+ if n := len(a.sideBand()); n != 5 {
+ t.Fatalf("the band holds %d items", n)
+ }
+ sideRowOn(t, a, "fail/3")
+ if more := plain(strings.Join(railText(a, a.viewHeight()), "\n")); !strings.Contains(more, "fewer") {
+ t.Fatalf("the opened band offers no way back:\n%s", more)
+ }
+ x, y = sideRowOn(t, a, "band/more")
+ railClick(t, a, x, y)
+ if a.side.bandAll {
+ t.Fatal("`fewer` did not fold the band back")
+ }
+ // OPENING A FAILURE IS LOOKING AT IT: it leaves the band and joins the
+ // finished work.
+ x, y = sideRowOn(t, a, "fail/5")
+ railClick(t, a, x, y)
+ if !a.roomOpen() || a.room.id != 5 {
+ t.Fatalf("a press on the failure opened %d", roomID(a))
+ }
+ for _, item := range a.sideBand() {
+ if item.key == "fail/5" {
+ t.Fatal("the failure the person opened is still in the band")
+ }
+ }
+ listed := false
+ for _, e := range a.railEntries() {
+ listed = listed || (e.node != nil && e.node.id == 5)
+ }
+ if !listed {
+ t.Fatal("the failure the person opened is not in the list")
+ }
+}
+
+// FOUR ITEMS ARE FOUR ROWS. `+1 more` would spend the row it saves, so the
+// band only counts what it cannot draw from the fifth item on.
+func TestTheBandDrawsFourItemsRatherThanCountOne(t *testing.T) {
+ a := sideBandFixture(t)
+ a.side.acked = map[uint64]bool{3: true}
+ keys, _ := sideDrawn(a)
+ got := strings.Join(keys, " ")
+ for _, want := range []string{"task/2", "task/1", "fail/5", "fail/4"} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("the band lost %q: %v", want, keys)
+ }
+ }
+ if strings.Contains(got, "band/more") {
+ t.Fatalf("four items were cut to three and a count: %v", keys)
+ }
+}
+
+// NOTHING NEEDING THE PERSON DRAWS NOTHING: no band, no rule, no `none`. And
+// a plain terminal gets the linear marks.
+func TestAnEmptyBandIsGoneAndAPlainTerminalReadsItsMarks(t *testing.T) {
+ a, _, _ := taskApp(t)
+ a.taskUpdate(update(1, "Fix the loader", session.TaskRunning, session.TaskNotice{}))
+ rows := railText(a, 10)
+ if strings.TrimRight(rows[1], " ") != "│ Running 1" {
+ t.Fatalf("an empty band left something between the header and the list:\n%s", strings.Join(rows, "\n"))
+ }
+ for _, row := range rows {
+ for _, never := range []string{"none", "Needs you", "────"} {
+ if strings.Contains(row, never) {
+ t.Fatalf("an idle column drew %q:\n%s", never, strings.Join(rows, "\n"))
+ }
+ }
+ }
+ b := sideBandFixture(t)
+ b.linear = true
+ text := strings.Join(railText(b, b.viewHeight()), "\n")
+ for _, want := range []string{b.taskStateMark(b.tasks[2]) + " Port the parser 2", b.taskStateMark(b.tasks[5]) + " Cut the goldens 5", "----"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the plain terminal's band is missing %q:\n%s", want, text)
+ }
+ }
+ if strings.Contains(text, "✗") || strings.Contains(text, "─") {
+ t.Fatalf("the plain terminal drew a glyph it cannot be trusted with:\n%s", text)
+ }
+}
+
+// NOTHING IN THE COLUMN MOVES THE CONVERSATION OR THE HEADER. A band item
+// arriving, a group folding, a row arriving, and in a team chat the other
+// word coming to the front: the body keeps its width and the header its row.
+func TestNothingInTheColumnMovesTheBodyOrTheHeader(t *testing.T) {
+ a, _, _ := taskApp(t)
+ railRun(a)
+ body := a.bodyWidth()
+ check := func(what string) {
+ t.Helper()
+ if a.bodyWidth() != body {
+ t.Fatalf("%s moved the conversation from %d to %d columns", what, body, a.bodyWidth())
+ }
+ view, _ := a.railDrawnView(a.viewHeight())
+ if len(view) == 0 || view[0].side == nil || view[0].side.key != sideHeadKey {
+ t.Fatalf("%s moved the header off the column's first row", what)
+ }
+ }
+ a.taskUpdate(update(8, "Cut the trailer", session.TaskRunning, session.TaskNotice{}))
+ a.taskUpdate(update(8, "Cut the trailer", session.TaskFailed, session.TaskNotice{Ending: session.TaskEndingSteps}))
+ check("a failure arriving in the band")
+ a.sideToggleGroup(railDone)
+ check("opening a group")
+ a.sideToggleGroup(railDone)
+ check("folding a group")
+ a.taskUpdate(update(9, "Port the loader", session.TaskRunning, session.TaskNotice{}))
+ check("a new row")
+
+ m, _, _, _ := trafficApp(t)
+ m.width, m.height = 160, 40
+ if m.sideKind() != sideKindManager {
+ t.Fatalf("the fixture's front chat is kind %d", m.sideKind())
+ }
+ width := m.bodyWidth()
+ if m.railWidth() != sideColsFor(m.width) {
+ t.Fatalf("the manager's column is %d wide, want %d", m.railWidth(), sideColsFor(m.width))
+ }
+ for _, view := range []int{sideTasks, sideTraffic, sideTasks} {
+ m.sideSetView(view)
+ if m.bodyWidth() != width || m.sideView() != view {
+ t.Fatalf("switching to view %d moved the conversation from %d to %d", view, width, m.bodyWidth())
+ }
+ if v, _ := m.railDrawnView(m.viewHeight()); len(v) == 0 || v[0].side == nil || v[0].side.key != sideHeadKey {
+ t.Fatalf("switching to view %d moved the header", view)
+ }
+ }
+}
+
+// THE HOVER GROUND IS THE CLICK TARGET. Every cell of a band row lights that
+// row and a press on any of them opens its task; on the header, each word and
+// the key light alone, and the air between them lights nothing and does
+// nothing.
+func TestTheHoverGroundIsTheClickTarget(t *testing.T) {
+ a := sideBandFixture(t)
+ _, y := sideRowOn(t, a, "task/2")
+ left := a.railLeft() + ansi.StringWidth(railSeam)
+ for col := 0; col < a.railRoom(); col++ {
+ a.setHover(left+col, y)
+ if a.hot.kind != hoverSide || a.hot.key != "task/2" || a.hot.index != -1 {
+ t.Fatalf("cell %d of the band row lit %+v", col, a.hot)
+ }
+ }
+ lit := a.railRows(a.viewHeight())[y-a.topHeight()]
+ if w := ansi.StringWidth(lit); w != ansi.StringWidth(railSeam)+a.railRoom() {
+ t.Fatalf("the hover ground is %d cells, want the whole row", w)
+ }
+ for _, col := range []int{0, a.railRoom() / 2, a.railRoom() - 1} {
+ a.closeRoom()
+ railClick(t, a, left+col, y)
+ if !a.roomOpen() || a.room.id != 2 {
+ t.Fatalf("a press on cell %d of the band row opened %d", col, roomID(a))
+ }
+ }
+ a.closeRoom()
+
+ // THE HEADER, cell by cell, against its own doors.
+ _, row := a.sideHeadRow(a.railRoom())
+ hy := a.topHeight()
+ for col := 0; col < a.railRoom(); col++ {
+ a.setHover(left+col, hy)
+ door := row.doorAt(col)
+ switch {
+ case door >= 0 && (a.hot.kind != hoverSide || a.hot.key != sideHeadKey || a.hot.index != door):
+ t.Fatalf("header cell %d is door %d and lit %+v", col, door, a.hot)
+ case door < 0 && a.hot.kind == hoverSide:
+ t.Fatalf("header cell %d is no door and lit %+v", col, a.hot)
+ }
+ }
+ // A plain chat's one word is not a door: there is nothing to switch to.
+ if len(row.doors) != 1 || row.doors[0].act.kind != sideActHide {
+ t.Fatalf("a plain chat's header has doors %+v", row.doors)
+ }
+}
diff --git a/internal/tui3/sidetraffic.go b/internal/tui3/sidetraffic.go
new file mode 100644
index 0000000000..99211b92b8
--- /dev/null
+++ b/internal/tui3/sidetraffic.go
@@ -0,0 +1,712 @@
+package tui3
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+ "github.com/Agent-Field/codeaf/internal/tui2/tokens"
+)
+
+// ── THE TRAFFIC VIEW (sidecol.go says what the column is) ──────────────────
+//
+// One line a row, newest first, and a thin `new` line under what arrived
+// since the person last looked. EVERY ROW READS `from → to words`: who sent
+// it, who it is for, then what it says. The manager is `◆`. Several
+// recipients are the first handle and `+N`. In a member's chat that member
+// is `you`. The age is never on the row. The hint line says it, at every
+// width, so the arrow and the names keep their cells and only the words are
+// cut, at a word.
+//
+// ◆ → @security +2 parser numbers running ▸
+// ↳ @review → ◆ ✓ 2 findings, both minor
+// General 5 msgs ▸
+//
+// A press on ▸ lays the thread's replies open under it, one line each in the
+// same `from → to`, and a second press folds them. A press on a handle opens
+// that member at the message, and a press anywhere else on a row takes the
+// person to the message in the conversation in front (teamjump.go). Whatever
+// answers nothing and is answered by nothing (a note, a start, a stop, an
+// event) is chatter, and all of it is the one `General` thread. Laid open,
+// its lines read `from → to` too.
+//
+// IN A MEMBER'S CHAT A ROW IS A MESSAGE, one of those involving that member,
+// `◆ → you` for what it was told and `you → ◆` for what it said, and a press
+// on it goes to it in the chat in front.
+//
+// WHAT THE BAND CARRIES IS NOT DRAWN AGAIN HERE: a member's question waiting
+// on the person is the band's row, `? @model → ◆ …`, and its own row would
+// say it twice.
+
+// sideTrafficCacheKey is everything the Traffic view's rows are drawn from.
+type sideTrafficCacheKey struct {
+ team, handle, last, divider, hot, focus, front string
+ kind, rows, width, height, hotDoor, moved int
+ ascii, held bool
+ minute int64
+}
+
+// sideTrafficCache is the Traffic view's rows as last laid.
+type sideTrafficCache struct {
+ key sideTrafficCacheKey
+ lines []railLine
+}
+
+// sideThreadKey is a work thread's name in the column's record of what is
+// laid open.
+func sideThreadKey(teamID, root string) string { return teamID + "/" + root }
+
+// sideGeneral is the root the chatter thread is keyed by.
+const sideGeneral = "general"
+
+// sideTrafficView is the Traffic view's rows, at most height, width the
+// column's own. It snapshots where the `new` line stands the first time the
+// Traffic comes into view, and records that the person has the newest entry
+// in front of them. Frame-safe: memory only.
+func (a *app) sideTrafficView(height int) ([]railLine, int) {
+ t, kind, handle := a.sideTeam()
+ if kind == sideKindPlain || height <= 0 {
+ return nil, -1
+ }
+ if a.side.up != t.ID {
+ if a.side.divider == nil {
+ a.side.divider = map[string]string{}
+ }
+ a.side.divider[t.ID] = a.traffic.seen[t.ID]
+ a.side.up = t.ID
+ }
+ width := a.railRoom()
+ rows := a.traffic.rows[t.ID]
+ last := ""
+ if len(rows) > 0 {
+ last = rows[len(rows)-1].ID
+ }
+ hot, hotDoor := "", -1
+ if a.hot.kind == hoverSide {
+ hot, hotDoor = a.hot.key, a.hot.index
+ }
+ focus := ""
+ if a.railHold {
+ focus = a.railWhere.key
+ }
+ key := sideTrafficCacheKey{
+ 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,
+ }
+ 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}
+ if kind == sideKindManager {
+ s.threads(kind, handle)
+ } else {
+ s.messages(handle)
+ }
+ lines := s.lines
+ if len(lines) > height {
+ lines = lines[:height]
+ }
+ a.side.traffic = sideTrafficCache{key: key, lines: lines}
+ return lines, -1
+}
+
+// sideSheet lays the Traffic view's rows one at a time.
+type sideSheet struct {
+ a *app
+ t team
+ width int
+ hot string
+ hotDoor int
+ divider string
+ lines []railLine
+ // fresh says the rows laid so far are newer than the divider, and ruled
+ // that the `new` line is down.
+ fresh, ruled bool
+}
+
+// seg is one piece of a row being laid: its words, how they are painted, and
+// the door they are (-1 for none). flex is the row's words, the one piece a
+// narrow column cuts, and it is cut at a word so the arrow and the names
+// stay whole.
+type sideSeg struct {
+ text string
+ paint func(string) string
+ door int
+ flex bool
+}
+
+// newer draws the `new` line once, above the first row that is not newer
+// than the divider, when rows above it were.
+func (s *sideSheet) newer(id string) {
+ if s.ruled {
+ return
+ }
+ if id > s.divider && s.divider != "" {
+ s.fresh = true
+ return
+ }
+ if s.fresh {
+ word := " " + sideNewWord + " "
+ rule := s.a.linearMark("─", "-")
+ left := 2
+ right := max(s.width-left-ansi.StringWidth(word), 0)
+ s.lines = append(s.lines, railLine{entry: -1,
+ text: s.a.pal.dim(strings.Repeat(rule, left)) + s.a.pal.muted(word) + s.a.pal.dim(strings.Repeat(rule, right))})
+ }
+ s.ruled = true
+}
+
+// sideHintWith is a row's hint with a fact the row gave up said in it, before
+// the click the hint ends on. The age is always one of those facts: it used
+// to sit at the right of the row, and the arrow had no cells left.
+func sideHintWith(hint, fact string) string {
+ if fact == "" {
+ return hint
+ }
+ if at := strings.LastIndex(hint, hintSegment+"click"); at >= 0 {
+ return hint[:at] + hintSegment + fact + hint[at:]
+ }
+ if hint == "" {
+ return fact
+ }
+ return hint + hintSegment + fact
+}
+
+// fitAtWord cuts words to width at a word boundary, with tail where they do
+// not fit. A first word longer than the room is cut where it lands, because
+// there is no boundary to stop at.
+func fitAtWord(words string, width int, tail string) string {
+ if width <= 0 {
+ return ""
+ }
+ if ansi.StringWidth(words) <= width {
+ return words
+ }
+ tw := ansi.StringWidth(tail)
+ if tw >= width {
+ return ansi.Truncate(tail, width, "")
+ }
+ head := ansi.Truncate(words, width-tw, "")
+ // A CUT THAT LANDS INSIDE A WORD steps back to the space before it. When
+ // the first word itself does not fit, the row says there is more and does
+ // not show half a word.
+ if len(head) < len(words) && (len(head) == 0 || !strings.HasPrefix(words[len(head):], " ")) {
+ if at := strings.LastIndex(head, " "); at > 0 {
+ head = strings.TrimRight(head[:at], " ")
+ } else {
+ return tail
+ }
+ }
+ head = strings.TrimRight(head, " ")
+ if head == "" {
+ return tail
+ }
+ return head + tail
+}
+
+// add lays one row: the segments from the left, the words cut at a word where
+// they run out of room, and nothing for the time. The age goes to the hint
+// line at every width, so the arrow and the names keep their cells. A door
+// keeps the columns its words ended up in.
+func (s *sideSheet) add(row *sideRow, segs []sideSeg, right []sideSeg, age string) {
+ pal := s.a.pal
+ if age != "" {
+ row.hint = sideHintWith(row.hint, age)
+ }
+ room := s.width
+ rightW := 0
+ for _, seg := range right {
+ rightW += ansi.StringWidth(seg.text)
+ }
+ // THE STATE AT THE RIGHT GIVES WAY BEFORE THE WORDS DO, when the column
+ // cannot hold both and a dozen cells of what the row is about (or all of
+ // it, when it is shorter). A door on the right, a thread's ▸, is kept: it
+ // is the one way to the replies.
+ need := 0
+ for _, seg := range segs {
+ need += ansi.StringWidth(seg.text)
+ }
+ need = min(need, 20)
+ for rightW > 0 && room-rightW < need {
+ // The count goes first and the state after it: the last words drawn
+ // are the first given up.
+ drop := -1
+ for i, seg := range right {
+ if seg.door < 0 && strings.TrimSpace(seg.text) != "" {
+ drop = i
+ }
+ }
+ if drop < 0 {
+ break
+ }
+ rightW -= ansi.StringWidth(right[drop].text)
+ right = append(right[:drop:drop], right[drop+1:]...)
+ }
+ left := room - rightW
+ // THE WORDS ARE THE ONLY SEGMENT THAT SHRINKS, and they shrink at a word.
+ // The arrow and the names were laid as fixed segments so a narrow column
+ // cuts `Please provide a st…` and never `Pleas…` through a name.
+ fixed := 0
+ for _, seg := range segs {
+ if !seg.flex {
+ fixed += ansi.StringWidth(seg.text)
+ }
+ }
+ for i := range segs {
+ if !segs[i].flex {
+ continue
+ }
+ segs[i].text = fitAtWord(segs[i].text, max(left-fixed, 0), s.a.linearMark("…", "~"))
+ }
+ var b strings.Builder
+ used := 0
+ spans := make([]hudSpan, len(row.doors))
+ paint := func(seg sideSeg, text string, w int) {
+ painted := text
+ switch {
+ case seg.door >= 0 && row.key == s.hot && seg.door == s.hotDoor:
+ painted = teamLinkHotInk(pal, text)
+ case seg.door >= 0 && row.doors[seg.door].act.kind == sideActJump && row.doors[seg.door].act.key != "":
+ painted = teamLinkInk(pal, text)
+ case seg.paint != nil:
+ painted = seg.paint(text)
+ }
+ if seg.door >= 0 && seg.door < len(spans) {
+ spans[seg.door] = hudSpan{from: used, to: used + w}
+ }
+ b.WriteString(painted)
+ used += w
+ }
+ for _, seg := range segs {
+ if used >= left {
+ break
+ }
+ text := seg.text
+ w := ansi.StringWidth(text)
+ if used+w > left {
+ text = ansi.Truncate(text, left-used, s.a.linearMark("…", "~"))
+ w = ansi.StringWidth(text)
+ }
+ paint(seg, text, w)
+ }
+ if pad := left - used; pad > 0 {
+ b.WriteString(strings.Repeat(" ", pad))
+ used += pad
+ }
+ for _, seg := range right {
+ paint(seg, seg.text, ansi.StringWidth(seg.text))
+ }
+ for i := range row.doors {
+ row.doors[i].span = spans[i]
+ }
+ s.lines = append(s.lines, railLine{text: b.String(), entry: -1, side: row})
+}
+
+// handle is a member's handle as a door that opens it at entry, or plain
+// muted words when the handle is nobody's in the team or is the chat in
+// front.
+func (s *sideSheet) handle(row *sideRow, h, entry string) sideSeg {
+ word := "@" + strings.TrimPrefix(h, "@")
+ if ansi.StringWidth(word) > trafficHandleCap {
+ word = ansi.Truncate(word, trafficHandleCap, s.a.linearMark("…", "~"))
+ }
+ if m, ok := s.t.ByHandle(strings.TrimPrefix(h, "@")); ok && m.Key != s.a.frontTabKey() {
+ hint := strings.Replace(s.a.teamMemberHint(m), " @"+m.Handle, " @"+m.Handle+" at this message", 1)
+ row.doors = append(row.doors, sideDoor{hint: hint, act: sideAct{kind: sideActJump, key: m.Key, entry: entry}})
+ return sideSeg{text: word, door: len(row.doors) - 1}
+ }
+ return sideSeg{text: word, paint: s.a.pal.muted, door: -1}
+}
+
+// arrow is the dim ` → ` between who a row is from and who it is for.
+func (s *sideSheet) arrow() sideSeg {
+ return sideSeg{text: " " + s.a.linearMark("→", "->") + " ", paint: s.a.pal.dim, door: -1}
+}
+
+// party is one end of `from → to`: the manager's mark, this member as `you`
+// when self is that handle, a word for everyone, or a handle that opens the
+// member at entry.
+func (s *sideSheet) party(row *sideRow, who, entry, self string) sideSeg {
+ if self != "" && (who == self || who == "@"+self) {
+ return sideSeg{text: "you", paint: s.a.pal.muted, door: -1}
+ }
+ switch who {
+ case teamstore.FromManager:
+ return sideSeg{text: s.a.teamManagerMark(), paint: s.a.pal.accent, door: -1}
+ case teamstore.FromSystem:
+ return sideSeg{text: "codeaf", paint: s.a.pal.dim, door: -1}
+ case teamstore.FromYou:
+ return sideSeg{text: "you", paint: s.a.pal.muted, door: -1}
+ case teamstore.ToEveryone:
+ return sideSeg{text: "all", paint: s.a.pal.muted, door: -1}
+ case teamstore.ToRoom:
+ return sideSeg{text: "room", paint: s.a.pal.muted, door: -1}
+ case "":
+ return sideSeg{text: "", door: -1}
+ }
+ return s.handle(row, who, entry)
+}
+
+// route is `from → to` for one entry. named is the member handles it went to,
+// and the first of them is drawn with `+N` for the rest. to is the address
+// when named is empty (the manager, everyone, one handle). self is this
+// member's handle in a member's chat, drawn as `you`.
+func (s *sideSheet) route(row *sideRow, from, entry, self string, named []string, to string) []sideSeg {
+ segs := []sideSeg{s.party(row, from, entry, self), s.arrow()}
+ if len(named) > 0 {
+ segs = append(segs, s.party(row, named[0], entry, self))
+ if len(named) > 1 {
+ segs = append(segs, sideSeg{text: " +" + itoa(len(named)-1), paint: s.a.pal.dim, door: -1})
+ }
+ return segs
+ }
+ segs = append(segs, s.party(row, to, entry, self))
+ return segs
+}
+
+// words is an entry's words on one line, what a row says about it.
+func sideWords(e teamstore.Entry) string {
+ text := strings.Join(strings.Fields(e.Text), " ")
+ if teamstore.IsRuling(e) {
+ text = trafficRulingText(text)
+ }
+ if text == "" {
+ text = e.State
+ }
+ return text
+}
+
+// says is what a row says about an entry: its words, and for a start or a
+// stop what was done to whom first, `started @lexer to run orbit · rewrite
+// the lexer`, `stopped @web · stuck in a retry loop`, because a start's words
+// are its brief and a stop's are its reason and neither says the act.
+func (s *sideSheet) says(e teamstore.Entry) string {
+ words := sideWords(e)
+ var act string
+ switch e.Kind {
+ case teamstore.KindStart:
+ act = "started " + s.a.trafficAddr(e.To)
+ // A START THAT NAMES A TEAM made a sub-team for the started
+ // conversation to run (DESIGN.md 8.10).
+ if e.Team != "" {
+ name := e.Team
+ if sub, ok := s.a.teamByID(e.Team); ok {
+ name = sub.Name
+ }
+ act += " to run " + name
+ }
+ case teamstore.KindStop:
+ act = "stopped " + s.a.trafficAddr(e.To)
+ default:
+ return words
+ }
+ if words == "" || words == e.State {
+ return act
+ }
+ return act + " · " + words
+}
+
+// clock is the time an entry was written, as the replies under a thread say
+// it: the hour and minute.
+func (s *sideSheet) clock(e teamstore.Entry) string {
+ if e.At.IsZero() {
+ return ""
+ }
+ return e.At.In(s.a.now().Location()).Format("15:04")
+}
+
+// ── A MANAGER'S WORK ────────────────────────────────────────────────────────
+
+// sideWork is a thread as the manager's Traffic draws it, and chatter the
+// entries that are a thread of their own and nobody's work.
+type sideWork struct {
+ thread teamstore.Thread
+ last teamstore.Entry
+}
+
+// sideChatter reports whether a thread is chatter: one entry, answering
+// nothing and answered by nothing, that is not a message somebody was asked
+// to act on.
+func sideChatter(th teamstore.Thread) bool {
+ if len(th.Replies) > 0 {
+ return false
+ }
+ switch th.Root.Kind {
+ case teamstore.KindDirective, teamstore.KindQuestion:
+ return false
+ }
+ return !teamstore.IsRuling(th.Root)
+}
+
+// sideLast is the newest entry of a thread.
+func sideLast(th teamstore.Thread) teamstore.Entry {
+ last := th.Root
+ for _, r := range th.Replies {
+ if r.ID > last.ID {
+ last = r
+ }
+ }
+ return last
+}
+
+// threads lays a manager's Traffic: its work threads, newest activity first,
+// and one General thread for the chatter, where its newest entry falls among
+// them.
+func (s *sideSheet) threads(kind int, handle string) {
+ all := s.a.trafficThreads(s.t)
+ var chatter []teamstore.Entry
+ var newest teamstore.Entry
+ for _, th := range all {
+ if !sideChatter(th) || s.a.sideAsked(s.t, kind, handle, th.Root.ID) {
+ continue
+ }
+ chatter = append(chatter, th.Root)
+ if th.Root.ID > newest.ID {
+ newest = th.Root
+ }
+ }
+ laid := len(chatter) == 0
+ for _, th := range all {
+ if sideChatter(th) {
+ continue
+ }
+ last := sideLast(th)
+ if !laid && newest.ID > last.ID {
+ s.general(chatter, newest)
+ laid = true
+ }
+ s.work(th, last)
+ }
+ if !laid {
+ s.general(chatter, newest)
+ }
+}
+
+// work lays one work thread: its row, and its replies under it when laid
+// open.
+func (s *sideSheet) work(th teamstore.Thread, last teamstore.Entry) {
+ pal := s.a.pal
+ e := th.Root
+ s.newer(last.ID)
+ key := sideThreadKey(s.t.ID, e.ID)
+ open := s.a.side.threads[key]
+ lines := replyLines(th)
+ row := &sideRow{key: "thread/" + e.ID,
+ act: sideAct{kind: sideActJump, entry: e.ID}}
+ // EVERY WORK ROW READS `from → to`: the manager's mark to the members it
+ // asked, or the member that wrote it back to the manager.
+ named := e.Recipients()
+ segs := s.route(row, e.From, e.ID, "", named, e.To)
+ title := s.says(e)
+ if teamstore.IsRuling(e) {
+ title = "ruling · " + title
+ }
+ segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: title, paint: pal.muted, door: -1, flex: true})
+ // THE STATE ITS ANSWERS LEAVE IT IN, and how many messages it holds.
+ state := sideThreadState(lines)
+ // A MESSAGE IS WORDS SOMEBODY WROTE: a wake or a finishing is an event
+ // the state already says, not a message.
+ msgs := 1
+ for _, r := range lines {
+ if r.said {
+ msgs++
+ }
+ }
+ var right []sideSeg
+ words := state
+ if state != "" {
+ right = append(right, sideSeg{text: " " + state, paint: pal.dim, door: -1})
+ }
+ if msgs > 1 {
+ count := itoa(msgs) + " msgs"
+ sep := " "
+ if words != "" {
+ words += " · "
+ sep = " · "
+ }
+ words += count
+ right = append(right, sideSeg{text: sep + count, paint: pal.dim, door: -1})
+ }
+ if len(th.Replies) > 0 {
+ glyph := s.a.linearMark(glyphShut, glyphShutASCII)
+ hint := "Show the replies" + hintSegment + "click"
+ if open {
+ glyph = s.a.linearMark(glyphOpen, glyphOpenASCII)
+ hint = "Fold the replies" + hintSegment + "click"
+ }
+ row.doors = append(row.doors, sideDoor{hint: hint, act: sideAct{kind: sideActThread, key: key}})
+ right = append(right, sideSeg{text: " ", door: -1}, sideSeg{text: glyph, paint: pal.muted, door: len(row.doors) - 1})
+ }
+ // THE HINT SAYS THE STATE TOO, because the row gives it up first when
+ // the column is narrow.
+ row.hint = title
+ if words != "" {
+ row.hint += hintSegment + words
+ }
+ // A RULING'S HINT NAMES THE CONFLICT IT DECIDED (DESIGN.md 8.10).
+ if teamstore.IsRuling(e) && e.Packet != "" {
+ row.hint += hintSegment + "the ruling on conflict " + e.Packet
+ }
+ row.hint += hintSegment + "click shows it in this chat"
+ s.add(row, segs, right, s.a.trafficAge(last))
+ if !open {
+ return
+ }
+ for _, r := range lines {
+ s.member(r)
+ }
+}
+
+// member lays one member's line under an open thread, with its events folded
+// into it the way replyLines folds them: `↳ @review → ◆ ✓ 2 findings`, or
+// `working…` for a member woken on the thread with nothing said yet. A wake or
+// a finishing is never a line of its own. The clock stays on the hint line.
+func (s *sideSheet) member(r trafficReply) {
+ pal := s.a.pal
+ e := r.last
+ if r.said {
+ e = r.entry
+ }
+ row := &sideRow{key: "reply/" + e.ID, act: sideAct{kind: sideActJump, entry: e.ID}}
+ segs := []sideSeg{{text: " " + s.a.linearMark("↳", "->") + " ", paint: pal.dim, door: -1}}
+ // A MEMBER'S LINE RUNS BACK TO THE MANAGER. A line the manager, the
+ // person or codeaf wrote runs the other way, to whoever it names.
+ to := teamstore.FromManager
+ if r.who == teamstore.FromManager || r.who == teamstore.FromSystem || r.who == teamstore.FromYou {
+ to = e.To
+ }
+ segs = append(segs, s.route(row, r.who, e.ID, "", nil, to)...)
+ words := r.note
+ if r.said {
+ words = s.says(r.entry)
+ }
+ switch r.state {
+ case teamstore.StateFinished:
+ words = s.a.linearMark(s.a.icon(tokens.GSettled), "ok") + " " + words
+ case teamstore.StateFailed:
+ words = s.a.linearMark(s.a.icon(tokens.GFailed), "x") + " " + words
+ case teamstore.StateAsking:
+ words = "asking: " + strings.TrimSpace(strings.TrimPrefix(words, "asks:"))
+ case "working":
+ words = "working" + s.a.linearMark("…", "...")
+ }
+ segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: pal.muted, door: -1, flex: true})
+ row.hint = sideHintWith(words+hintSegment+"click shows it in this chat", s.clock(e))
+ s.add(row, segs, nil, "")
+}
+
+// sideThreadState is the one word a thread's answers leave it in: someone
+// asking, someone still working, a failure, all finished, or answered.
+func sideThreadState(lines []trafficReply) string {
+ if len(lines) == 0 {
+ return "sent"
+ }
+ // EACH MEMBER'S LAST LINE IS WHERE IT STANDS. replyLines starts a new line
+ // for a member when an event follows a finished one, so a member that asked
+ // and then failed has both lines, and only the later one is still true.
+ last := make(map[string]int, len(lines))
+ for i, r := range lines {
+ last[r.who] = i
+ }
+ working, failed, finished, members := false, false, 0, 0
+ for i, r := range lines {
+ if last[r.who] != i {
+ continue
+ }
+ members++
+ switch r.state {
+ case teamstore.StateAsking:
+ return "asking"
+ case "working":
+ working = true
+ case teamstore.StateFailed:
+ failed = true
+ case teamstore.StateFinished:
+ finished++
+ }
+ }
+ switch {
+ case working:
+ return "running"
+ case failed:
+ return "failed"
+ case finished == members:
+ return "done"
+ }
+ return "answered"
+}
+
+// reply lays one chatter line under General: `↳ ◆ → @price an older aside`.
+// The clock stays on the hint line, as a work row's age does.
+func (s *sideSheet) reply(e teamstore.Entry) {
+ pal := s.a.pal
+ row := &sideRow{key: "reply/" + e.ID, act: sideAct{kind: sideActJump, entry: e.ID}}
+ segs := []sideSeg{{text: " " + s.a.linearMark("↳", "->") + " ", paint: pal.dim, door: -1}}
+ segs = append(segs, s.route(row, e.From, e.ID, "", e.Recipients(), e.To)...)
+ words := s.says(e)
+ segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: pal.muted, door: -1, flex: true})
+ row.hint = sideHintWith(words+hintSegment+"click shows it in this chat", s.clock(e))
+ s.add(row, segs, nil, "")
+}
+
+// general lays the chatter as one thread, `General`, and its entries under
+// it when laid open, newest first.
+func (s *sideSheet) general(chatter []teamstore.Entry, newest teamstore.Entry) {
+ pal := s.a.pal
+ s.newer(newest.ID)
+ key := sideThreadKey(s.t.ID, sideGeneral)
+ open := s.a.side.threads[key]
+ row := &sideRow{key: key, act: sideAct{kind: sideActThread, key: key},
+ hint: "Everything that is nobody's work" + hintSegment + "click shows it"}
+ if open {
+ row.hint = "Everything that is nobody's work" + hintSegment + "click folds it"
+ }
+ glyph := s.a.linearMark(glyphShut, glyphShutASCII)
+ if open {
+ glyph = s.a.linearMark(glyphOpen, glyphOpenASCII)
+ }
+ row.doors = append(row.doors, sideDoor{hint: row.hint, act: row.act})
+ right := []sideSeg{{text: " " + itoa(len(chatter)) + " " + plural("msg", len(chatter)) + " ", paint: pal.dim, door: -1}, {text: glyph, paint: pal.muted, door: 0}}
+ s.add(row, []sideSeg{{text: "General", paint: pal.muted, door: -1}}, right, s.a.trafficAge(newest))
+ if !open {
+ return
+ }
+ for _, e := range chatter {
+ s.reply(e)
+ }
+}
+
+// ── A MEMBER'S MESSAGES ─────────────────────────────────────────────────────
+
+// messages lays a member's Traffic: every drawn message involving it, newest
+// first, one line each.
+func (s *sideSheet) messages(handle string) {
+ rows := s.a.traffic.rows[s.t.ID]
+ for i := len(rows) - 1; i >= 0; i-- {
+ e := rows[i]
+ if !trafficShown(e) || e.Wake() || !sideInvolves(e, handle) {
+ continue
+ }
+ if s.a.sideAsked(s.t, sideKindMember, handle, e.ID) {
+ continue
+ }
+ s.newer(e.ID)
+ row := &sideRow{key: "msg/" + e.ID, act: sideAct{kind: sideActJump, entry: e.ID}}
+ // THIS MEMBER IS `you`. What it was told reads `◆ → you`, what it
+ // said reads `you → ◆` or `you → @other`.
+ segs := s.route(row, e.From, e.ID, handle, e.Recipients(), e.To)
+ words := s.says(e)
+ switch {
+ case e.State == teamstore.StateFinished:
+ words = s.a.linearMark(s.a.icon(tokens.GSettled), "ok") + " " + words
+ case e.State == teamstore.StateFailed:
+ words = s.a.linearMark(s.a.icon(tokens.GFailed), "x") + " " + words
+ }
+ segs = append(segs, sideSeg{text: " ", door: -1}, sideSeg{text: words, paint: s.a.pal.muted, door: -1, flex: true})
+ row.hint = words + hintSegment + "click shows it in this chat"
+ s.add(row, segs, nil, s.a.trafficAge(e))
+ }
+}
diff --git a/internal/tui3/sidetraffic_test.go b/internal/tui3/sidetraffic_test.go
new file mode 100644
index 0000000000..9c79691446
--- /dev/null
+++ b/internal/tui3/sidetraffic_test.go
@@ -0,0 +1,251 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// WHAT CAME IN WHILE THE TASKS WERE IN FRONT IS COUNTED ON THE OTHER WORD,
+// `Traffic 2 new`, and when the Traffic comes to the front a thin `new` line
+// stands under it and over what was already seen. The line holds still while
+// the Traffic is read, a row arriving moves neither the header nor the body,
+// and once looked at the word counts everything again.
+func TestTheTrafficSaysWhatIsNewAndHoldsStill(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ a.welcome.open = false
+ price, _ := trafficHandle(t, a, harbor, "openrouter")
+ rail, _ := trafficHandle(t, a, harbor, "Refactor")
+ trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "old work"})
+ trafficReadNow(t, a)
+ _ = railLines(t, a) // the Traffic in front: the old work is seen
+ a.sideSetView(sideTasks)
+ body, cols := a.bodyWidth(), a.railWidth()
+ head := railRowOf(railLines(t, a), sideTasksWord)
+
+ trafficAppend(t, a, harbor,
+ teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: rail, Text: "first new work"},
+ teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "second new work"},
+ )
+ trafficReadNow(t, a)
+ rows := railLines(t, a)
+ if railRowOf(rows, sideTrafficWord+" 2 "+sideNewWord) != head || a.bodyWidth() != body || a.railWidth() != cols {
+ t.Fatalf("the other word does not count what is new in place (body %d, cols %d):\n%s", a.bodyWidth(), a.railWidth(), strings.Join(rows, "\n"))
+ }
+
+ a.sideSetView(sideTraffic)
+ rows = railLines(t, a)
+ joined := strings.Join(rows, "\n")
+ second, first := railRowOf(rows, "second new work"), railRowOf(rows, "first new work")
+ line, old := railRowOf(rows, "── "+sideNewWord+" ──"), railRowOf(rows, "old work")
+ if railRowOf(rows, sideTrafficWord) != head || second != head+1 || first != second+1 || line != first+1 || old != line+1 {
+ t.Fatalf("the new line does not stand between the new and the seen (%d %d %d %d):\n%s", second, first, line, old, joined)
+ }
+ if strings.Contains(rows[head], sideNewWord) {
+ t.Fatalf("the word still says new with the Traffic in front: %q", rows[head])
+ }
+
+ // A ROW ARRIVING WHILE IT IS READ goes on top; the line and the header
+ // hold, and the body does not move.
+ trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: rail, Text: "third new work"})
+ trafficReadNow(t, a)
+ rows = railLines(t, a)
+ if railRowOf(rows, "third new work") != head+1 || railRowOf(rows, "── "+sideNewWord+" ──") != line+1 || railRowOf(rows, sideTrafficWord) != head || a.bodyWidth() != body {
+ t.Fatalf("a row arriving moved the line or the header:\n%s", strings.Join(rows, "\n"))
+ }
+
+ // AND LOOKED AT, IT IS SEEN: away and back, no line.
+ a.sideSetView(sideTasks)
+ a.sideSetView(sideTraffic)
+ if rows := railLines(t, a); railRowOf(rows, "── "+sideNewWord+" ──") >= 0 {
+ t.Fatalf("the line stayed over what was read:\n%s", strings.Join(rows, "\n"))
+ }
+}
+
+// IN A MEMBER'S CHAT THE TRAFFIC IS THAT MEMBER'S MESSAGES, newest first, one
+// line each: what it said is `→ whom`, what it was told is who told it, and
+// what went between two other members is not there. The count on the word is
+// the same set.
+func TestAMembersTrafficIsItsOwnMessages(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ price, priceKey := trafficHandle(t, a, harbor, "openrouter")
+ rail, _ := trafficHandle(t, a, harbor, "Refactor")
+ trafficAppend(t, a, harbor,
+ teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "scrape the prices"},
+ teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: rail, Text: "not for price"},
+ teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: "prices are in"},
+ teamstore.Entry{Kind: teamstore.KindNote, From: teamstore.FromManager, To: teamstore.ToEveryone, Text: "all hands"},
+ )
+ trafficReadNow(t, a)
+ spend(t, a, a.trafficGo(priceKey))
+ a.sideSetView(sideTraffic)
+ rows := railLines(t, a)
+ joined := strings.Join(rows, "\n")
+ all := railRowOf(rows, "all hands")
+ said := railRowOf(rows, "prices are in")
+ told := railRowOf(rows, "scrape the prices")
+ if all < 0 || said != all+1 || told != said+1 {
+ t.Fatalf("the member's messages are not newest first, one line each (%d %d %d):\n%s", all, said, told, joined)
+ }
+ if !strings.Contains(rows[said], "you → "+teamManagerGlyph) || !strings.Contains(rows[told], teamManagerGlyph+" → you") {
+ t.Fatalf("a row does not say who it is from and who it is for:\n%s", joined)
+ }
+ if strings.Contains(joined, "not for price") {
+ t.Fatalf("a message between others is in the member's Traffic:\n%s", joined)
+ }
+ if n, _ := a.sideTrafficCount(mustTeam(t, a, harbor), sideKindMember, price); n != 3 {
+ t.Fatalf("the word counts %d, want the member's 3", n)
+ }
+ // A PRESS ON A ROW GOES TO ITS MESSAGE HERE, and nowhere else.
+ x, y := sideRowOn(t, a, railKeyOfReply(t, a, "prices are in"))
+ sideClick(t, a, x, y)
+ if a.frontTabKey() != priceKey {
+ t.Fatalf("a press on the member's own row left its chat for %q", a.frontTabKey())
+ }
+}
+
+// THE GROUND THE POINTER LIGHTS IS THE TARGET THE PRESS HITS. On every row the
+// Traffic draws, laid open and not, the hover at a cell names the row, or the
+// door, that a press at the same cell acts on; the whole row lights for a
+// row, and lighting moves no word of it.
+func TestTheTrafficsHoverGroundIsItsClickTarget(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ price, _ := trafficHandle(t, a, harbor, "openrouter")
+ rail, _ := trafficHandle(t, a, harbor, "Refactor")
+ q := threadScenario(t, a, harbor, price, rail)
+ a.sideToggleThread(sideThreadKey(harbor, q))
+ a.sideToggleThread(sideThreadKey(harbor, sideGeneral))
+ _ = railLines(t, a)
+ view, _ := a.railDrawnView(a.viewHeight())
+ left := a.railLeft() + ansi.StringWidth(railSeam)
+ checked := 0
+ for i, line := range view {
+ if line.side == nil || line.side.key == sideHeadKey {
+ continue
+ }
+ y := a.topHeight() + i
+ for col := 0; col < a.railRoom(); col++ {
+ a.setHover(left+col, y)
+ door := line.side.doorAt(col)
+ switch {
+ case door >= 0:
+ if a.hot.kind != hoverSide || a.hot.key != line.side.key || a.hot.index != door {
+ t.Fatalf("row %q col %d: the pointer lit %+v, the press hits door %d", line.side.key, col, a.hot, door)
+ }
+ case line.side.pressable():
+ if a.hot.kind != hoverSide || a.hot.key != line.side.key || a.hot.index != -1 {
+ t.Fatalf("row %q col %d: the pointer lit %+v, the press hits the row", line.side.key, col, a.hot)
+ }
+ default:
+ if a.hot.kind == hoverSide {
+ t.Fatalf("row %q col %d lights with nothing to press", line.side.key, col)
+ }
+ }
+ checked++
+ }
+ // THE WHOLE ROW LIGHTS, and the words under the light are the words.
+ if line.side.pressable() {
+ x, _ := sideRowOn(t, a, line.side.key)
+ a.dropHover()
+ before := a.railRows(a.viewHeight())
+ a.setHover(x, y)
+ after := a.railRows(a.viewHeight())
+ y0 := y - a.topHeight()
+ if before[y0] == after[y0] || ansi.Strip(before[y0]) != ansi.Strip(after[y0]) {
+ t.Fatalf("row %q: the hover did not light it, or moved its words:\n%q\n%q", line.side.key, before[y0], after[y0])
+ }
+ }
+ }
+ if checked == 0 {
+ t.Fatal("the Traffic drew no row to point at")
+ }
+ a.dropHover()
+}
+
+// rowKeepsTheArrow reports that a Traffic row still says who it is from and
+// who it is for, cuts its words at a word, and carries no age. full is the
+// words the row was cut from.
+func rowKeepsTheArrow(row, full string) bool {
+ row = strings.TrimRight(row, " ")
+ if !strings.Contains(row, "→") || strings.HasSuffix(row, "now") {
+ return false
+ }
+ // A cut through a word of full leaves a prefix of that word and the
+ // ellipsis, with the rest of the word missing.
+ for _, w := range strings.Fields(full) {
+ if len(w) < 4 {
+ continue
+ }
+ for n := 2; n < len(w); n++ {
+ frag := w[:n] + "…"
+ if strings.Contains(row, frag) && !strings.Contains(row, w) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// EVERY WIDTH DRAWS `from → to` AND CUTS ONLY THE WORDS. At a column of 28,
+// 32 and 40 the arrow and the names stay, the words stop at a word, and the
+// age is on the hint line. The same is true of a member's own rows.
+func TestTrafficRowsKeepTheArrowAtEveryWidth(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ price, priceKey := trafficHandle(t, a, harbor, "openrouter")
+ rail, _ := trafficHandle(t, a, harbor, "Refactor")
+ q := threadScenario(t, a, harbor, price, rail)
+ full := "Please provide a brief status update on your part"
+ for _, width := range []int{112, 128, 160} {
+ a.width, a.height = width, 40
+ a.touch()
+ if got := a.railWidth(); got != sideColsFor(width) || (got != 28 && got != 32 && got != 40) {
+ t.Fatalf("width %d lends column %d, want 28, 32 or 40", width, got)
+ }
+ rows := railLines(t, a)
+ at := railRowOf(rows, "@"+price+" +2")
+ if at < 0 || !strings.Contains(rows[at], teamManagerGlyph+" → ") || !rowKeepsTheArrow(rows[at], full) {
+ t.Fatalf("at column %d the work row lost its arrow or cut a word:\n%s", a.railWidth(), strings.Join(rows, "\n"))
+ }
+ hint := a.sideRowOf("thread/" + q).hint
+ if !strings.Contains(hint, hintSegment+"now"+hintSegment+"click") {
+ t.Fatalf("at column %d the age is not on the hint: %q", a.railWidth(), hint)
+ }
+ }
+ spend(t, a, a.trafficGo(priceKey))
+ a.sideSetView(sideTraffic)
+ for _, width := range []int{112, 128, 160} {
+ a.width, a.height = width, 40
+ a.touch()
+ rows := railLines(t, a)
+ told := railRowOf(rows, teamManagerGlyph+" → you")
+ if told < 0 || !rowKeepsTheArrow(rows[told], full) {
+ t.Fatalf("at column %d a member row is not `from → you`:\n%s", a.railWidth(), strings.Join(rows, "\n"))
+ }
+ if hint := a.sideRowOf(railKeyOfReply(t, a, teamManagerGlyph+" → you")).hint; !strings.Contains(hint, "now") {
+ t.Fatalf("at column %d the member row's hint has no age: %q", a.railWidth(), hint)
+ }
+ }
+}
+
+// A CUT NEVER LEAVES A CLAUSE ITS SEPARATOR AND NOTHING ELSE.
+func TestABandRowIsCutAtAClause(t *testing.T) {
+ for _, c := range []struct {
+ words string
+ width int
+ want string
+ }{
+ {"Ship the price table · your call", 24, "Ship the price table…"},
+ {"Ship the price table · your call", 29, "Ship the price table · your…"},
+ {"Ship the price table", 30, "Ship the price table"},
+ } {
+ if got, w := fitClauses(c.words, c.width, "…"); got != c.want || w > c.width {
+ t.Fatalf("%q at %d is %q, want %q", c.words, c.width, got, c.want)
+ }
+ }
+}
diff --git a/internal/tui3/standing.go b/internal/tui3/standing.go
index 58ff0aa87b..0e91191dad 100644
--- a/internal/tui3/standing.go
+++ b/internal/tui3/standing.go
@@ -131,17 +131,20 @@ const (
// their first card said, in as many words, that they did not understand the
// options. So the words say what will HAPPEN: it gets set up, you change
// something about it, it happens once, or nothing does.
- standYesWord = "yes, set it up"
- standChangeWord = "change when or where"
- standOnceWord = "just once"
+ // These are the repeating check's words with no cadence in hand. A real card
+ // reads [session.StandingOptions], which puts the cadence on the yes and
+ // says a different sentence for a reminder, a watch or a rule.
+ standYesWord = "Set it up"
+ standChangeWord = "Change…"
+ standOnceWord = "Only now, don't repeat"
// standNoWordChip is the way out, ON the card. It used to be `esc` and a `0`
- // named in the hint slot under the message box and nowhere else — a decline
- // a person had to already know about, which is the one trade
+ // named in the hint slot under the message box and nowhere else. A decline
+ // a person had to already know about is the one trade
// docs/DESIGN-LANGUAGE.md refuses by name: every chord keeps a visible,
// clickable door beside it. So the decline is a chip like the others, under
// [session.StandingNoKey], and it is the chip that is never dropped for want
// of room ([app.pickRow]).
- standNoWordChip = "no"
+ standNoWordChip = "Don't set it up"
// The two band labels. They are lower-case nouns and not headings: this is
// a card in a conversation, and a card with a heading on every row is a form.
@@ -179,10 +182,10 @@ const (
// bands two rows above are for. Under a card that draws both, that sentence
// was the two-renderings defect one size smaller. What no band can say is how
// LONG each answer lasts, so that is what is left.
- standYesCost = "it keeps happening until you stop it"
- standOnceCost = "it happens now, and nothing is kept"
- standNoCost = "nothing happens, now or later"
- standChangeCost = "say the time or the place you want instead"
+ standYesCost = "It repeats on that cadence until you stop it."
+ standOnceCost = "Runs the check one time now. Nothing repeats."
+ standNoCost = "Nothing is set up, and nothing runs."
+ standChangeCost = "Say a different time or place. Nothing is set up yet."
)
// The glyphs a standing row wears, and their stand-ins on a terminal that
@@ -550,10 +553,11 @@ func (a *app) standingQuestion(card *standingCard, notice session.StandingNotice
// this" rather than "there are no answers".
options = session.StandingOptions(card.item)
}
- dressed := make([]session.AnswerOption, 0, len(options))
- for _, option := range options {
- option.Label, option.Consequence = standAnswerWord(option.Key), standAnswerCost(option.Key)
- dressed = append(dressed, option)
+ // THE WORDS ARE THE ENGINE'S. This surface draws them and does not respell
+ // them: another window, and the record of what was pressed, read the same
+ // list. A notice that arrived without them is filled from the item.
+ if options[0].Label == "" || options[0].Consequence == "" {
+ options = session.StandingOptions(card.item)
}
return session.Question{
ID: card.id,
@@ -561,53 +565,30 @@ func (a *app) standingQuestion(card *standingCard, notice session.StandingNotice
Ask: session.AskChoice,
Form: session.FormCard,
Asker: session.Asker{Kind: session.AskerModel},
- Head: session.StandingAskLead + strings.TrimSpace(card.item.Words),
+ Head: session.StandingHead(card.item),
Reason: session.StandingAskReason,
Subject: session.SubjectRef{Kind: session.SubjectOrder, ID: card.id, Name: strings.TrimSpace(card.item.Words)},
- Options: dressed,
+ Options: options,
Stakes: session.StakesReversible,
Scope: []session.AnswerScope{session.ScopeOnce, session.ScopeAlways},
// THE CARD'S BOX IS ITS CORRECTION LANE and always was: "make it 2pm" is
// a real answer to this question, and the engine reads a standing answer
// that carries words alone as a correction to re-propose on
// (session's applyToLane).
- Input: session.InputShape{Kind: session.InputText, Prompt: standChangeCost},
+ Input: session.InputShape{Kind: session.InputText, Prompt: session.StandingChangeHint(card.item)},
Deadline: notice.Deadline,
Asked: a.now(),
}
}
// standAnswerWord is what one answer is CALLED on this card, by the key that
-// takes it.
-//
-// EVERY ONE OF THEM NAMES ITS OUTCOME IN WORDS A STRANGER READS COLD, which is
-// why the card says more than the kind's own list does ([session.AnswerOptions]
-// spells them `yes`, `just once`, `not set up` for home's chip row, where the
-// column is scarce). A person meeting their first card said, in as many words,
-// that they did not understand the options; these say what will HAPPEN.
-func standAnswerWord(key string) string {
- switch key {
- case standYesKey:
- return standYesWord
- case session.StandingOnceKey:
- return standOnceWord
- case session.StandingNoKey:
- return standNoWordChip
- }
- return ""
-}
-
-// standAnswerCost is what one answer costs, by the key that takes it. A key the
-// list does not carry has no clause, which is the emptiness law rather than a
-// default.
-func standAnswerCost(key string) string {
- switch key {
- case standYesKey:
- return standYesCost
- case session.StandingOnceKey:
- return standOnceCost
- case session.StandingNoKey:
- return standNoCost
+// takes it. The words are the kind's own ([session.StandingOptions]), so the
+// transcript, home and the recorded labels say the same thing.
+func standAnswerWord(item standing.Item, key string) string {
+ for _, option := range session.StandingOptions(item) {
+ if option.Key == key {
+ return option.Label
+ }
}
return ""
}
@@ -647,9 +628,9 @@ func standVerdictOf(card *standingCard, answer session.Answer) (string, string)
// A CORRECTION IS NOT A YES. The person said what is wrong with the
// arrangement and the model re-proposes on those words; nothing stands
// yet, and the row has to say so.
- return standChangedWord, standChangeWord
+ return standChangedWord, session.StandingChangeWord(card.item)
}
- word := standAnswerWord(key)
+ word := standAnswerWord(card.item, key)
switch key {
case session.StandingOnceKey:
return standOnceDone, word
@@ -826,35 +807,20 @@ func StandingCardRows(a *app, card *standingCard, width int, sel bool) []string
// are agreeing to.
func (a *app) standBands(card *standingCard, width int) []string {
var out []string
+ // THE THIRD LINE IS THE WHEN AND THE COST, one sentence. The tags used to
+ // take a row each. What a person checks is the cadence and what one time
+ // costs, and they read them together.
+ var fact string
if card.when != "" && card.item.When.Kind != standing.WhenHold {
- word := standWhenTag + card.when
+ fact = card.when
if card.guessed {
// THE GUESS IS SAID OUT LOUD, in the card's own sentence from
// docs/AMBIENT.md. A cadence the model invented and the card stated
// flatly is the one thing on this block a person cannot audit
// afterwards, because it looks exactly like something they said.
- word += standGuessTag
- }
- for _, line := range wrap(word, width) {
- out = append(out, a.pal.dim(line))
+ fact += standGuessTag
}
}
- // AND HOW FAR IT REACHES, ALWAYS SAID, which is the one band here that is
- // never dropped. The other two can be empty because a notice may carry no
- // words for them; a reach cannot — [standing.Item.Level] resolves the zero
- // value to a real answer — and an order whose reach was not on the card is
- // an order somebody agreed to without knowing where it applies
- // (docs/STANDING-ORDERS.md: the card always names it before anything
- // stands). It is drawn in the person's own words and never the field's
- // ([standLevelWord]).
- for _, line := range wrap(standWhereTag+standLevelWord(card.item.Level()), width) {
- out = append(out, a.pal.dim(line))
- }
- // AND A RULE HAS NO COST BAND AT ALL. A hold never wakes, so it never runs a
- // probe, never buys a judgment and never launches work ([standing.Item.Spends]
- // is where that is decided) — and the emptiness law reaches a whole band: an
- // allowance quoted on a card for something that can never draw on it is a
- // figure the person has to weigh and nothing will ever spend.
cost := ""
if card.item.Spends() {
cost = card.cost
@@ -867,10 +833,28 @@ func (a *app) standBands(card *standingCard, width int) []string {
}
}
if cost != "" {
- for _, line := range wrap(standCostTag+cost, width) {
+ if fact != "" {
+ fact += " · " + cost
+ } else {
+ fact = cost
+ }
+ }
+ if fact != "" {
+ for _, line := range wrap(fact, width) {
out = append(out, a.pal.dim(line))
}
}
+ // AND HOW FAR IT REACHES, ALWAYS SAID, which is the one band here that is
+ // never dropped. The other two can be empty because a notice may carry no
+ // words for them. A reach cannot: [standing.Item.Level] resolves the zero
+ // value to a real answer, and an order whose reach was not on the card is
+ // an order somebody agreed to without knowing where it applies
+ // (docs/STANDING-ORDERS.md: the card always names it before anything
+ // stands). It is drawn in the person's own words and never the field's
+ // ([standLevelWord]).
+ for _, line := range wrap(standWhereTag+standLevelWord(card.item.Level()), width) {
+ out = append(out, a.pal.dim(line))
+ }
return out
}
@@ -984,13 +968,14 @@ func (a *app) standingAnimating() bool {
// conversation draws and the card home's errand pane draws (homeexchange.go) are
// the same object read the same way.
func (a *app) standingCardFor(notice session.StandingNotice) *standingCard {
- words := strings.TrimSpace(notice.Item.Words)
- name := standName(words)
+ // THE HEAD NAMES THE KIND. The second line is what it does, the brief's
+ // title or the person's own sentence when nobody wrote a title.
+ does := strings.TrimSpace(notice.Item.Title())
return &standingCard{
id: notice.ID,
item: notice.Item,
- name: name,
- words: standSub(name, words),
+ name: session.StandingHead(notice.Item),
+ words: does,
when: strings.TrimSpace(notice.WhenWords),
cost: strings.TrimSpace(notice.CostWords),
guessed: notice.Guessed,
diff --git a/internal/tui3/standing_test.go b/internal/tui3/standing_test.go
index ed0897fbb7..cfd388daf9 100644
--- a/internal/tui3/standing_test.go
+++ b/internal/tui3/standing_test.go
@@ -132,8 +132,9 @@ func TestAStandingProposalDrawsWhenAndCost(t *testing.T) {
text := standText(a)
for _, want := range []string{
taskHeadCorner + " " + glyphAsk + " " + standWaitGlyph,
- standWhenTag + "Mondays at 9am",
- standCostTag + "about $0.02 a run, at most once a day",
+ "Mondays at 9am",
+ "about $0.02 a run, at most once a day",
+ session.StandingHeadCheck,
standEndsWord + "30s",
taskFootCorner,
} {
@@ -236,7 +237,11 @@ func TestTheThreeKeysSendTheThreeAnswers(t *testing.T) {
if kept.stand == nil || !kept.stand.settled() {
t.Fatal("the answered card left the transcript")
}
- if page := standText(kept); !strings.Contains(page, standYesWord+" · "+standSetWord) {
+ // The chosen word is the kind's own yes, cadence included. The verdict sits
+ // after it, so a later reading says both which button was pressed and what
+ // that button did.
+ wantFoot := standAnswerWord(standItem(), "1") + " · " + standSetWord
+ if page := standText(kept); !strings.Contains(page, wantFoot) {
t.Fatalf("the settled card does not carry the answer and what it came to:\n%s", page)
}
@@ -514,7 +519,7 @@ func TestAOneOffReminderCardDrawsTwoChips(t *testing.T) {
})
block := standBlock(a)
- for _, want := range []string{"1 " + standYesWord, "0 " + standNoWordChip} {
+ for _, want := range []string{"1 Remind me", "0 Don't remind me"} {
if !strings.Contains(block, want) {
t.Fatalf("a reminder's card is missing %q:\n%s", want, block)
}
@@ -535,7 +540,7 @@ func TestAOneOffReminderCardDrawsTwoChips(t *testing.T) {
// The derivation names the keys the card drew and not one more. (The slot
// itself is quiet while the block draws them — hints pick A — and this is
// the reading behind it, which is where the defect would be.)
- const twoHint = "1 yes, set it up · 0 no · esc later"
+ const twoHint = "1 Remind me in 1 minute — 07:35 · 0 Don't remind me · esc later"
if got := a.questionHintFor(); got != twoHint {
t.Fatalf("the hint is %q, want %q", got, twoHint)
}
@@ -580,10 +585,10 @@ func TestAWatchCardStillDrawsThreeChips(t *testing.T) {
})
block := standBlock(a)
- if !strings.Contains(block, "3 "+standOnceWord) {
- t.Fatalf("a watch lost its `%s` answer:\n%s", standOnceWord, block)
+ if !strings.Contains(block, "3 Check once now") {
+ t.Fatalf("a watch lost its once answer:\n%s", block)
}
- const threeHint = "1 yes, set it up · 3 just once · 0 no · esc later"
+ const threeHint = "1 Watch for it · 3 Check once now · 0 Don't watch · esc later"
if got := a.questionHintFor(); got != threeHint {
t.Fatalf("the hint is %q, want %q", got, threeHint)
}
@@ -672,7 +677,13 @@ func TestZeroSaysNoToAStandingCardWhereverItIsDrawn(t *testing.T) {
Item: item, WhenWords: "every few minutes", CostWords: "about $0.02 a check",
Options: session.StandingOptions(item),
})
- if block := standBlock(a); !strings.Contains(block, session.StandingNoKey+" "+standNoWordChip) {
+ label := ""
+ for _, option := range session.StandingOptions(item) {
+ if option.Key == session.StandingNoKey {
+ label = option.Label
+ }
+ }
+ if block := standBlock(a); !strings.Contains(block, session.StandingNoKey+" "+label) {
t.Fatalf("the card does not draw the decline:\n%s", block)
}
}
@@ -985,9 +996,9 @@ func TestEveryStandingAnswerSaysWhatItWillDo(t *testing.T) {
})
block := standBlock(a)
for key, want := range map[string]string{
- "1": standYesCost,
- session.StandingOnceKey: standOnceCost,
- session.StandingNoKey: standNoCost,
+ "1": "It watches until you stop it.",
+ session.StandingOnceKey: "Checks once now. Nothing keeps watching.",
+ session.StandingNoKey: "Nothing watches.",
} {
if !strings.Contains(block, want) {
t.Fatalf("the answer under %q does not say %q:\n%s", key, want, block)
@@ -1127,3 +1138,16 @@ func TestAStandingQuestionArrivingByBothRoadsDrawsOnce(t *testing.T) {
t.Fatalf("the lane's copy respelled the answers:\n%s", block)
}
}
+
+// A NARROW BAND DROPS THE CADENCE BEFORE IT CUTS A CHARACTER. "Set it up ·
+// every 3 hours" becomes "Set it up", and only a still-too-narrow stem is cut.
+func TestANarrowStandingLabelDropsTheCadenceFirst(t *testing.T) {
+ a := newTestApp(&fakeAgent{})
+ full := "Set it up · every 3 hours"
+ if got := a.questionBandWord(full, "1", 24); got != "Set it up" {
+ t.Fatalf("the cadence was not dropped: %q", got)
+ }
+ if got := a.questionBandWord("Don't set it up", "0", 80); got != "Don't set it up" {
+ t.Fatalf("the no was rewritten: %q", got)
+ }
+}
diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go
index 7f0927d001..ddeaeac6c5 100644
--- a/internal/tui3/stop.go
+++ b/internal/tui3/stop.go
@@ -394,9 +394,14 @@ func (a *app) stopSay(line string) {
// the roster does not hold the keyboard there is no cursor and nothing is being
// aimed at (taskstrip.go's [app.stripFocused] reads the same two fields).
func (a *app) railFocusNode() *taskNode {
- if !a.railHold || a.railWhere.id == 0 {
+ if !a.railHold {
return nil
}
+ if a.railWhere.id == 0 {
+ // A TASK'S ROW IN THE BAND IS THAT TASK, for every key the held column
+ // answers (sidecol.go).
+ return a.tasks[sideTaskOf(a.railWhere.key)]
+ }
return a.tasks[a.railWhere.id]
}
@@ -410,9 +415,8 @@ func (a *app) railFocusNode() *taskNode {
// at an unseen row is the guess this whole path exists to avoid: [app.railView]
// is the one door onto the roster's geometry, the same door the pointer and the
// frame are answered through, so this and they cannot disagree about what is
-// on screen. A FOLDED ROOT STANDS FOR WHAT IT HIDES, exactly as it does for the
-// pointer: one row covering a family is one target, and its `worst` node is the
-// work it is standing for ([app.railView] reads the same field for the pin).
+// on screen. A group folded to its heading hides its rows from this count as
+// it hides them from the eye.
//
// Counted here and not beside the caller so the one-row rule has one statement
// rather than two copies a second caller could get wrong.
@@ -426,13 +430,7 @@ func (a *app) stopVisible() (int, *taskNode) {
continue
}
if a.stopTaskTarget(node).empty() {
- // A folded root carries nothing stoppable of its own but may be standing
- // for a subtree that does. The work it stands for is the worst node it
- // hid, which is the same reading the pin and the row's own glyph take.
- if !e.folded || a.stopTaskTarget(e.worst).empty() {
- continue
- }
- node = e.worst
+ continue
}
if count++; count > 1 {
return count, nil
diff --git a/internal/tui3/stopping_test.go b/internal/tui3/stopping_test.go
index 6de06c6328..cbcc0f43d6 100644
--- a/internal/tui3/stopping_test.go
+++ b/internal/tui3/stopping_test.go
@@ -217,6 +217,10 @@ func TestTheStoppedSurfaceReadsAClockThatDoesNotMoveOnItsOwn(t *testing.T) {
// insists the compared frame SEES it.
func TestTheStoppedFramesComparisonStillCoversTheHead(t *testing.T) {
a, _, elapse := stoppingAppOnAHeldClock(t)
+ // THE HEAD'S CLOCK NEEDS THE ROOM THE NAV LEAVES IT. The top line carries
+ // the places before the clock (topnav.go), and on the fixture's narrow
+ // frame the clock is the first thing the line drops.
+ a.width = 160
drive(t, a, key("esc"))
was := stoppedFrame(a)
diff --git a/internal/tui3/striporder_test.go b/internal/tui3/striporder_test.go
index a0169ddcde..e1e6ba9cd4 100644
--- a/internal/tui3/striporder_test.go
+++ b/internal/tui3/striporder_test.go
@@ -7,51 +7,44 @@ import (
"github.com/charmbracelet/x/ansi"
)
-// THE STRIP READS HOME, THE TEAM CHIP, THE MANAGER, THEN THE TABS. Home is a
-// fixed door and stands first; the chip filters the tabs, so it sits right
-// before them with the manager's place after it. The hits follow the words,
-// and as the row narrows Home goes first, then the chip, never the tab in
-// front.
-func TestTheStripReadsHomeThenTheTeamThenItsTabs(t *testing.T) {
+// THE STRIP READS THE TEAM CHIP, THE MANAGER, THEN THE TABS. The chip filters
+// the tabs, so it stands first, right before them, with the manager's place
+// after it. There is no `home` piece in front of it any more: home is the
+// nav's first word, on the row over the strip on every page (topnav.go). The
+// hits follow the words, and as the row narrows the chip goes before the tab
+// in front ever does.
+func TestTheStripReadsTheTeamThenItsTabs(t *testing.T) {
a, _, _, _ := trafficApp(t)
a.open = func(workspace, transcript string) (Conversation, error) { return Conversation{}, nil }
a.width, a.height = 160, 40
- if !a.homeDoorOpen() {
- t.Fatal("the fixture has no Home door")
- }
row := ansi.Strip(a.tabsRow(a.width))
- home, chip, manager := strings.Index(row, pageHome.word()), strings.Index(row, "harbor ▾"), strings.Index(row, teamManagerGlyph+" Manager")
- if home < 0 || chip < 0 || manager < 0 || !(home < chip && chip < manager) {
+ chip, manager := strings.Index(row, "harbor ▾"), strings.Index(row, teamManagerGlyph+" Manager")
+ if chip < 0 || manager < 0 || chip > manager {
t.Fatalf("the strip reads %q", row)
}
- var homeHit tabHit
+ if strings.Contains(row, " home ") || strings.Contains(row, "Home") {
+ t.Fatalf("the strip still carries a way home, which is the nav's first word now: %q", row)
+ }
for _, hit := range a.chatTabHits {
- if hit.kind == tabHome {
- homeHit = hit
- }
if hit.kind == tabManager || hit.kind == tabHere || hit.kind == tabOther {
if hit.span.from < a.wall.chip.to {
t.Fatalf("a tab's hit %+v is before the chip's end %d", hit, a.wall.chip.to)
}
}
}
- if homeHit.span.from != headLabelAt || a.wall.chip.from != headLabelAt+len(" home ")+2 {
- t.Fatalf("Home's hit %+v and the chip's %+v are not where they are drawn", homeHit.span, a.wall.chip)
+ if a.wall.chip.from != headLabelAt {
+ t.Fatalf("the chip's hit %+v is not the strip's first piece at %d", a.wall.chip, headLabelAt)
}
if got := plainCells(row, a.wall.chip.from, a.wall.chip.to); !strings.Contains(got, "harbor") {
t.Fatalf("the chip's hit covers %q", got)
}
- // Narrowing: Home goes before the chip, and the tab in front stays.
- sawHomeless := false
+ // Narrowing: the chip goes, and the tab in front stays.
+ sawChipless := false
for w := 159; w >= roomHeadFloor; w-- {
a.chatTabBar = tabBar{}
row := ansi.Strip(a.tabsRow(w))
- hasHome, hasChip := strings.Contains(row, pageHome.word()), strings.Contains(row, "harbor")
- if hasHome && !hasChip {
- t.Fatalf("at %d the chip went before Home: %q", w, row)
- }
- if !hasHome && hasChip {
- sawHomeless = true
+ if !strings.Contains(row, "harbor") {
+ sawChipless = true
}
front := false
for _, hit := range a.chatTabHits {
@@ -63,7 +56,7 @@ func TestTheStripReadsHomeThenTheTeamThenItsTabs(t *testing.T) {
t.Fatalf("at %d the tab in front is gone: %q", w, row)
}
}
- if !sawHomeless {
- t.Fatal("no width dropped Home and kept the chip")
+ if !sawChipless {
+ t.Fatal("no width gave the chip up to keep the tab in front")
}
}
diff --git a/internal/tui3/tabinset_test.go b/internal/tui3/tabinset_test.go
index f92fc5b4f4..097f94ccc4 100644
--- a/internal/tui3/tabinset_test.go
+++ b/internal/tui3/tabinset_test.go
@@ -42,7 +42,13 @@ func TestTabInsetSurroundsPaintedStatusAndClose(t *testing.T) {
line := a.tabsPaint(pieces)
first := ansi.Cut(line, label.span.from, label.span.from+1)
last := ansi.Cut(line, close.span.to-1, close.span.to)
- if plain(first) != " " || plain(last) != " " {
+ // WITH NO COLOUR, THE POINTER'S TAB WEARS `·` IN ITS LEADING INSET,
+ // as a nav word does; every other edge is a blank.
+ lead := " "
+ if profile == tokens.NoColor && hover >= 0 {
+ lead = "·"
+ }
+ if plain(first) != lead || plain(last) != " " {
t.Fatalf("profile=%v signal=%v active=%v hover=%d: tab edges are not inset: %q / %q", profile, signal, active, hover, first, last)
}
if profile >= tokens.ANSI256 && (active || hover >= 0) {
@@ -77,11 +83,11 @@ func TestTabInsetCellsKeepTheirNavigationAndCloseOwnership(t *testing.T) {
if closeIt {
x, want = close.to-1, tabClose
}
- hit, ok := a.tabAt(x, placeTabRow)
+ hit, ok := a.tabAt(x, tabStripRow)
if !ok || hit.kind != want || hit.tab.word != word {
t.Fatalf("inset maps to wrong target: %+v", hit)
}
- cmd, took := a.tabPress(x, placeTabRow)
+ cmd, took := a.tabPress(x, tabStripRow)
if !took {
t.Fatal("inset click fell through")
}
diff --git a/internal/tui3/tabscroll.go b/internal/tui3/tabscroll.go
index 6b92074332..b69c721c56 100644
--- a/internal/tui3/tabscroll.go
+++ b/internal/tui3/tabscroll.go
@@ -30,10 +30,10 @@ func (a *app) tabWindow(tabs []chatTab, widths []int, budget, active int) (int,
for _, width := range widths {
used += width + 1
}
- scroll := used > budget && budget >= tabReadableCells+tabInsetCells+tabCloseCells+2+2*tabArrowCells
+ scroll := used > budget && budget >= tabReadableCells+tabInsetCells+tabCloseCells+2+tabArrowsCells(1)
available := budget
if scroll {
- available -= 2 * tabArrowCells
+ available -= tabArrowsCells(1)
} else {
v.browsing = false
}
@@ -57,14 +57,21 @@ func (a *app) tabWindow(tabs []chatTab, widths []int, budget, active int) (int,
return from, to, scroll
}
+// tabArrowsCells is what a scrolling strip spends on its two arrows: their
+// cells and the gap in front of the left one ([app.tabsFit]).
+func tabArrowsCells(sepW int) int { return 2*tabArrowCells + sepW }
+
func (a *app) tabArrowPiece(right, enabled bool, at int) (tabPiece, *tabHit) {
- if !enabled {
- return tabPiece{word: " ", quiet: true}, nil
- }
word, kind := a.linearMark("‹", "<"), tabScrollLeft
if right {
word, kind = a.linearMark("›", ">"), tabScrollRight
}
+ // A SCROLLING STRIP DRAWS BOTH ARROWS, the one with nowhere to go dim and
+ // inert. Its cells are held either way so the window never shifts under a
+ // press, and three blank cells there read as a gap nobody meant.
+ if !enabled {
+ return tabPiece{word: " " + word + " ", quiet: true}, nil
+ }
return tabPiece{word: " " + word + " ", kind: kind}, &tabHit{span: hudSpan{from: at, to: at + tabArrowCells}, kind: kind}
}
diff --git a/internal/tui3/tabscroll_test.go b/internal/tui3/tabscroll_test.go
index 15c84f277e..30b4818d2f 100644
--- a/internal/tui3/tabscroll_test.go
+++ b/internal/tui3/tabscroll_test.go
@@ -73,7 +73,7 @@ func TestManyTabsRemainReadableWhileArrowsBrowseWithoutSwitching(t *testing.T) {
break
}
right := tabScrollTarget(t, a, tabScrollRight)
- cmd, took := a.tabPress(right.span.to-1, placeTabRow)
+ cmd, took := a.tabPress(right.span.to-1, tabStripRow)
if !took || cmd != nil {
t.Fatal("scroll arrow opened or started something")
}
@@ -104,7 +104,7 @@ func TestTabStripWheelBrowsesBothAxesWithoutTouchingTheTranscript(t *testing.T)
for _, button := range []tea.MouseButton{tea.MouseWheelRight, tea.MouseWheelDown, tea.MouseWheelLeft, tea.MouseWheelUp} {
_ = a.tabsRow(a.width)
from := a.tabView.from
- drive(t, a, tea.MouseWheelMsg{X: 20, Y: placeTabRow, Button: button})
+ drive(t, a, tea.MouseWheelMsg{X: 20, Y: tabStripRow, Button: button})
if a.tabView.from == from {
t.Fatalf("wheel %v did not browse tabs", button)
}
@@ -122,7 +122,10 @@ func TestTabViewportFitsUnicodePlainAndCompactFramesAfterBrowsing(t *testing.T)
for _, ascii := range []bool{false, true} {
a := manyTabApp(t)
a.pal = newPalette(profile, ascii)
- for _, width := range []int{160, 80, 40, 24, 12} {
+ // 36 is a compact strip: the strip had eight fewer cells while a
+ // `home` piece led it, and forty columns was compact then; the
+ // scroll floor itself ([tabReadableCells]) did not move.
+ for _, width := range []int{160, 80, 36, 24, 12} {
for _, height := range []int{40, 16} {
a.width, a.height = width, height
a.touch()
@@ -136,7 +139,7 @@ func TestTabViewportFitsUnicodePlainAndCompactFramesAfterBrowsing(t *testing.T)
t.Fatalf("overlapping or offscreen target at %d: %+v", width, hit)
}
for x := hit.span.from; x < hit.span.to; x++ {
- got, ok := a.tabAt(x, placeTabRow)
+ got, ok := a.tabAt(x, tabStripRow)
if !ok || got.kind != hit.kind || got.tab.key != hit.tab.key {
t.Fatalf("drawn target disagrees with hit at %d", x)
}
@@ -227,7 +230,7 @@ func TestNewChatFollowsTheLastVisibleTab(t *testing.T) {
}
}
for x := plus.span.from; x < plus.span.to; x++ {
- got, ok := a.tabAt(x, placeTabRow)
+ got, ok := a.tabAt(x, tabStripRow)
if !ok || got.kind != plus.kind {
t.Fatalf("adjacent control has wrong hit ownership: %+v", got)
}
diff --git a/internal/tui3/tabtitle.go b/internal/tui3/tabtitle.go
index 5252238546..363fe07a43 100644
--- a/internal/tui3/tabtitle.go
+++ b/internal/tui3/tabtitle.go
@@ -32,7 +32,7 @@ func (a *app) tabTitlePreview(frame string) string {
rows := strings.Split(frame, "\n")
// A modal or another place can hide the strip while its last hit map remains.
// Only a frame actually drawing this strip may reveal its title.
- if at := placeTabRow; at >= len(rows) || rows[at] != a.chatTabBar.line {
+ if at := tabStripRow; at >= len(rows) || rows[at] != a.chatTabBar.line {
return frame
}
preview := strings.Split(ansi.Wrap(title, width-2*headLabelAt, ""), "\n")
@@ -43,7 +43,7 @@ func (a *app) tabTitlePreview(frame string) string {
end := min(width, max(a.tabBoxEnd(hit), headLabelAt+block))
start := end - block
for i, line := range preview {
- at := placeHeadRows - 1 + i
+ at := chatHeadRows - 1 + i
if at >= len(rows) {
break
}
diff --git a/internal/tui3/tabtitle_test.go b/internal/tui3/tabtitle_test.go
index ab26c304c6..d68cc00bae 100644
--- a/internal/tui3/tabtitle_test.go
+++ b/internal/tui3/tabtitle_test.go
@@ -10,19 +10,19 @@ func TestTabHoverRevealsFullTitleWithoutMovingTargets(t *testing.T) {
a.title = "Shipping the parser with complete unicode support"
a.tabsRow(a.width)
span := tabSpanFor(t, a, a.title)
- a.hot, _ = a.tabHoverAt(span.from, placeTabRow)
+ a.hot, _ = a.tabHoverAt(span.from, tabStripRow)
before := a.tabsRow(a.width)
frame := strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1)
rows := strings.Split(frame, "\n")
- rows[placeTabRow] = before
+ rows[tabStripRow] = before
seal := rule(a.width)
- rows[placeTabRow+1] = seal
+ rows[tabStripRow+1] = seal
frame = strings.Join(rows, "\n")
shown := strings.Split(a.tabTitlePreview(frame), "\n")
- if !strings.Contains(plain(shown[placeHeadRows-1]), a.title) {
+ if !strings.Contains(plain(shown[chatHeadRows-1]), a.title) {
t.Fatal("hover did not put the full title on the head's blank row")
}
- if shown[placeTabRow+1] != seal {
+ if shown[tabStripRow+1] != seal {
t.Fatal("preview covered the rule under the strip")
}
if a.tabsRow(a.width) != before || tabSpanFor(t, a, a.title) != span {
@@ -49,7 +49,7 @@ func TestTabTitlePreviewEndsUnderItsTab(t *testing.T) {
a.title = base
a.tabsRow(a.width)
span := tabSpanFor(t, a, a.title)
- a.hot, _ = a.tabHoverAt(span.from, placeTabRow)
+ a.hot, _ = a.tabHoverAt(span.from, tabStripRow)
hit, ok := a.hotTab()
if !ok {
t.Fatal("the pointer is not over the tab")
@@ -66,7 +66,7 @@ func TestTabTitlePreviewEndsUnderItsTab(t *testing.T) {
t.Fatalf("a longer title moved the tab from %+v to %+v", span, got)
}
rows := strings.Split(strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1), "\n")
- rows[placeTabRow] = row
+ rows[tabStripRow] = row
shown := strings.Split(a.tabTitlePreview(strings.Join(rows, "\n")), "\n")
for i := range shown {
shown[i] = plain(shown[i])
@@ -85,19 +85,19 @@ func TestTabTitlePreviewEndsUnderItsTab(t *testing.T) {
t.Fatalf("the fixture's tab ends at %d, which cannot show both a short and a long name", end)
}
shown := preview(base)
- if at := strings.Index(shown[placeHeadRows-1], base); at+len(base) != end {
- t.Fatalf("a name that fits ends at %d, want the tab's edge %d:\n%q", at+len(base), end, shown[placeHeadRows-1])
+ if at := strings.Index(shown[chatHeadRows-1], base); at+len(base) != end {
+ t.Fatalf("a name that fits ends at %d, want the tab's edge %d:\n%q", at+len(base), end, shown[chatHeadRows-1])
}
long := grown(end - headLabelAt + 10)
shown = preview(long)
- if at := strings.Index(shown[placeHeadRows-1], long); at != headLabelAt {
- t.Fatalf("a name wider than the room left of its tab starts at %d, want the margin %d:\n%q", at, headLabelAt, shown[placeHeadRows-1])
+ if at := strings.Index(shown[chatHeadRows-1], long); at != headLabelAt {
+ t.Fatalf("a name wider than the room left of its tab starts at %d, want the margin %d:\n%q", at, headLabelAt, shown[chatHeadRows-1])
}
wide := grown(a.width + 20)
shown = preview(wide)
- first, second := shown[placeHeadRows-1], shown[placeHeadRows]
+ first, second := shown[chatHeadRows-1], shown[chatHeadRows]
if !strings.HasPrefix(first, strings.Repeat(" ", headLabelAt)+"Shipping") || strings.TrimSpace(second) == "" || !strings.HasPrefix(second, strings.Repeat(" ", headLabelAt)) || second[headLabelAt] == ' ' {
t.Fatalf("a name wider than the row does not wrap from the margin:\n%q\n%q", first, second)
}
diff --git a/internal/tui3/tallyrows_test.go b/internal/tui3/tallyrows_test.go
index 537650e70f..9f0284f936 100644
--- a/internal/tui3/tallyrows_test.go
+++ b/internal/tui3/tallyrows_test.go
@@ -246,21 +246,20 @@ func TestTheManualQuotesBothTasksOpeningHeadingsExactly(t *testing.T) {
}
}
-// A ROSTER FOOTER THE MANUAL QUOTES IS BUILT FROM THE ROSTER'S OWN WORD TABLE.
-// `railGroupWords` moved and four passages went on teaching the words it had
-// dropped, so the next move of that table fails here rather than in front of
-// somebody reading the page.
+// THE GROUP HEADINGS THE MANUAL QUOTES ARE BUILT FROM THE ROSTER'S OWN WORD
+// TABLE. The column's footer of counts is gone and its headings carry the
+// counts now (`Running 4`, `Done 6 ▸`); the words moved once before and four
+// passages went on teaching the words it had dropped, so the next move of the
+// table fails here rather than in front of somebody reading the page.
func TestTheManualQuotesTheRosterGroupsOwnWords(t *testing.T) {
wants := []string{
- itoa(148) + " " + railGroupWords[railParked] + railSep + itoa(12) + " " + railGroupWords[railDone],
- // IN THE FOOTER'S OWN ORDER ([railFootOrder]), which leads with what is
- // HAPPENING and not with what is asking — the manual had it the other way
- // round as well as in the retired words.
- itoa(3) + " " + railGroupWords[railRunning] + railSep + itoa(1) + " " + railGroupWords[railAttention],
+ railHeadWords[railRunning] + " " + itoa(4),
+ railHeadWords[railIdle] + " " + itoa(3) + " " + glyphShut,
+ railHeadWords[railDone] + " " + itoa(6) + " " + glyphShut,
}
for _, want := range wants {
if !manual.Chat().Mentions(want) {
- t.Fatalf("the chat manual does not quote the roster footer %q in the table's own words", want)
+ t.Fatalf("the chat manual does not quote the roster heading %q in the table's own words", want)
}
}
}
diff --git a/internal/tui3/task.go b/internal/tui3/task.go
index a355bdc44b..a79c42a153 100644
--- a/internal/tui3/task.go
+++ b/internal/tui3/task.go
@@ -2148,74 +2148,54 @@ func countdownWord(d time.Duration) string {
// ── the roster ──────────────────────────────────────────────────────────────
//
-// THE RAIL WAS A PRESENCE LIST AND IT IS NOW A ROSTER, because the two stop
-// being the same thing somewhere around the fortieth node. A presence list holds
-// what is alive and forgets everything else, which is exactly right for a
-// session with three nodes in it and useless for a day's work: "where did that
-// task go" is the commonest question a person asks a column of work, and a
-// column that dropped every landed node had already thrown the answer away.
-//
-// So the column keeps EVERY node the session has admitted, and it survives
-// hundreds of them by four mechanisms and no new scroll machinery:
-//
-// - IT IS A FOREST AND NOT FIVE BUCKETS. The column used to open with five
-// state headings — needs you, running, idle, parked, done — and file every
-// node under one of them, which scattered ONE run across four sections: the
-// root under `running`, its finished cuts under `done`, the one that
-// conflicted at the top of the column, and nothing on screen saying they
-// were the same piece of work. A shape is the news an adaptive run makes
-// (internal/orchestrate spawns as it learns), and the state of a single node
-// inside it is a detail. So a family is drawn WHOLE, under its own root, in
-// the order the session admitted it — and a node that belongs to no family
-// is the flat row it has always been.
-//
-// What the headings sorted, the ORDER of the families still says: a family
-// stands where its most urgent member puts it ([app.railUrgency]), so work
-// that is waiting on a person is at the top of the column and a settled run
-// is at the bottom, whole, one row deep.
-// - FOLDING IS THE PERSON'S, PER FAMILY, AND IT HAS A DEFAULT WORTH HAVING. A
-// family with anything live in it opens; a family that is entirely settled —
-// or entirely waiting — opens as one row wearing the worst thing that
-// happened under it and a count of what it is standing for. The map is the
-// person's correction of that default and it sticks ([app.railShut]).
+// THE ROSTER IS THE SIDE COLUMN'S TASKS VIEW (sidecol.go), and it is grouped
+// by what the work is doing, one line a task:
+//
+// Running 4
+// ⠙ rebase onto dev 2m
+// ⠙ price scrape 40s
+// Queued 3 ▸
+// Done 7 ▾
+// ✓ read the law 3m
+//
+// It keeps EVERY node the session has admitted, and it survives dozens of them
+// by three mechanisms and no new scroll machinery:
+//
+// - WHAT IS RUNNING IS ALWAYS OPEN, and everything else folds to one row with
+// a count until the person opens it. Running work is bounded by the slots
+// the machine has; queued, waiting and done work is bounded by nothing,
+// and a plan can admit a hundred nodes in a breath. The fold is the
+// person's, per group, and it is remembered for the session
+// ([app.sideGroupShut]).
+// - WHAT NEEDS THE PERSON IS NOT IN THE LIST AT ALL. A task whose next step
+// is theirs, and a failure they have not opened, are the band's rows at
+// the top of the column (sidecol.go's [app.sideBand]), and a row is never
+// drawn twice.
// - THE COLUMN IS A WINDOW. What shows is a slice of the line list around the
-// focus, taken by [listTop] — the same function the model picker and the two
-// typed lists scroll with, because a second scroller on this surface would be
-// a second set of off-by-ones.
-// - THE FOOTER SAYS THE WHOLE. What the window cannot show — the spend, the
-// weight, the count of every state the forest has folded into a shape — is
-// one dim block at the bottom of the column, in the group vocabulary the
-// headings used to carry.
+// focus, taken by [listTop], the same function the model picker and the two
+// typed lists scroll with, because a second scroller on this surface would
+// be a second set of off-by-ones.
+//
+// ONE LINE A TASK: its state, its name cut with an ellipsis, and how long it
+// has been at it in the muted ink at the right. What a two-line row used to say
+// under the name (the call it is in, what it waits on, the branch it kept) is
+// on the hint line under the pointer, with the whole name ([app.sideHoverWords]
+// reads [app.railUnder]).
//
// THE KEYBOARD IS ASKED FOR, NEVER TAKEN (alt+t, esc to give it back). The
// draft is this surface's rest state and a map that stole keys from it would
-// make typing a thing you check before you do — see the marker law at
+// make typing a thing you check before you do: see the marker law at
// [app.railRows].
//
-// AND THE COLUMN WIDENS ON DEMAND (w). A tree spends three cells a level, and
-// three levels of indent inside thirty columns is a list of first words. The
-// wide tier is a third width the person asks for and never a width the surface
-// takes: it is charged against the conversation like the other two, and the one
-// piece of chrome that says it exists is a hint that appears only while a title
-// is actually being cut by its own indent ([app.railFootRows]).
+// AND THE COLUMN WIDENS ON DEMAND (alt+w), by [railWideGain], charged against
+// the conversation like the rest of it.
const (
- // railCols is the whole charge a full rail makes on the frame: the seam,
- // its gutter, and the column the nodes are drawn in.
- railCols = 30
- // railSlimCols is the charge under a narrower frame: the same column,
- // tighter.
- railSlimCols = 24
- // railWideCols is the charge of a column a person has ASKED to widen (w,
- // [app.railKey]). Forty-six is thirty plus five levels of indent, which is
- // deeper than any run this surface has drawn — the point of the tier is that
- // a tree stops eating its own names, not that it can nest forever. It is
- // charged against the conversation exactly like the other two, which is why
- // it is a key and not a default.
- railWideCols = 46
- // railWideGain is what taking that tier lends a row, and it is the whole of
- // what the widen hint promises ([app.railEntryRows]).
- railWideGain = railWideCols - railCols
+ // railWideGain is what a column the person has ASKED to widen (alt+w,
+ // [app.railKey]) takes on top of the one the frame lends it
+ // ([sideColsFor]). It is charged against the conversation exactly like
+ // the rest of the column, which is why it is a key and not a default.
+ railWideGain = 16
// railFloor is the frame a FULL rail takes. Under it the conversation
// would be reading at ninety columns to keep a column of titles on screen.
railFloor = 120
@@ -2234,15 +2214,9 @@ const (
railMarkASCII = "> "
)
-// The disclosure marks a family root wears, and their ASCII stand-ins: closed
-// points at what it is hiding, open points down at what it showed.
-//
-// THEY ARE DRAWN UNDER THE POINTER AND NOWHERE ELSE (see [app.railEntryRows]).
-// A triangle on every root at rest is a column of widgets; a triangle that
-// appears in the glyph cell the moment the pointer is over the row is the
-// affordance arriving exactly when there is a hand to use it. What a FOLDED
-// family shows at rest is its count instead — there is something hidden, and a
-// count is the one thing a person cannot discover by hovering.
+// The disclosure marks a folded group and a work thread wear, and their ASCII
+// stand-ins: closed points at what it is hiding, open points down at what it
+// showed.
const (
glyphShut = "▸"
glyphShutASCII = ">"
@@ -2273,8 +2247,8 @@ const (
// What the keyboard offers while the roster holds it.
const (
- // railHoldHint is what the legend's hint slot says while the roster has the
- // keyboard (render.go's [app.hintWord]) — the keys [app.railKey] takes,
+ // railHoldKeys is what the legend's hint slot says while the roster has the
+ // keyboard (render.go's [app.hintWord]), the keys [app.railKey] takes,
// quoted from the handler rather than authored twice.
//
// It is split at the dismiss key because ONE OF THOSE KEYS IS CONDITIONAL:
@@ -2291,8 +2265,10 @@ const (
// person who pressed `x` over an empty box and watched a letter appear — the
// key works the moment the roster is being driven, and this line is where that
// is said out loud (#892).
- railHoldKeys = "↑↓ move · →← tree · enter open · " + stopRaiseKey + " stop · " + railWidenChord + " wide"
- railHoldHint = railHoldKeys + " · esc"
+ railHoldKeys = "↑↓ move · enter open · " + stopRaiseKey + " stop · " + railWidenChord + " wide"
+ // sideSwitchKeys leads that line in a chat in a team, where the column has
+ // two words and ← and → move between them (sidecol.go).
+ sideSwitchKeys = "←→ tasks/traffic"
// The footer names both answers the handle can give. A bare "w" in a column
// of counts is a keystroke nobody would risk pressing, and a handle whose
// return trip is not named is only half an affordance.
@@ -2323,43 +2299,28 @@ const (
// where Option is not meta, which is exactly what [chordDeadKeys] is a table of.
const railHoldChord = chordAltWord + "t"
-// The column's own door, and the two lines that name it.
+// railStowKey is the column's older key, and it still puts the column away
+// and brings it back. The header names [sideHideKey] (alt+l), which does the
+// same and also lays the column over the body on a frame too narrow for it;
+// the legend's hint slot names it while the column is away
+// ([app.sideBackHint]).
//
-// THE KEY IS FREE AND IT IS THE LAST FREE ONE WORTH SPENDING. alt+t is the
-// roster's ([app.railKey]) and every other letter this surface could reach for
-// is a chord the message box already answers — ctrl+a, ctrl+e, ctrl+b, ctrl+f,
-// and ctrl+u are the readline edits a person types without looking, and
-// taking one of those for a sidebar would be a keystroke that deleted a word the
-// first time somebody meant it. ctrl+g is readline's abort, which this surface
-// has always spelled esc, so nothing is lost by binding it.
-const (
- railStowKey = "ctrl+g"
- // railStowHint is the last line of the column, and unlike the widen offer
- // above it, it is drawn WHENEVER THE COLUMN IS. The widen tier is contextual —
- // a cut title earns the offer — but the way out of a column is the one thing a
- // person cannot discover by hovering, cannot reach from the keyboard they have
- // not been handed, and will look for at exactly the moment they have decided
- // they are done with it. One dim row at the bottom is the whole cost.
- railStowHint = railStowKey + " hide"
- // railBackHint is the other half, and it lives in the legend's hint slot while
- // the column is away and this session has run anything (render.go's
- // [app.hintWord]). It is the shape of that slot's other lines: the key, then
- // what it reaches.
- railBackHint = railStowKey + " tasks"
-)
+// ctrl+g is readline's abort, which this surface has always spelled esc, so
+// nothing was lost by binding it.
+const railStowKey = "ctrl+g"
// ── THE EDGE A CLOSED COLUMN LEAVES BEHIND ──────────────────────────────────
//
// ctrl+g USED TO MAKE THE COLUMN VANISH WITHOUT A TRACE, and a thing with no
// trace is a thing a person cannot get back. The two ways home were the chord
// itself — which is knowledge, not an affordance, and the person who pressed it
-// by accident never had that knowledge — and the legend's [railBackHint], which
-// is one line of five words in a slot that carries something else most of the
+// by accident never had that knowledge, and the legend's hint slot, which is
+// one line of five words in a slot that carries something else most of the
// time and says nothing at all in a session that has run no work.
//
// So a closed column leaves an EDGE: [railGripCols] columns down the right of
// the frame, near-silent, with a handle at the middle of it, and the whole strip
-// is a door. Pressing anywhere on it is exactly ctrl+g ([app.railStow] takes
+// is a door. Pressing anywhere on it is exactly alt+l ([app.railStow] takes
// both).
//
// THREE THINGS KEEP IT HONEST:
@@ -2370,7 +2331,9 @@ const (
// - IT WHISPERS ONLY WHAT IS TRUE. One cell above the handle carries the state
// of the work while there is work in a state worth carrying — something
// running, or something waiting on a person — and NOTHING otherwise, which
-// is the emptiness law in the smallest space this surface has.
+// is the emptiness law in the smallest space this surface has. In a chat in
+// a team, the cells under it count what came in on the Traffic since the
+// person last read it, while anything did.
// - IT IS NOT THERE WHEN A COLUMN COULD NOT BE. Under [railSlimFloor] the
// frame lends no columns to anything, and an edge onto a column that cannot
// stand would be a door onto a room that does not exist.
@@ -2393,16 +2356,6 @@ const (
// louder still and belong to the work rather than to the door.
railGripGlyph = "❮"
railGripGlyphASCII = "<"
- // railGripOpenGlyph is the SAME control in its other state: the chevron the
- // column wears while it stands, pointing right because that is the way it
- // goes. It rides the footer's own door line ([railStowHint]) rather than the
- // seam, which is already the width handle and may not mean two things.
- //
- // SO THE RIGHT EDGE ALWAYS CARRIES ONE CHEVRON — `❯` to close while the column
- // is up, `❮` to open while it is away — and the pointer can go round the whole
- // cycle without ever being told a chord.
- railGripOpenGlyph = "❯"
- railGripOpenGlyphASCII = ">"
)
// railGroup is what a node is DOING, which is the only thing the roster sorts
@@ -2508,133 +2461,56 @@ func (a *app) taskAwaitsPerson(node *taskNode) bool {
return status.Presence == session.TaskPresenceNeedsLook && status.State == session.TaskRunning
}
-// taskParentDeciding reports whether the node above this one is STILL WORKING,
-// and is therefore the one being asked about work under it that nobody could
-// check.
-//
-// IT IS A FOLD AND NOT A MUTE, and the difference is the whole of #268. The
-// engine routes a sub-task's landing note to its parent node's own agent, which
-// has the `tasks` tool and the diff and every reason to answer it (session's
-// deliverTaskNote) — so while the parent lives, the top of the family is what a
-// person should be reading first. That is an argument about LOUDNESS and it was
-// once read as an argument about presence: the child was filed under `done` and
-// drew no card, so a nested question could expire with nobody able to see it.
-// What this answers now is only [app.railGlyphRank]'s question — how loud —
-// while [app.railGroupOf] keeps the demand where it belongs.
-//
-// It is asked about exactly one thing — is the parent unsettled — and everything
-// else answers "no": a node with no parent is a root and is the person's, a node
-// whose parent this session has never heard of has nobody above it that could
-// decide, and a node whose parent has landed has been orphaned and is the
-// person's again.
-//
-// It is deliberately not a walk up the whole family. The immediate parent is the
-// only node that is ever handed this child's news, so a grandparent's state says
-// nothing about whether anybody is reading it.
-func (a *app) taskParentDeciding(node *taskNode) bool {
- if node == nil || node.parent == "" {
- return false
- }
- for _, up := range a.tasks {
- if stripKey(up) != node.parent {
- continue
- }
- return up.state == session.TaskRunning || up.state == session.TaskQueued
- }
- return false
-}
-
-// railShut reports whether a family is drawn as its root alone.
-//
-// THE DEFAULT IS THE DESIGN AND THE MAP IS THE PERSON'S CORRECTION OF IT, which
-// is why this is not a plain bool per node: a family nobody has touched must
-// follow the default even as the work under it moves. A family with anything
-// live in it — running, waiting on a person, or waiting for a slot — is open,
-// because that is the shape somebody is watching; a family that has entirely
-// settled, or that is entirely waiting behind other work, is one row with a count
-// on it.
-func (a *app) railShut(node *taskNode) bool {
- if open, said := a.railOpen[node.id]; said {
- return !open
- }
- return !a.railKinLive(node)
-}
-
-// railTwigShut is the same question asked where the family is already grown, and
-// it is the one the layout uses: [app.railShut] has to build the subtree back up
-// to answer, which down a walk is the family regrown once a row.
-func (a *app) railTwigShut(t *railTwig) bool {
- if open, said := a.railOpen[t.node.id]; said {
- return !open
- }
- return !a.railTwigLive(t)
-}
-
-// railSetOpen folds one family open or closed.
-func (a *app) railSetOpen(node *taskNode, open bool) {
- if node == nil {
- return
- }
- if a.railOpen == nil {
- a.railOpen = map[uint64]bool{}
- }
- if a.railShut(node) == !open {
- return
- }
- a.railOpen[node.id] = open
- a.touch()
-}
+// railHeadWords is the word each group's heading wears in the column, the
+// same vocabulary as [railGroupWords] said as a heading.
+var railHeadWords = [railGroupCount]string{"Needs you", "Running", "Queued", "Waiting", "Done"}
-// railToggle is what a press on a root's glyph cell does.
-func (a *app) railToggle(node *taskNode) { a.railSetOpen(node, a.railShut(node)) }
+// railListOrder is the order the column's groups stand in. The work waiting
+// on a person is not among them: it is the band's (sidecol.go).
+var railListOrder = [...]railGroup{railRunning, railIdle, railParked, railDone}
-// railEntry is one navigable row of the roster: a node, and where in its family
-// it hangs.
+// railEntry is one navigable row of the roster: a group's heading, or a node
+// under it.
type railEntry struct {
node *taskNode
- // stems is the ancestry as the connectors need it: one entry per level, true
- // where that level's node still has siblings to come. Its length is the
- // node's depth, so a root's is empty and a root has no connector.
- stems []bool
- // root says this node HEADS a family — it has children, so it is the one row
- // in the family that folds. A node that belongs to no family is not a root:
- // it is the flat row this column has always drawn.
- root bool
- // folded says this root is standing for its whole subtree, hidden is how many
- // nodes it is standing for, and worst is the node whose state that one row
- // wears. All three are zero on every other row, and all three are settled at
- // WALK TIME because the walk has the subtree in its hand — asking the same
- // questions again at paint time would be re-growing the family once a row.
- folded bool
- hidden int
- worst *taskNode
+ // group is the group the row heads or sits in.
+ group railGroup
+ // head says this row is the group's heading, count how many nodes the
+ // group holds, and shut that the group is folded to it.
+ head bool
+ count int
+ shut bool
}
// railSpot names a row by IDENTITY rather than by index, and it is what the
// focus is stored as.
//
-// An index would be a cursor that jumps: work landing reorders the families, a
-// fold takes a subtree out from under it, and both happen while nobody is
-// touching the keyboard. An id survives all of it, and when the node it names is
-// genuinely gone the walk clamps rather than teleporting.
-//
-// AN ID IS ENOUGH BECAUSE EVERY ROW OF THIS COLUMN IS THIS CONVERSATION'S. It
-// used to carry a second field for the project's record rows, which cannot be
-// named by id at all — ids restart with every conversation (task_index.go says so
-// on [session.TaskIndexEntry.ID]) — and those rows are the task page's now.
+// An index would be a cursor that jumps: work landing moves a node from one
+// group to another, a fold takes a group's rows out from under it, and both
+// happen while nobody is touching the keyboard. An id survives all of it, and
+// when the node it names is genuinely gone the walk clamps rather than
+// teleporting.
type railSpot struct {
id uint64
+ // group is a group's heading, as its railGroup plus one.
+ group int
// jobs is the jobs section's label, and job is a row of that section. They
// live here rather than in a second cursor because this column has one
// walk and one enter, and a second map would be a second idea of where
// the keyboard is.
jobs bool
job int
+ // key is one of the side column's own rows: a band item or a row of the
+ // Traffic (sidecol.go's [sideRow.key]).
+ key string
}
func (s railSpot) onJobs() bool { return s.jobs || s.job != 0 }
func railSpotOf(e railEntry) railSpot {
+ if e.head {
+ return railSpot{group: int(e.group) + 1}
+ }
if e.node == nil {
return railSpot{}
}
@@ -2698,31 +2574,13 @@ func railFinalOrder(nodes []*taskNode) []*taskNode {
return out
}
-// ── THE FOREST ──────────────────────────────────────────────────────────────
-
-// railTwig is one node of a family as the roster holds it: the node and the
-// children the session admitted under it.
-//
-// It is a shape of its own rather than a flat list of (node, depth) pairs
-// because every question the column asks is about a SUBTREE — is anything under
-// this live, what is the worst thing that happened in it, how many rows is it
-// standing for — and a depth-tagged list answers those by scanning forward for
-// the next row at the same depth, which is a tree with its structure taken out
-// and then guessed back.
-type railTwig struct {
- node *taskNode
- kids []*railTwig
-}
-
// railKin buckets every node this session has admitted by its parent's key, and
// indexes them all by their own.
//
// It walks [app.taskOrder], so a parent's children come out in the order the
-// session met them — the one order a family is allowed to use, because any other
-// one moves a row a person is watching for a reason they cannot see. The
-// alphabet is the parent seam's ([taskNode.ParentID] and [stripKey],
-// taskstrip.go): an orchestrate node id is a string, and "" is an honest
-// "nobody spawned this".
+// session met them. The alphabet is the parent seam's ([taskNode.ParentID] and
+// [stripKey], taskstrip.go): an orchestrate node id is a string, and "" is an
+// honest "nobody spawned this".
func (a *app) railKin() (kids map[string][]*taskNode, byKey map[string]*taskNode) {
byKey = make(map[string]*taskNode, len(a.taskOrder))
for _, id := range a.taskOrder {
@@ -2747,172 +2605,56 @@ func (a *app) railKin() (kids map[string][]*taskNode, byKey map[string]*taskNode
return kids, byKey
}
-// railRootOf walks up to the head of a node's family.
-//
-// The visited set is not defensive tidiness: the parent is written by an adapter
-// this package does not own ([taskNode.ParentID]), and a cycle in it would be a
-// frame that never returns rather than a frame that looks wrong.
-func railRootOf(node *taskNode, byKey map[string]*taskNode) *taskNode {
- seen := map[string]bool{}
- for {
- key := stripKey(node)
- if seen[key] {
- return node
- }
- seen[key] = true
- up := byKey[node.ParentID()]
- if up == nil {
- return node
- }
- node = up
- }
-}
-
-// railGrow builds one family, depth first, in the order the session met it. The
-// visited set carries the same law [railRootOf] states.
-func railGrow(node *taskNode, kids map[string][]*taskNode, seen map[string]bool) *railTwig {
- key := stripKey(node)
- twig := &railTwig{node: node}
- if seen[key] {
- return twig
- }
- seen[key] = true
- for _, kid := range kids[key] {
- twig.kids = append(twig.kids, railGrow(kid, kids, seen))
- }
- return twig
-}
-
-// count is every node under this twig, its own row not included — what a folded
-// root has to say it is standing for.
-func (t *railTwig) count() int {
- n := 0
- for _, kid := range t.kids {
- n += 1 + kid.count()
- }
- return n
+// railListed reports whether a node is a row of the list rather than of the
+// band: work waiting on the person, and a failure they have not opened, are
+// the band's (sidecol.go).
+func (a *app) railListed(node *taskNode, g railGroup) bool {
+ return g != railAttention && !(g == railDone && a.sideBandFails(node))
}
-// railForest preserves creation order. State changes update each row without
-// moving a task away from the place where the person first saw it.
-func (a *app) railForest() []*railTwig {
- kids, byKey := a.railKin()
- seen, grown := map[string]bool{}, map[string]bool{}
- var trees []*railTwig
- for _, id := range a.taskOrder {
- node := a.tasks[id]
- if node == nil {
+// railEntries is the roster's row model: each group that has anything in it,
+// its heading, and its nodes under it unless it is folded.
+func (a *app) railEntries() []railEntry {
+ members := a.railMembers()
+ out := make([]railEntry, 0, len(a.taskOrder)+len(railListOrder))
+ for _, g := range railListOrder {
+ n := 0
+ for _, node := range members[g] {
+ if a.railListed(node, g) {
+ n++
+ }
+ }
+ if n == 0 {
continue
}
- root := railRootOf(node, byKey)
- key := stripKey(root)
- if seen[key] {
+ shut := a.sideGroupShut(g)
+ out = append(out, railEntry{group: g, head: true, count: n, shut: shut})
+ if shut {
continue
}
- seen[key] = true
- trees = append(trees, railGrow(root, kids, grown))
- }
- return trees
-}
-
-// railKinLive reports whether anything in this node's family is still moving or
-// still waiting on somebody: it is the fold's default, and it is asked of the
-// root ([app.railShut]).
-func (a *app) railKinLive(node *taskNode) bool {
- kids, _ := a.railKin()
- return a.railTwigLive(railGrow(node, kids, map[string]bool{}))
-}
-
-func (a *app) railTwigLive(t *railTwig) bool {
- switch a.railGroupOf(t.node) {
- case railAttention, railRunning, railIdle:
- return true
- }
- if t.node.Paused() {
- return true
- }
- for _, kid := range t.kids {
- if a.railTwigLive(kid) {
- return true
+ for _, node := range members[g] {
+ if a.railListed(node, g) {
+ out = append(out, railEntry{node: node, group: g})
+ }
}
}
- return false
-}
-
-// railEntries is the roster's row model: every family, whole, under its own
-// root, with the folded ones standing at one row each.
-//
-// THE FOREST IS THIS COLUMN'S WHOLE ACCOUNT OF WHO IS WORKING, and nothing is
-// hung under it from the engine's live tree. It used to be: a second, smaller
-// list of the same hands was attached beneath each row from
-// [session.Agent.WorkingNow], keyed by the row's bare id — and it never once
-// drew, because that door spells a worker `task:7`, `run:2` or `run:2/plan`
-// (session's work_tree.go) and this column was asking it for "7". Fixing the
-// spelling would not have fixed the surface, it would have started the double
-// draw the broken key had been hiding: every worker in that tree ALREADY has a
-// row of its own here. A graph node announces itself with the id of whatever
-// spawned it (session's task_run.go) and an adaptive run registers a row for
-// itself and one per planned node (its family seam), so both halves of the
-// engine's tree arrive here as ordinary notices and [app.railForest] hangs them
-// on their parents. The preview could only ever have restated them, in one mark
-// and a name, under the fuller row that was already there.
-//
-// So the ownership rule, stated once: A WORKER IS DRAWN BY THE FAMILY THAT
-// OWNS IT, on the row its own notice minted.
-func (a *app) railEntries() []railEntry {
- out := make([]railEntry, 0, len(a.taskOrder))
- for _, tree := range a.railForest() {
- out = a.railWalk(out, tree, nil)
- }
return out
}
-// railWalk lays one family out, depth first.
-func (a *app) railWalk(out []railEntry, t *railTwig, stems []bool) []railEntry {
- e := railEntry{node: t.node, stems: stems, root: len(t.kids) > 0}
- if e.root && a.railTwigShut(t) {
- e.folded, e.hidden, e.worst = true, t.count(), a.railWorst(t)
- return append(out, e)
- }
- out = append(out, e)
- for i, kid := range t.kids {
- // The stack is COPIED down rather than appended to in place: one backing
- // array shared between two siblings is the second sibling drawing the
- // first one's stems.
- next := make([]bool, len(stems), len(stems)+1)
- copy(next, stems)
- out = a.railWalk(out, kid, append(next, i < len(t.kids)-1))
- }
- return out
-}
-
-// railColsFor is how wide the rail is at a frame width: full from railFloor,
-// slim down to railSlimFloor, gone under that.
-func railColsFor(width int) int {
- switch {
- case width >= railFloor:
- return railCols
- case width >= railSlimFloor:
- return railSlimCols
- }
- return 0
-}
-
-// railColumns is [railColsFor] with the person's own answer folded in: a column
-// somebody widened (w) takes [railWideCols], and only where the frame was
-// already lending a full one. The floors are the frame's and the tier is the
-// person's — a terminal that cannot afford thirty columns cannot afford
-// forty-six either.
+// railColumns is [sideColsFor] with the person's own answer folded in: a
+// column somebody widened (alt+w) takes [railWideGain] more, and only where
+// the frame was already lending a full one and the conversation keeps its
+// floor. The floors are the frame's and the tier is the person's.
+//
+// IT DOES NOT ASK WHAT THE COLUMN SHOWS. Which word is in front, what is
+// folded and what the band holds are all drawn inside these columns, so none
+// of them moves the conversation (sidecol.go).
func (a *app) railColumns(width int) int {
- // THE MANAGER'S TASKS STAND IN THE TRAFFIC'S COLUMN, at its width, so
- // swapping the two moves nothing in the conversation (teamrail.go).
- if a.trafficOn() {
- return trafficColsFor(width)
- }
- if a.railWide && width >= railFloor {
- return railWideCols
+ cols := sideColsFor(width)
+ if cols > 0 && a.railWide && width >= railFloor {
+ cols = min(cols+railWideGain, width-sideBodyFloor)
}
- return railColsFor(width)
+ return cols
}
// railCanWiden reports whether the third tier is ON OFFER at this frame — the
@@ -2950,13 +2692,12 @@ func (a *app) railCanWiden() bool {
// longer does (taskview.go says why); what a directory with a history behind it
// gets instead is one dim line at the foot of the column naming the page that
// holds it ([taskSheetPastHint]).
+//
+// AND IT IS THE SAME COLUMN IN EVERY CHAT. A chat in a team has the team's
+// Traffic in it as its second word (sidecol.go), and that changes what is
+// drawn inside the column and never whether it stands or how wide it is.
func (a *app) railShowing() bool {
- // WITH THE MANAGER IN FRONT THE COLUMN IS THE TRAFFIC'S, and the tasks are
- // on it only when the person chose them there (teamrail.go).
- if a.trafficOn() {
- return a.trafficTasksShowing() && !a.railQuiet()
- }
- if a.railAway || a.railQuiet() || a.trafficHoldsRail() {
+ if a.sideAway() || a.railQuiet() {
return false
}
width, _ := a.size()
@@ -3011,7 +2752,12 @@ func (a *app) railQuiet() bool {
// A JOB COUNTS. It is this conversation's work as much as a task is, and a
// session that has only started a server still has a row the keyboard can
// stand on (jobsection.go).
-func (a *app) railAvail() bool { return len(a.taskOrder) > 0 || len(a.jobs) > 0 }
+//
+// AND SO DOES A TEAM. A chat in a team has the team's Traffic on this column
+// (sidecol.go), which is rows to stand on whether or not it has run anything.
+func (a *app) railAvail() bool {
+ return len(a.taskOrder) > 0 || len(a.jobs) > 0 || a.sideKind() != sideKindPlain
+}
// railFull reports whether the roster is drawn OVER the body rather than beside
// it — the narrow frame's answer to the same key.
@@ -3029,7 +2775,7 @@ func (a *app) railAvail() bool { return len(a.taskOrder) > 0 || len(a.jobs) > 0
// frame does with the request is a question about its width. One state cannot
// disagree with itself about whether the roster is up.
func (a *app) railFull() bool {
- if !a.railHold || !a.railAvail() || a.railAway || a.railQuiet() {
+ if !a.railHold || !a.railAvail() || a.sideAway() || a.railQuiet() {
return false
}
width, _ := a.size()
@@ -3054,18 +2800,15 @@ func (a *app) railRoom() int {
// column to put away.
//
// It is deliberately the exact complement of [app.railShowing] at every width
-// that lends columns at all — one of the two is true whenever [railColsFor] is
+// that lends columns at all: one of the two is true whenever [sideColsFor] is
// positive — so the right-hand strip of the frame always belongs to the roster
// in one of its two shapes, and never to nobody.
func (a *app) railStowed() bool {
- // AND WITH THE MANAGER IN FRONT THERE IS NO EDGE AT ALL: the right is the
- // Traffic's, and the person's own answer in railAway is kept for the next
- // conversation (teamrail.go).
- if a.trafficOn() || !a.railAway || a.railQuiet() {
+ if !a.sideAway() || a.railQuiet() {
return false
}
width, _ := a.size()
- return railColsFor(width) > 0
+ return sideColsFor(width) > 0
}
// railWidth is what the rail costs the conversation, in columns: its own where
@@ -3130,6 +2873,17 @@ func (a *app) railGripRows(height int) []string {
if mark, hue := a.railGripState(); mark != "" && height > 1 {
out[height/2-1] = " " + hue(mark)
}
+ // AND TWO CELLS BELOW IT, HOW MUCH CAME IN ON THE TRAFFIC since the person
+ // last had it in front of them, in a chat in a team, while anything did.
+ if t, kind, handle := a.sideTeam(); kind != sideKindPlain && height > 2 {
+ if _, fresh := a.sideTrafficCount(t, kind, handle); fresh > 0 {
+ count := itoa(min(fresh, 99))
+ if len(count) == 1 {
+ count = " " + count
+ }
+ out[height/2+1] = a.pal.dim(count)
+ }
+ }
return out
}
@@ -3145,10 +2899,17 @@ func (a *app) railGripRows(height int) []string {
// program's existing vocabulary for "wants you" and "moving" said in a single
// column. The rail's own glyphs are a spinner and a tree, and neither is a thing
// that fits in one static cell.
+//
+// THE TRAFFIC'S QUESTIONS COUNT AS A PERSON'S, because they are: whatever the
+// band would carry in amber is what this cell says in amber (sidecol.go).
func (a *app) railGripState() (string, func(string) string) {
members := a.railMembers()
+ asked := false
+ if t, kind, handle := a.sideTeam(); kind != sideKindPlain {
+ asked = len(a.sideAsks(t, kind, handle)) > 0
+ }
switch {
- case len(members[railAttention]) > 0:
+ case len(members[railAttention]) > 0 || asked:
return a.linearMark(homeAskGlyph, homeAskASCII), a.pal.ask
case len(members[railRunning]) > 0:
return a.linearMark(homeLiveGlyph, homeLiveASCII), a.pal.accent
@@ -3172,13 +2933,9 @@ func (a *app) railGripAt(x, y int) bool {
// question about the transcript resolves through — what the frame draws, where
// the wheel lands, which row a click hit. A rail the layout knew about and the
// hit-testing did not would deliver clicks to rows wrapped at another width.
-//
-// THE TRAFFIC RAIL IS CHARGED HERE TOO, while the manager is in front
-// (teamtraffic.go): it stands to the right of this column, and the conversation
-// is what gives it the columns.
func (a *app) bodyWidth() int {
width, _ := a.size()
- if body := width - a.railWidth() - a.trafficWidth(); body > 0 {
+ if body := width - a.railWidth(); body > 0 {
return body
}
return width
@@ -3200,36 +2957,22 @@ type railLine struct {
plan string
// entry indexes [app.railEntries], or -1 for the padding and the footer.
entry int
- // head says this is the entry's FIRST line, which is the one a marker goes
- // on: a two-line node with two markers would read as two nodes.
+ // head says this is the entry's line a marker goes on. Every entry is one
+ // line now, so it is every entry's line; it is kept for the run's own rows
+ // spliced among them ([app.railDrawnView]), which are not.
head bool
- // glyph is the row's FOLD CELL in the column's own coordinates, and badge the
- // ▸ +N a folded root wears. They are written at LAYOUT and read by the click,
- // which is the bargain the strip's chips make (taskstrip.go): the geometry is
- // recorded where it is decided, because a hit-test that recomputed it would
- // be measuring a row the frame has not drawn.
- //
- // glyph IS EMPTY ON EVERY FRAME WHERE THAT CELL IS NOT A CONTROL, which is
- // most of them: the cell holds the row's state until the pointer is on a row
- // that can fold, and only then does it become ▾ or ▸ ([app.railLead] says
- // why the press may not work this out for itself). An empty span is a span
- // that holds no column, so the cells fall to the row and the row is the
- // node's door.
- glyph hudSpan
- badge hudSpan
+ // side is one of the side column's own rows: its header, a band item, or a
+ // row of the Traffic (sidecol.go), with the doors on it and what a press
+ // does. nil on every roster line.
+ side *sideRow
// hint says this line is the footer's widen offer, which is pressable and
// belongs to no entry.
hint bool
- // stow marks the pinned first line, the column's own door
- // ([railStowHint]). It is a second flag rather than a kind on the line above
- // because both can be drawn at once and a press has to tell them apart: one
- // changes the column's width and the other takes it off the frame.
- stow bool
// more says this line is the footer's door onto the TASK PAGE
- // ([taskSheetMoreHint] or [taskSheetPastHint], taskview.go) — a third flag for
- // the second one's reason: all three can be drawn at once, and a press has to
- // know whether it was asked to widen the column, to hide it, or to leave it
- // for a page that holds work this session never ran.
+ // ([taskSheetPastHint], taskview.go), a flag of its
+ // own because all of the footer's lines can be drawn at once, and a press has
+ // to know whether it was asked to widen the column or to leave it for a page
+ // that holds work this session never ran.
more bool
// keeping says this line is the footer's standing count, whose door is
// /standing (standdoor.go). It is a fourth flag for the third one's reason:
@@ -3272,16 +3015,9 @@ type railLine struct {
// railLines renders every entry, in order. It is the unwindowed list, and the
// window is taken out of it by [app.railView].
func (a *app) railLines(entries []railEntry, width int) []railLine {
- out := make([]railLine, 0, len(entries)+len(entries)/2)
+ out := make([]railLine, 0, len(entries))
for i := range entries {
- rows, glyph, badge := a.railEntryRows(entries[i], width)
- for j, text := range rows {
- line := railLine{text: text, entry: i, head: j == 0}
- if j == 0 {
- line.glyph, line.badge = glyph, badge
- }
- out = append(out, line)
- }
+ out = append(out, railLine{text: a.railEntryRow(entries[i], width), entry: i, head: true})
}
return out
}
@@ -3314,22 +3050,34 @@ func (a *app) railView(height int) ([]railLine, int) {
if height <= 0 || !a.railStanding() {
return nil, -1
}
- // The hide control belongs to the sidebar, outside every scrolling list
- // and task panel. Neither a new task nor a deeper page may displace it.
- var head []railLine
- if a.trafficOn() && !a.railFull() {
- // THE TRAFFIC'S HEADER STAYS ON ITS COLUMN with the tasks laid in it:
- // the whole line is the way back to the traffic (teamrail.go).
- head = append(head, railLine{text: a.trafficTasksHead(a.railRoom()), entry: -1, stow: true})
- } else if !a.railFull() && ansi.StringWidth(a.railDoorHint())+2 <= a.railRoom() {
- head = append(head, railLine{text: a.railDoorLine(), entry: -1, stow: true})
+ // THE HEADER AND THE BAND BELONG TO THE COLUMN, outside every scrolling
+ // list and task panel, so neither a new task, a deeper page nor the other
+ // word may displace them (sidecol.go).
+ room := a.railRoom()
+ head := a.sideHead(room)
+ if len(head) > height {
+ head = head[:height]
}
if a.roomOpen() && len(head) < height {
word := a.icon(tokens.GScopeUp) + " " + railMainWord
- head = append(head, railLine{text: a.pal.accent(fit(word, a.railRoom())), entry: -1, roomAction: railMainAction})
+ head = append(head, railLine{text: a.pal.accent(fit(word, room)), entry: -1, roomAction: railMainAction})
+ }
+ a.side.pinned = len(head)
+ if a.sideView() == sideTraffic {
+ rows, _ := a.sideTrafficView(height - len(head))
+ out := make([]railLine, 0, height)
+ out = append(append(out, head...), rows...)
+ for len(out) < height {
+ out = append(out, railLine{entry: -1})
+ }
+ a.sideRemember(out)
+ return out, -1
}
+ a.side.up = ""
rows, focus := a.railContentView(height - len(head))
- return append(head, rows...), focus
+ out := append(head, rows...)
+ a.sideRemember(out)
+ return out, focus
}
func (a *app) railContentView(height int) ([]railLine, int) {
@@ -3343,15 +3091,9 @@ func (a *app) railContentView(height int) ([]railLine, int) {
entries := a.railEntries()
focus := a.railFocusIndex(entries)
- // THE LINES ARE LAID OUT BEFORE THE FOOTER IS ASKED FOR, which is the one
- // ordering this function is not free to choose: whether the footer offers the
- // wide tier is a fact about what the rows did to their titles, and a footer
- // built first would be answering it about the frame before this one
- // ([app.railFootRows]).
- a.railCramped = false
- // A SECTION EARNS ITS LABEL FROM A REAL ROW. The typeable doors remain when
- // nothing exists, while the emptiness law spends no pixels naming absence.
- head := a.marginHead(room, len(entries) > 0)
+ // THE GROUPS STAND STRAIGHT UNDER THE HEADER, which already names the
+ // column and counts its work (sidecol.go).
+ var head []railLine
lines := a.railLines(entries, room)
foot, marks := a.railFootRows(room, height)
body := height - len(foot)
@@ -3439,62 +3181,6 @@ func (a *app) railContentView(height int) ([]railLine, int) {
return out, focus
}
-// railMovingHead is how many lines at the top of the list belong to work that is
-// MOVING — running right now, or standing still waiting on a person.
-//
-// IT IS THOSE TWO GROUPS AND NOT EVERY LIVE ONE, which is the difference between
-// a head that stays small and a head that eats the column. What is running at
-// once is bounded by the slots the executor has, and what is waiting on a person
-// is bounded by the person; what is QUEUED is bounded by nothing at all — one
-// plan can admit a hundred nodes in a breath — so a head that pinned the idle
-// group would pin the whole window the first time somebody started an adaptive
-// run. Queued work is a promise and promises can wait their turn in a scroll.
-//
-// It is the count through the LAST such line rather than the length of an
-// unbroken run, because a family is drawn whole: a settled child sitting between
-// two running siblings is part of the live shape, and a head that stopped at it
-// would pin half a tree. Families with nothing live in them sort below every
-// family that has ([app.railForest]), so what this measures is the moving region
-// and not the whole column.
-//
-// A FOLDED ROOT COUNTS FOR WHAT IT IS HIDING. One row standing for a subtree
-// with something running in it is that running work as far as this column is
-// concerned, which is the same fact its glyph already carries ([app.railWorst]).
-func (a *app) railMovingHead(lines []railLine, entries []railEntry) int {
- head := 0
- for i, line := range lines {
- if line.entry < 0 || line.entry >= len(entries) {
- continue
- }
- if a.railEntryMoving(entries[line.entry]) {
- head = i + 1
- }
- }
- return head
-}
-
-// railEntryMoving reports whether one drawn row is work that is running or
-// waiting on a person, its hidden descendants included.
-func (a *app) railEntryMoving(e railEntry) bool {
- if e.node == nil {
- return false
- }
- nodes := []*taskNode{e.node}
- if e.folded && e.worst != nil {
- nodes = append(nodes, e.worst)
- }
- for _, node := range nodes {
- switch a.railGroupOf(node) {
- case railAttention, railRunning:
- return true
- }
- if node.Paused() {
- return true
- }
- }
- return false
-}
-
// railDrawnView is the column AS IT IS DRAWN: [app.railView] with a run's own
// rows put in their place. IT IS THE ONE ANSWER TO "WHAT IS ON THIS SCREEN ROW",
// for the frame and for the pointer alike. The rows used to be spliced in by the
@@ -3503,8 +3189,8 @@ func (a *app) railEntryMoving(e railEntry) bool {
// press on a task opened nothing or opened its neighbour.
func (a *app) railDrawnView(height int) ([]railLine, int) {
view, focus := a.railView(height)
- if len(view) == 0 {
- return nil, focus
+ if len(view) == 0 || a.sideView() == sideTraffic {
+ return view, focus
}
// THE TREES COME OUT OF THE READING THE PLACE ALREADY HOLDS, never out of the
// store: this is a frame, and a frame never reads the disk. The reading is
@@ -3572,8 +3258,8 @@ func (a *app) railDrawnView(height int) ([]railLine, int) {
// A NODE ROW THAT CARRIES A RUN IS THAT RUN'S ROW. The door that takes the
// run road publishes a node row for the run's own task, naming it
// ([taskNode.planTask]) — and an older row is matched by the title it
- // wears. That row is kept, drawn as the head of a family, and the run's
- // parts hang under it: one piece of work, one row.
+ // wears. That row is kept, and the run's parts hang under it, a level in:
+ // one piece of work, one row.
carrier := make(map[string]int)
for _, line := range view {
node := nodeOf(line)
@@ -3600,7 +3286,7 @@ func (a *app) railDrawnView(height int) ([]railLine, int) {
kids = append(kids, block.self.kids...)
}
if at, ok := carrier[run]; ok {
- under[at] = append(under[at], a.planRailLines(kids, entries[at].stems, entries[at].root, width)...)
+ under[at] = append(under[at], a.planRailLines(kids, 1, width)...)
continue
}
if block.self != nil {
@@ -3631,37 +3317,19 @@ func (a *app) railDrawnView(height int) ([]railLine, int) {
continue
}
if node != nil && carried[line.entry] {
- if !line.head {
- continue
- }
- // THE CARRIER IS DRAWN AGAIN AS THE HEAD OF ITS FAMILY, by the
- // same renderer, so its under-block keeps the stem the parts
- // below it hang from.
- e := entries[line.entry]
- kids := len(under[line.entry]) > 0
- if kids && !e.folded {
- e.root = true
- }
- rows, glyph, badge := a.railEntryRows(e, width)
- for j, text := range rows {
- redrawn := line
- redrawn.text, redrawn.head = text, j == 0
- if j == 0 {
- redrawn.glyph, redrawn.badge = glyph, badge
- } else {
- redrawn.glyph, redrawn.badge = hudSpan{}, hudSpan{}
- }
- next = append(next, redrawn)
- }
- if !e.folded {
- next = append(next, under[line.entry]...)
- }
+ // THE CARRIER KEEPS ITS ONE LINE, and the run's parts hang under
+ // it, a level in: a task on this column is one line (sidecol.go).
+ next = append(next, line)
+ next = append(next, under[line.entry]...)
continue
}
next = append(next, line)
}
if !placed {
- next = append(append([]railLine{}, ahead...), next...)
+ // UNDER THE HEADER AND THE BAND, which stand at the top of every
+ // view (sidecol.go), and ahead of the rest.
+ at := min(a.side.pinned, len(next))
+ next = append(append(append([]railLine{}, next[:at]...), ahead...), next[at:]...)
}
if len(next) > height {
next = next[:height]
@@ -3717,7 +3385,10 @@ func (a *app) railRows(height int) []string {
// the label (jobHere below), so the keyboard's place is not silent.
jobHere := (a.railWhere.jobs && line.jobs) || (a.railWhere.job != 0 && line.job == a.railWhere.job)
jobRowHere := a.railWhere.job != 0 && line.job == a.railWhere.job
- if (focus >= 0 && line.head && line.entry == focus) || jobRowHere {
+ // A ROW OF THE SIDE COLUMN'S OWN IS HELD BY ITS KEY, the band's and the
+ // Traffic's alike (sidecol.go).
+ sideHere := a.railHold && line.side != nil && a.railWhere.key != "" && line.side.key == a.railWhere.key
+ if (focus >= 0 && line.head && line.entry == focus) || jobRowHere || sideHere {
lead = a.pal.accent(a.linearMark(railMark, railMarkASCII))
}
text := line.text
@@ -3730,9 +3401,7 @@ func (a *app) railRows(height int) []string {
// It is [palette.selected] and not a new mark — the same background the strip
// puts on the chip of the room a person is standing in (taskstrip.go's
// [app.stripChip]), and the same one every selected row on this surface
- // wears (palette.go). It covers EVERY line of the entry, not just its head:
- // a node's row is two lines tall when it has something to say under its
- // title, and a band on half of it would read as a row cut in two.
+ // wears (palette.go), and it covers the whole row.
//
// AND EVERY NODE ROW TAKES THE HOVER STEP, because every node row answers
// to a click — the whole row is that node's door (hover.go's own law). It
@@ -3748,7 +3417,20 @@ func (a *app) railRows(height int) []string {
if line.entry >= 0 && line.entry < len(entries) {
node = entries[line.entry].node
}
+ var head railEntry
+ if line.entry >= 0 && line.entry < len(entries) && entries[line.entry].head {
+ head = entries[line.entry]
+ }
switch {
+ case line.side != nil && a.sideHovering(line.side.key):
+ // THE HOVER GROUND COVERS THE WHOLE ROW, padding and time included,
+ // because the whole row is what a press on it answers.
+ text = a.hoverRow(text, room)
+ case sideHere:
+ text = a.pal.cursor(text, room)
+ case line.side != nil:
+ case head.head && a.hot.kind == hoverRailGroup && a.hot.index == int(head.group):
+ text = a.hoverRow(text, room)
case line.roomAction != "" && a.hot.kind == hoverRoomControl && a.hot.key == line.roomAction:
text = a.hoverRow(text, room)
case a.roomStandingOn(node):
@@ -3777,11 +3459,6 @@ func (a *app) railRows(height int) []string {
// pressable line here takes it on: it answers to a click, so the pointer
// says so ([taskSheetPastHint], taskview.go).
text = a.hoverRow(text, room)
- case line.stow && a.hoveringRailDoor():
- // AND THE COLUMN'S OWN DOOR TAKES IT TOO, on the terms every other
- // pressable line here takes it on: it answers to a click, so the pointer
- // says so ([app.railDoorLine]).
- text = a.hoverRow(text, room)
case line.keeping && a.hoveringRailStanding():
// AND THE STANDING COUNT, which is a door onto /standing and says so
// twice for the margin door's reason: its own ink comes up
@@ -3860,7 +3537,10 @@ func (a *app) railNodeAt(y int) *taskNode {
// railFocusAt finds the entry a spot names, or -1.
func railFocusAt(entries []railEntry, spot railSpot) int {
for i, e := range entries {
- if e.node != nil && e.node.id == spot.id {
+ switch {
+ case spot.group > 0 && e.head && int(e.group)+1 == spot.group:
+ return i
+ case spot.group == 0 && spot.id != 0 && e.node != nil && e.node.id == spot.id:
return i
}
}
@@ -3868,26 +3548,24 @@ func railFocusAt(entries []railEntry, spot railSpot) int {
}
// railFocusIndex is where the cursor is in the current entry list, or -1 when
-// the roster does not have the keyboard.
+// the roster does not have the keyboard, or has it on a row of the side
+// column's own (sidecol.go).
//
-// THE CURSOR FOLLOWS THE WORK AND THEN THE FAMILY. A node that lands does not
-// move any more — a family is drawn where it always was — but a fold closing
-// over it does, and that is now the one way a focused row leaves the list. Where
-// its rows went is its nearest drawn ancestor, which is exactly the row standing
-// for it, so that is where the cursor stands.
+// THE CURSOR FOLLOWS THE WORK AND THEN ITS GROUP. A node that moves from
+// running to done moves its row, and the cursor goes with it; a fold closing
+// over it takes its row away, and then the cursor stands on the heading that
+// is standing for it.
func (a *app) railFocusIndex(entries []railEntry) int {
- if !a.railHold || a.railWhere.onJobs() || len(entries) == 0 {
+ if !a.railHold || a.railWhere.onJobs() || a.railWhere.key != "" || len(entries) == 0 {
return -1
}
if at := railFocusAt(entries, a.railWhere); at >= 0 {
return at
}
- if node := a.tasks[a.railWhere.id]; node != nil {
- _, byKey := a.railKin()
- for up := byKey[node.ParentID()]; up != nil; up = byKey[up.ParentID()] {
- if at := railFocusAt(entries, railSpot{id: up.id}); at >= 0 {
- return at
- }
+ if node := a.tasks[a.railWhere.id]; node != nil && a.railWhere.group == 0 {
+ g := a.railGroupOf(node)
+ if at := railFocusAt(entries, railSpot{group: int(g) + 1}); at >= 0 {
+ return at
}
}
// The node itself is gone — /new, or a session that dropped it. The top of the
@@ -3915,15 +3593,27 @@ func (a *app) railTake(hold bool) {
// it back: remembered.
a.railStow(false)
}
+ if !hold && a.railFull() {
+ // The overlay goes with the hold, and the Traffic's `new` line with it.
+ a.side.up = ""
+ }
a.railHold = hold
if hold {
// WHERE THE CURSOR LANDS IS THE FIRST ROW THERE IS. The key is refused
// outright on a column with no rows of this session's ([app.railAvail]), so
// there is always one to land on — a task, or the jobs section when
// that is the work this conversation has.
+ // A ROW OF WORK BEFORE A HEADING: the heading is what the rows under it
+ // are filed by, and the person asked for the work.
spots := a.railSpots()
if railSpotAt(spots, a.railWhere) < 0 && len(spots) > 0 {
a.railWhere = spots[0]
+ for _, spot := range spots {
+ if spot.group == 0 {
+ a.railWhere = spot
+ break
+ }
+ }
}
}
a.touch()
@@ -3984,18 +3674,8 @@ func (a *app) railKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
// THE ONE KEY THAT ANSWERS WITH THE COLUMN ITSELF. It is read before the
// hold below because it is true in both postures — a column that is up goes
// away, a column that is away comes back — and it is the only key on this
- // map a person may press without having asked for the roster first.
- //
- // WITH THE MANAGER IN FRONT IT SWAPS THE TRAFFIC'S COLUMN between the
- // traffic and the manager's own tasks, and with no live tasks it has
- // nothing to swap to and does nothing (teamrail.go).
- if a.trafficOn() {
- if a.trafficTaskCount() == 0 {
- return nil, false
- }
- a.trafficTasksShow(!a.trafficTasksShowing())
- return nil, true
- }
+ // map a person may press without having asked for the roster first. It is
+ // the older name of [sideHideKey], which the column's header names.
// IT ONLY ACTS ON A ROSTER THAT IS ON THE FRAME, or on one it has already
// taken off. The column stands empty now ([app.railShowing]), so "on the
// frame" no longer needs any tasks behind it — a column carrying nothing but
@@ -4046,11 +3726,12 @@ func (a *app) railKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
case "down":
a.railMove(1)
return nil, true
- case "right":
- a.railOut()
- return nil, true
- case "left":
- a.railIn()
+ case "right", "left":
+ // ← AND → MOVE BETWEEN THE HEADER'S TWO WORDS, in a chat in a team,
+ // which is what they are for on every strip of words on this surface.
+ // In a chat with one word they are eaten and do nothing, so an arrow
+ // meant for the column never moves the draft's caret instead.
+ a.sideStep()
return nil, true
case railWidenChord:
// WIDEN, IN THE ONE SPELLING NOTHING CAN EAT. It used to be the bare
@@ -4096,7 +3777,7 @@ func (a *app) railKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
// holding a rail focus they cannot see. The rail's rule is that explicit
// focus outranks ambient place, and this is that rule applied to one more
// key; the way out is esc, which the hint already advertises
- // ([railHoldHint]).
+ // ([railHoldKeys]).
return nil, true
}
// THE ANSWERS TO THE ONE QUESTION A ROW CAN BE ASKING ARE NOT TAKEN HERE.
@@ -4126,13 +3807,18 @@ func (a *app) railMove(delta int) {
a.touch()
}
-// railSpots is every row the keyboard walks: this conversation's work, then
-// the jobs section under it. One walk, so enter on the jobs label is the
-// same key that opens a task, and a person who held ↓ off the last family
-// lands on the section that is actually next.
+// railSpots is every row the keyboard walks, in the order the column draws
+// them: the band, then the view in front. In the tasks that is this
+// conversation's work and then the jobs section under it; one walk, so enter
+// on the jobs label is the same key that opens a task, and a person who held
+// ↓ off the last group lands on the section that is actually next. In the
+// Traffic it is its rows (sidecol.go).
func (a *app) railSpots() []railSpot {
+ out := a.sideSpots()
+ if a.sideView() == sideTraffic {
+ return out
+ }
entries := a.railEntries()
- out := make([]railSpot, 0, len(entries)+len(a.jobs)+1)
for _, e := range entries {
out = append(out, railSpotOf(e))
}
@@ -4142,11 +3828,17 @@ func (a *app) railSpots() []railSpot {
func railSpotAt(spots []railSpot, want railSpot) int {
for i, s := range spots {
switch {
+ case s.key != "" || want.key != "":
+ if s.key == want.key {
+ return i
+ }
case s.jobs && want.jobs:
return i
case s.job != 0 && s.job == want.job:
return i
- case !s.onJobs() && !want.onJobs() && s.id != 0 && s.id == want.id:
+ case s.group != 0 && s.group == want.group:
+ return i
+ case !s.onJobs() && !want.onJobs() && s.group == 0 && want.group == 0 && s.id != 0 && s.id == want.id:
return i
}
}
@@ -4197,80 +3889,6 @@ func (a *app) railScroll(delta int) {
}
}
-// railCursorTo parks the cursor at one row of that list.
-func (a *app) railCursorTo(entries []railEntry, at int) {
- if at < 0 || at >= len(entries) {
- return
- }
- a.railWhere = railSpotOf(entries[at])
-}
-
-// railOut is →, and it is the tree grammar every file manager a person has used
-// spells the same way: on a folded root it OPENS the family, and on a family
-// already open it steps INTO it, onto the first child.
-//
-// ON A LANDED ROW IT OPENS THE ROW'S OWN BLOCK, which is the same grammar one
-// scale down: → is "show me what is inside this", and what is inside a row that
-// has come home is the branch it kept or the log it wrote ([app.railTucks]). On
-// anything else it does nothing — there is nothing further out to go.
-func (a *app) railOut() {
- entries := a.railEntries()
- at := a.railFocusIndex(entries)
- if at < 0 {
- return
- }
- e := entries[at]
- if !e.root {
- if a.railTuckShut(e.node) && a.railTucks(e) {
- a.railSetOpen(e.node, true)
- }
- return
- }
- if e.folded {
- a.railSetOpen(e.node, true)
- return
- }
- if at+1 < len(entries) {
- a.railWhere = railSpotOf(entries[at+1])
- a.touch()
- }
-}
-
-// railIn is ←, and it is the mirror: on an open root it FOLDS the family, on a
-// landed row whose block is showing it tucks the block back away, and anywhere
-// else it walks up to the parent row.
-//
-// THE CURSOR NEVER STAYS ON A ROW THE FOLD TOOK AWAY. Folding from the root
-// leaves it on the root, which is the row the family is now standing in; jumping
-// from a leaf leaves it on the parent, which is the row a second ← will fold.
-func (a *app) railIn() {
- entries := a.railEntries()
- at := a.railFocusIndex(entries)
- if at < 0 {
- return
- }
- e := entries[at]
- if e.root && !e.folded {
- a.railSetOpen(e.node, false)
- return
- }
- if !e.root && !a.railTuckShut(e.node) && a.railTucks(e) {
- a.railSetOpen(e.node, false)
- return
- }
- depth := len(e.stems)
- if depth == 0 {
- return
- }
- for i := at - 1; i >= 0; i-- {
- if len(entries[i].stems) < depth {
- a.railWhere = railSpotOf(entries[i])
- a.touch()
- return
- }
- }
-}
-
// railWiden takes the third width tier, or gives it back. It is sticky — a
// person who widened the column meant to keep it — and it goes with the nodes
// when the session does ([app.dropTasks]).
@@ -4310,12 +3928,20 @@ func (a *app) railWiden(wide bool) {
// session opens where the last one was told to, which is the behaviour of a
// session that has never been told anything.
func (a *app) railStow(away bool) {
- // WITH THE MANAGER IN FRONT THE ASK IS ANSWERED IN THE TRAFFIC'S COLUMN and
- // the saved answer is not touched: bringing the tasks back shows them there
- // when the manager has any, and putting them away shows the traffic
- // (teamrail.go).
- if a.trafficOn() {
- a.trafficTasksShow(!away)
+ // ON THE TEAMS PAGE THE ANSWER IS THE PAGE'S, and it is not written: the
+ // page opens with the column folded because it has a rail of its own on
+ // the left (teamspagehost.go).
+ if a.teamsHosting() {
+ if a.tp.traffic == !away {
+ return
+ }
+ a.tp.traffic = !away
+ if away {
+ a.railHold = false
+ a.side.up = ""
+ }
+ a.dropHover()
+ a.touch()
return
}
if a.railAway == away {
@@ -4323,6 +3949,7 @@ func (a *app) railStow(away bool) {
}
a.railAway = away
if away {
+ a.side.up = ""
// THE KEYBOARD GOES BACK TO THE DRAFT WITH THE COLUMN. A hold left standing
// on a roster that is not drawn is six keys taken from the box by a list
// nobody can see, and [app.railFull] would raise the overlay the moment the
@@ -4337,36 +3964,37 @@ func (a *app) railStow(away bool) {
// row under it — the same law the task page states ([app.taskSheetEnter],
// taskview.go), because these are two lists of the same work.
//
-// A NODE THIS SESSION HOLDS OPENS ITS ROOM. Folding has its own two keys, which
-// is what took the overload off this one.
+// A NODE THIS SESSION HOLDS OPENS ITS ROOM, and a group's heading opens the
+// group or folds it, which is what a press on either does. Work an earlier
+// conversation ran has no room and never will: a room is a live lane onto a
+// node in this session's graph, and it is reached through the task page
+// instead, where enter goes inside its card (taskrecord.go).
//
-// EVERY ROW OF THIS COLUMN IS SUCH A NODE, so that is the whole of what this key
-// does. Work an earlier conversation ran has no room and never will — a room is a
-// live lane onto a node in this session's graph — and it is reached through the
-// task page instead, where enter goes inside its card (taskrecord.go).
+// A ROW OF THE COLUMN'S OWN DOES WHAT A PRESS ON IT DOES: a band item opens
+// its task or goes to its message, and a work thread lays its replies open or
+// folds them (sidecol.go).
func (a *app) railEnter() tea.Cmd {
+ if a.railWhere.key != "" {
+ return a.sideRowAct(a.railWhere.key)
+ }
entries := a.railEntries()
at := a.railFocusIndex(entries)
- if at < 0 || entries[at].node == nil {
+ if at < 0 {
return nil
}
+ if entries[at].head {
+ a.sideToggleGroup(entries[at].group)
+ return nil
+ }
+ if entries[at].node == nil {
+ return nil
+ }
+ a.sideAck(entries[at].node)
return a.openRailRoom(entries[at].node)
}
// ── the footer ──────────────────────────────────────────────────────────────
-// railFootOrder is the order the footer counts the groups in, and it is not the
-// column's order: the column leads with what is asking for a decision because
-// that is where the eye starts, and the footer leads with what is HAPPENING
-// because a total is read as a state of the session.
-var railFootOrder = [railGroupCount]railGroup{railRunning, railAttention, railIdle, railParked, railDone}
-
-// railFootMax is how many lines the footer may spend on the COUNTS. Three is
-// the whole aggregate at the full width; a fourth would be the column reporting
-// on itself. The standing line, the doors and the offer are each measured
-// against the height on their own.
-const railFootMax = 3
-
// railFootMarks is where the footer's pressable lines landed, as indices into
// the rows it returns, or -1 for a line this frame did not draw.
//
@@ -4380,61 +4008,25 @@ type railFootMarks struct {
// missing.
var noRailFoot = railFootMarks{hint: -1, more: -1, keeping: -1}
-// railFootRows is the aggregate: what the window cannot show, said once at the
-// bottom of the column.
+// railFootRows is the foot of the column: the standing count, the door onto
+// the task page, and the offer of the wide tier, each only when it is true.
//
-// 3 running · 1 needs you
-// 148 waiting · 12 done
// ◦ 2 standing orders
+// earlier ▸
//
-// THE Σ IS GONE WITH THE MONEY IT LED. It meant "this is a SUM, including what
-// the column folded away", and it earned that while the first line was
-// `Σ $1.42 · 312k tok`. The bill left this foot for the status row on
-// 2026-09-09 — one number drawn twice on one frame — and a sigma in front of a
-// row of counts is a mathematician's mark on a tally: the counts are counts,
-// they say so in words, and nothing about them needs a symbol to be believed.
-//
-// AND THE GROUP WORDS OUTLIVED THE GROUPS. The column stopped filing nodes under
-// five headings ([app.railEntries] draws families now), and the five words are
-// still the vocabulary a person has for what a session is doing — so the count
-// of each is what the bottom of the column says, across every node in the
-// forest, folded or not.
-//
-// IT ALSO CARRIES THE ONE CONTEXTUAL OFFER ON THIS SURFACE. When a title is
-// being cut by its own indent and the frame could lend the wide tier, the last
-// line of the footer says so ([railWideHint]) — and it says it only then. A
-// permanent "w widens" is chrome charged to every session that never grew a
-// tree. It reports which of its lines that offer landed on, or -1, because the
-// line is pressable and the press has to know where it was drawn.
+// THE COUNTS OF EACH GROUP ARE NOT HERE ANY MORE. They were the foot's first
+// lines for as long as the column drew families, which scattered the groups;
+// the groups are the column's own headings now, each with its count on it, and
+// the header's `Tasks N` says the whole (sidecol.go).
//
// AND THE STANDING COUNT IS A LINE OF IT SINCE 2026-09-09 ([app.railStandingLine],
-// standdoor.go). It was a segment of the status row; it belongs here, under the
-// counts of what this column is holding, because it is the same question those
-// counts answer — what is alive on this project — and because this column is
-// where a person already looks for it. It keeps everything it had: it is drawn
-// only when something stands here, its mark breathes while a pass has one of
-// those orders in its hands, and pressing it opens /standing.
+// standdoor.go). It keeps everything it had: it is drawn only when something
+// stands here, its mark breathes while a pass has one of those orders in its
+// hands, and pressing it opens /standing.
func (a *app) railFootRows(width, height int) ([]string, railFootMarks) {
if width < 8 || height < 4 {
return nil, noRailFoot
}
- var segs []string
- // THE BOOKS DECIDE WHETHER A FIGURE IS DRAWN AND THE CLOCK DECIDES WHAT IT
- // SAYS. The guards ask the exact totals, because the emptiness law is about
- // whether there is anything to report; the figures themselves come off the
- // eased readings, so this foot counts up with the status line rather than
- // jumping beside it (reveal.go).
- // THE MONEY IS NOT HERE ANY MORE. It was the session's whole bill, and so
- // is the figure at the left of the status row two lines down — one number
- // drawn twice on one frame, and the second copy cost the column two of its
- // three lines. The foot counts what the column holds; the bill is the
- // status row's (foot.go).
- members := a.railMembers()
- for _, g := range railFootOrder {
- if n := len(members[g]); n > 0 {
- segs = append(segs, itoa(n)+" "+railGroupWords[g])
- }
- }
// IN THIS TERMINAL'S OWN SPELLING of the modifier (chords.go), because the
// offer names a chord now rather than a bare letter and a Mac calls that
// modifier Option.
@@ -4446,20 +4038,11 @@ func (a *app) railFootRows(width, height int) ([]string, railFootMarks) {
// THE DOOR ONTO THE TASK PAGE IS OFFERED ONLY WHEN THERE IS MORE BEHIND IT,
// which is the emptiness law applied to an affordance rather than to a figure.
// A door on a column that is already showing everything is a row that promises
- // a page and delivers the list you were looking at.
- //
- // AND IT WEARS THE NAME OF WHAT IS BEHIND IT. A project with a record says
- // `earlier`, because that is the section the page opens on and the word a
- // person is looking for; a column whose only held-back thing is a family it
- // folded says `view more`, because there is no earlier work to promise
- // ([taskSheetPastHint] and [taskSheetMoreHint], taskview.go). It is ONE door
- // either way — one line, one press, one page.
- record := a.railHasRecord()
- viewText := taskSheetMoreHint
- if record {
- viewText = taskSheetPastHint
- }
- view := ansi.StringWidth(viewText) <= width && (record || a.railFoldedAny())
+ // a page and delivers the list you were looking at. What is behind it is
+ // the project's record, so it says `earlier` ([taskSheetPastHint],
+ // taskview.go): a group the column folded is one press away on the column.
+ viewText := taskSheetPastHint
+ view := ansi.StringWidth(viewText) <= width && a.railHasRecord()
// THE STANDING LINE IS DRAWN ONLY WHERE SOMETHING STANDS, which is the
// emptiness law the segment already kept on the status row: a permanent
// `0 standing orders` is a permanent reminder of the absence of a thing
@@ -4468,38 +4051,28 @@ func (a *app) railFootRows(width, height int) ([]string, railFootMarks) {
if ansi.StringWidth(standWord) > width {
standWord = ""
}
- if len(segs) == 0 && standWord == "" && !offer && !view {
+ if standWord == "" && !offer && !view {
return nil, noRailFoot
}
- // The footer never takes more than a third of the column: a roster that is
- // mostly its own summary has stopped being a roster.
- rooms := min(railFootMax, height/3)
- lines := railPack(segs, width, rooms)
- out := make([]string, 0, len(lines)+2)
+ out := make([]string, 0, 4)
// ONE BLANK ABOVE IT, when the column can lend one — whitespace is how this
// surface separates blocks, and a rule across a two-cell column would be a
// border on a seam.
- if len(lines)+1 < height {
+ if height > 4 {
out = append(out, "")
}
- for _, line := range lines {
- out = append(out, a.pal.dim(line))
- }
marks := noRailFoot
- // THE STANDING COUNT GOES DIRECTLY UNDER THE TALLY, because it is the last of
- // the counts: three lines saying what this project is holding, and then the
- // doors and the offers about the column itself.
+ // THE STANDING COUNT GOES FIRST, because it says what this project is
+ // holding, and then the doors and the offers about the column itself.
if standWord != "" && len(out)+1 < height {
marks.keeping = len(out)
out = append(out, a.railStandingLine())
}
- // THE PAGE'S DOOR GOES DIRECTLY UNDER THE TALLY, above the width offer.
- // The counts say what this session has, the door says where the rest of it
- // is, and widening answers "how much of my screen is this taking". A person
- // reading the tally and wanting more finds the next line saying so.
+ // THE PAGE'S DOOR GOES ABOVE THE WIDTH OFFER. The door says where the rest
+ // of the work is, and widening answers "how much of my screen is this
+ // taking".
//
- // IT IS DIM, which is the same weight the tally above it wears and the
- // footnoted record rows used to. A door onto a month of other people's
+ // IT IS DIM, the weight the footnoted record rows used to wear. A door onto a month of other people's
// afternoons is not a thing this column should raise its voice about; it is a
// thing it should never fail to mention.
if view && len(out)+1 < height {
@@ -4534,69 +4107,6 @@ func (a *app) railStandingLine() string {
return a.pal.dim(a.keepingWord())
}
-// railDoorLine is the standing column's own door as it is drawn: the chevron
-// that closes it, and then the chord only while the chord does the same thing.
-//
-// THE CHEVRON IS THE CONTROL AND THE WORDS ARE THE LABEL, which is why they are
-// painted at two weights. `ctrl+g hide` tells the hand that types chords what to
-// press only while no foreground command owns that key; with one running, the
-// label is simply `hide`. The `❯` is what the hand that does NOT type chords
-// presses either way, so it takes the ink — the same split the closed edge makes
-// at the other end of the cycle ([app.railGripRows]).
-//
-// IT POINTS RIGHT AND ITS TWIN POINTS LEFT, and between them the pointer can go
-// round the whole cycle: `❯` sends the column off the right edge, `❮` brings it
-// back over the conversation. One control, two states, and neither of them a
-// chord somebody had to be told about.
-func (a *app) railDoorLine() string {
- mark := a.linearMark(railGripOpenGlyph, railGripOpenGlyphASCII)
- ink := a.pal.ink
- if a.hoveringRailDoor() {
- ink = a.pal.accent
- }
- return ink(mark) + " " + paintHint(a.railDoorHint(), a.pal, a.pal.dim)
-}
-
-// railDoorHint names only the keyboard action available on this frame. The
-// pointer's chevron still hides the column while a command owns ctrl+g, so the
-// verb stays and only the unavailable chord comes off the line.
-func (a *app) railDoorHint() string {
- if a.promotableRow() >= 0 {
- return "hide"
- }
- return railStowHint
-}
-
-// railDoorAt reports whether a pointer is on that line. It is the hover's guard,
-// and the press resolves the same fact through [railLine.stow] — one geometry
-// asked twice, because the line is found by the layout either way.
-func (a *app) railDoorAt(x, y int) bool {
- if !a.railAt(x, y) || a.railSeamAt(x, y) {
- return false
- }
- line, ok := a.railLineAt(y)
- return ok && line.stow
-}
-
-// railFoldedAny reports whether the column is standing one row for a family it
-// has folded — which is the SECOND of the two things that earn the footer's door
-// onto the task page, the first being the project's own record
-// ([app.railHasRecord], taskview.go).
-//
-// A folded family is work the column is deliberately not drawing and the page
-// draws every family whole, so the door is honest. A landed node of this
-// session's, drawn on the column and listed again on the page, earns nothing:
-// that is the same row said twice, and a line offering to show you what you are
-// already looking at is chrome.
-func (a *app) railFoldedAny() bool {
- for _, e := range a.railEntries() {
- if e.folded {
- return true
- }
- }
- return false
-}
-
// railMoreAt reports whether a pointer is on the footer's door onto the task
// page. It is the hover's guard, and the press resolves the same fact through
// [railLine.more] — one geometry asked twice, because the line is found by the
@@ -4609,455 +4119,109 @@ func (a *app) railMoreAt(x, y int) bool {
return ok && line.more
}
-// railOffersResize reports whether the footer should name the handle. A cut
-// title earns the offer on its own; focus and the pointer make it visible while
-// a person is already acting on the roster. The frame still has the final say.
+// railOffersResize reports whether the footer should name the handle: while a
+// person is already acting on the roster, with the keyboard or the pointer.
+// The frame still has the final say.
func (a *app) railOffersResize() bool {
if !a.railCanWiden() {
return false
}
// The task panel reserves its footer before laying out the tree. Hover may
// recolor that footer, but must never add a row and move the controls.
- return a.roomOrganized() || a.railCramped || a.railHold || a.hoveringRailArea()
-}
-
-// railPack folds the footer's segments into at most rooms lines of at most width
-// cells, joined by this surface's own separator.
-//
-// A segment that will not fit is DROPPED and the fold is said out loud with the
-// ellipsis this surface truncates everything with: a footer that silently stops
-// counting is a footer that claims the session is smaller than it is.
-//
-// IT TAKES NO LEAD ANY MORE. It had one — `Σ `, on the first line only — for as
-// long as the first line was the session's bill; the counts that are left say
-// what they are in words ([app.railFootRows] says why the sigma went).
-func railPack(segs []string, width, rooms int) []string {
- if rooms < 1 || width < 1 {
- return nil
- }
- out := make([]string, 0, rooms)
- line := ""
- for _, seg := range segs {
- add := seg
- if line != "" {
- add = railSep + seg
- }
- if ansi.StringWidth(line)+ansi.StringWidth(add) <= width {
- line += add
- continue
- }
- // A FIRST SEGMENT TOO WIDE FOR THE COLUMN IS CUT RATHER THAN DROPPED: a
- // count is still worth reading with its tail folded, and dropping it would
- // leave the line under it claiming to be the first thing this session has.
- if line == "" {
- line = fit(seg, width)
- continue
+ return a.roomOrganized() || a.railHold || a.hoveringRailArea()
+}
+
+// railEntryRow is one row of the roster, one line whatever it is:
+//
+// Running 4 a group's heading, always open
+// ⠙ rebase onto dev 2m a task: its state, its name, its time
+// Queued 3 ▸ a folded group and its count
+// Done 7 ▾ the same group, opened
+//
+// EVERY TASK ROW OPENS WITH ONE GLYPH AND IT IS THE STATE, two cells in under
+// its heading, then the name the person approved, cut with an ellipsis, and
+// how long the work has been at it (or took) in the muted ink at the right.
+// The whole name, and what a row used to say under it, are the hint line's
+// ([app.sideHoverWords]).
+//
+// A HEADING IS THE GROUP'S WORD AND ITS COUNT, and a group that folds wears ▸
+// while it is folded and ▾ while it is open. Running work does not fold, so
+// its heading wears no mark: a mark is a promise that a press does something.
+func (a *app) railEntryRow(e railEntry, width int) string {
+ if e.head {
+ word := railHeadWords[e.group] + " " + itoa(e.count)
+ line := a.pal.muted(railHeadWords[e.group]) + " " + a.pal.dim(itoa(e.count))
+ if e.group != railRunning {
+ mark := a.linearMark(glyphOpen, glyphOpenASCII)
+ if e.shut {
+ mark = a.linearMark(glyphShut, glyphShutASCII)
+ }
+ word += " " + mark
+ line += " " + a.pal.muted(mark)
}
- out = append(out, line)
- if len(out) == rooms {
- out[rooms-1] = fit(out[rooms-1]+" "+glyphMore, width)
- return out
+ if ansi.StringWidth(word) > width {
+ return a.pal.muted(fit(word, width))
}
- line = fit(seg, width)
+ return line
}
- // The loop returns the moment the last line is spoken for, so what reaches
- // here is a line with room left in the block.
- if line != "" {
- out = append(out, line)
- }
- return out
-}
-
-// railEntryRows is one row of the forest: WHAT IT IS on the first line, and what
-// is true of it on the second.
-//
-// ⠙ Fix nil-map #7 a node that belongs to no family
-// bash go test ./… · 42s
-// ⠙ Ship the port #1 and a family, drawn whole
-// ├─ ✓ Read the law #2
-// ├─ ⠙ Write the tree #3
-// │ 42s · 9.9k · $0.31
-// │ └─ ◌ Cut the goldens #4
-// └─ ◌ Wire the seam #5
-// ⠙ Port the parser ▸ +7 the same family, folded
-//
-// EVERY ROW OPENS WITH ONE GLYPH AND IT IS THE STATE. A flat row used to lead
-// with two — the state and the node's own ◆ — and the second bought nothing
-// here: it is the same mark on every task, the tree rows never carried it, and
-// this column holds nothing but tasks, so it marked a distinction the column
-// does not contain while spending two of the twenty-two cells the name has
-// ([app.railLead] states the whole of it). Down a tree the neighbours are
-// already named by the connectors they hang from, and the question left over is
-// which limb is still moving: so the column is a column of STATES and it can be
-// read downward, flat rows and family rows alike.
-//
-// THE NAME LEADS AND THE HANDLE TRAILS. The glyph and the title are what a
-// person reads down this column — the state, and the words they themselves
-// approved — and the id is what identifies the node to the
-// MACHINE: the number the engine says in its own sentences ("task 7 done",
-// session's task_run.go), the thing to type when you go looking for the branch,
-// and the least interesting fact on the row. So it is dim, it is at the far end,
-// and the title is measured against what is left.
-//
-// A FOLDED ROOT SPENDS THAT SAME SLOT ON ITS COUNT. The handle is how you find
-// one node and the count is how many nodes this row is standing for — on the one
-// row that is hiding work, the second question is the one being asked.
-//
-// THE SUBTITLE IS NOT HERE. Every other place a task is drawn carries the one
-// line that says what it is; this column is twenty-two cells wide and is a
-// PRESENCE list — the question it answers is "what is alive", and a sentence
-// clipped to twenty-two cells answers no question at all.
-//
-// It reports the FOLD cell's columns and the badge's alongside the rows, because
-// both are pressable and both are narrower than the row they are on — and
-// because everything else on the row is the node's own door, so a target
-// recorded where nothing is drawn is a click the task swallows.
-func (a *app) railEntryRows(e railEntry, width int) ([]string, hudSpan, hudSpan) {
node := e.node
if node == nil {
- return nil, hudSpan{}, hudSpan{}
- }
- // Deep ancestry keeps its full navigation identity, but its indentation must
- // leave room for a name and the under-row's child stem. The ellipsis marks
- // omitted outer connectors; only this drawing copy is shortened.
- depthRoom := max((width-railTitleFloor-2-treeIndentCols)/treeIndentCols, 1)
- compressed := len(e.stems) > depthRoom
- if compressed {
- e.stems = e.stems[len(e.stems)-depthRoom:]
- }
- prefix, at := a.railPrefix(e.stems)
- if compressed {
- tail, _ := a.railPrefix(e.stems[1:])
- prefix = a.pal.dim(a.linearMark("…", "~")+strings.Repeat(" ", treeIndentCols-1)) + tail
- }
- glyph, lead, folds := a.railLead(e)
- room := width - at - ansi.StringWidth(lead)
- // The trailing slot: a folded root says how much it is standing for, every
- // other row says its handle, and both stand down when the title cannot afford
- // them.
- meta := railMetaWord(node)
- if e.folded {
- meta = a.linearMark(glyphShut, glyphShutASCII) + " +" + itoa(e.hidden)
- }
- if room-ansi.StringWidth(meta)-1 < railTitleFloor {
- meta = ""
- }
- if meta != "" {
- room -= ansi.StringWidth(meta) + 1
- }
- title, whole := fit(node.title, room), ansi.StringWidth(node.title)
- // THE HINT IS EARNED TWICE OVER: by the INDENT, because a name cut on a row
- // with nothing above it is a name this column is simply too narrow for, and by
- // the WIDE TIER ACTUALLY SAVING IT, because an offer that does not fix what a
- // person can see is worse than no offer.
- if at > 0 && whole > room && whole <= room+railWideGain {
- a.railCramped = true
- }
- line := prefix + lead + a.railTitle(node, title)
- badge := hudSpan{}
- if meta != "" {
- if pad := room - ansi.StringWidth(title) + 1; pad > 0 {
- line += strings.Repeat(" ", pad)
- }
- if e.folded {
- from := width - ansi.StringWidth(meta)
- badge = hudSpan{from: from, to: width}
- }
- line += a.pal.dim(meta)
- }
- rows := []string{line}
- if a.railSaysMore(e) {
- for _, under := range a.railUnder(node, width-a.railUnderCols(e)) {
- rows = append(rows, a.railUnderStem(e)+under)
- }
- }
- // THE CELL IS A TARGET ONLY WHERE IT IS DRAWN AS ONE. At rest it holds the
- // STATE — a spinner, a tick, a demand — and a state is not a control; the
- // disclosure appears in its place under the pointer and only there
- // ([app.railLead]). An empty span holds no column ([hudSpan.holds] asks
- // [hudSpan.pressable] first), so on every other frame these cells belong to
- // the row, which is the node's door.
- cell := hudSpan{}
- if folds {
- cell = hudSpan{from: at, to: at + ansi.StringWidth(glyph)}
- }
- return rows, cell, badge
-}
-
-// railSaysMore reports whether this row is allowed the block under its title.
-//
-// A SETTLED NODE IS ONE LINE, WHEREVER IT STANDS. A family is drawn whole, which
-// means the rows that landed are on screen to make the rows that have not landed
-// legible — and a merge word and a price under each of them is a second column of
-// history inside a shape somebody is reading for its shape. So only the rows that
-// are still going somewhere say anything more: what is running, and what is
-// waiting on a person. A folded root says nothing extra either — it is standing
-// for a whole subtree, and one row is the point of it.
-//
-// AND THE FLAT ROW FOLLOWS THE SAME LAW, which is the thing that changed. A row
-// with no family around it used to keep its block forever, so thirteen jobs that
-// had all landed hours ago spent thirty-nine lines of a thirty-cell column
-// restating history, and the standing section under them was squeezed to a single
-// cut-off row. The column exists to say what is alive; space it spends on what is
-// over is space taken from what is not.
-//
-// WHAT IS QUEUED IS NOT SETTLED, and a flat one keeps its line: `waits: Collect
-// sources` is the only place this surface says what is in the way, and inside a
-// family the sibling above it is that answer already.
-//
-// AND NOTHING IS THROWN AWAY — it is TUCKED. A landed flat row that had something
-// to say folds it behind the same disclosure a family root wears, opened with →
-// or a press on the glyph cell and remembered in the same map ([app.railOpen]).
-// So the branch a stopped run kept is one gesture away rather than gone; see
-// [app.railTucks].
-func (a *app) railSaysMore(e railEntry) bool {
- if e.folded {
- return false
- }
- group := a.railGroupOf(e.node)
- switch group {
- case railRunning, railAttention:
- return true
- }
- if e.node.Paused() {
- return true
- }
- // A HELD ROW SAYS WHAT IS HOLDING IT WHEREVER IT SITS IN A FAMILY. The
- // engine starts as much of a fan as this machine can carry and holds the
- // rest (internal/session's task_pressure.go), so a family is routinely half
- // running and half waiting — and a child row that drew nothing was the one
- // shape a person could not tell apart from work that has simply not been
- // reached yet.
- if e.node.waiting != "" {
- return true
- }
- if e.root || len(e.stems) > 0 {
- return false
- }
- if group == railDone {
- return !a.railTuckShut(e.node)
- }
- return true
-}
-
-// railTucks reports whether this row folds ITS OWN BLOCK — the landed flat row,
-// open or shut, and nothing else on the column.
-//
-// IT ASKS WHETHER THERE IS ANYTHING BEHIND THE DISCLOSURE, at the width the block
-// would be drawn at, because a triangle on a row with nothing under it is an
-// affordance that answers a press with silence. A node that came home clean with
-// no price to report has nothing tucked, and its glyph cell stays its state.
-//
-// It is asked of ONE row at a time — the row under the pointer, and the row a key
-// arrived on — and never down the whole column, which is why the layout reads
-// [app.railSaysMore] instead: that question is answered from the fold map alone,
-// and this one renders a block to answer.
-func (a *app) railTucks(e railEntry) bool {
- if e.node == nil || e.root || e.folded || len(e.stems) > 0 {
- return false
+ return ""
}
- if a.railGroupOf(e.node) != railDone {
- return false
+ const indent = " "
+ glyph := a.railTreeGlyph(node)
+ room := width - len(indent) - 2
+ // THE TIME GIVES WAY TO THE NAME: it is drawn only where the name keeps
+ // railTitleFloor cells beside it, and never where it would cut a name that
+ // fits whole without it.
+ age := a.railAge(node)
+ ageW := ansi.StringWidth(age) + 1
+ nameW := ansi.StringWidth(node.title)
+ if age != "" && room-ageW >= railTitleFloor && !(nameW <= room && nameW > room-ageW) {
+ room -= ageW
+ } else {
+ age = ""
}
- return len(a.railUnder(e.node, a.railRoom()-a.railUnderCols(e))) > 0
-}
-
-// railTuckShut reports whether a landed flat row is holding its block back.
-//
-// THE DEFAULT IS SHUT AND THE MAP IS THE PERSON'S CORRECTION OF IT, which is
-// [app.railShut]'s own bargain said about one row instead of a family — and it is
-// the same map, so a row and a family are folded by the same gesture and undone
-// by it too. It is spelled separately because [app.railShut] answers by regrowing
-// the node's family, which the layout may not pay for once a row.
-func (a *app) railTuckShut(node *taskNode) bool {
- return node == nil || !a.railOpen[node.id]
-}
-
-// railNodeRows is one node with no family around it — the flat row, and the
-// shape every caller outside the column's own layout wants.
-func (a *app) railNodeRows(node *taskNode, width int) []string {
- rows, _, _ := a.railEntryRows(railEntry{node: node}, width)
- return rows
-}
-
-// railLead is the row's glyph cell, the whole lead it sits in, air included, and
-// whether that cell IS A FOLD CONTROL on this frame.
-//
-// THE DISCLOSURE IS THE POINTER'S AND IT REPLACES THE STATE. A family root under
-// the pointer trades its state cell for ▾ or ▸ — one cell, in place, so nothing
-// on the row moves — because the affordance is worth exactly as much as the
-// state for the one moment there is a hand on it. At rest the column is states
-// all the way down.
-//
-// AND A LANDED ROW WITH ITS BLOCK TUCKED AWAY OFFERS THE SAME CELL, because it is
-// the same gesture on the same map ([app.railTucks]). One fold vocabulary down
-// the column: what is hiding something says so under the hand, and ▸ opens it
-// whether what it is hiding is a subtree or two lines of its own history.
-//
-// THE THIRD ANSWER IS WHAT THE PRESS READS, and returning it is the whole of the
-// fix: [app.railPress] used to fold whenever a row COULD disclose — a family
-// root, a landed row with a block tucked under it — while the cell only DRAWS
-// the triangle under the pointer. So a press on a root this surface was not
-// holding a hover for folded the family with a STATE glyph on screen, and the
-// task the person was aiming at never opened. Opening a room drops the hover
-// ([app.dropHover]) and a pointer that has not moved since sends no motion to
-// put it back, so the very next click after opening anything landed in exactly
-// that gap. The set that LIGHTS is the set that acts, which is hover.go's own
-// law; this is the one answer both halves now read.
-//
-// AND THE ROW NO LONGER CARRIES THE ◆. It is one marker drawn as furniture,
-// saying "this row is a task" and nothing else (taskident.go) — a distinction
-// this column does not contain, because every node row on it is a task and the
-// tree rows never wore it at all. It cost two cells of NAME on the narrowest
-// surface here, on flat rows only, so two rows of the same kind led differently
-// and the under-block — indented two cells by [app.railUnderCols] — sat two
-// cells to the left of the title it belongs to. Dropping it buys the name those
-// cells and squares the block up under it. Every other place a task is drawn
-// keeps the marker, because those places hold more than tasks.
-func (a *app) railLead(e railEntry) (string, string, bool) {
- glyph := a.railTreeGlyph(e.node)
- if e.folded && e.worst != nil {
- // A FOLDED ROOT WEARS THE WORST THING UNDER IT. The row is standing for a
- // whole subtree, so the one cell it has says what that subtree's news is
- // rather than what its root happens to be doing.
- glyph = a.railTreeGlyph(e.worst)
- }
- fold := a.hoveringRail(e.node) && (e.root || a.railTucks(e))
- if fold {
- mark := a.linearMark(glyphOpen, glyphOpenASCII)
- if e.folded || (!e.root && a.railTuckShut(e.node)) {
- mark = a.linearMark(glyphShut, glyphShutASCII)
- }
- glyph = a.pal.accent(mark)
+ title, w := fitWidth(node.title, room)
+ line := indent + glyph + " " + a.railTitle(node, title)
+ if age != "" {
+ line += strings.Repeat(" ", max(room-w, 0)+1) + a.pal.dim(age)
}
- return glyph, glyph + " ", fold
+ return line
}
-// railPrefix is the connectors for one row, painted, and the CELLS they cost. A
-// root has none — it is the thing everything else hangs from.
-func (a *app) railPrefix(stems []bool) (string, int) {
- if len(stems) == 0 {
- return "", 0
- }
- var out strings.Builder
- for _, more := range stems[:len(stems)-1] {
- if more {
- out.WriteString(a.linearMark(treeStem, treeStemASCII))
- continue
+// railAge is how long a task has been at it, while it runs, or how long it
+// took, once it has landed, in the fewest cells: `40s`, `2m`, `1h`. "" for
+// work that has not started, and for a landed node nobody timed.
+func (a *app) railAge(node *taskNode) string {
+ var d time.Duration
+ switch node.state {
+ case session.TaskRunning:
+ start := node.spawnedAt()
+ if start.IsZero() {
+ return ""
}
- out.WriteString(treeVoid)
- }
- if stems[len(stems)-1] {
- out.WriteString(a.linearMark(treeBranch, treeBranchASCII))
- } else {
- out.WriteString(a.linearMark(treeLast, treeLastASCII))
- }
- return a.pal.dim(out.String()), len(stems) * treeIndentCols
-}
-
-// railUnderStem is what an under-row is drawn behind, and railUnderCols is what
-// that costs.
-//
-// THE STEM CONTINUES THROUGH THE UNDER-BLOCK. A node's telemetry sits between
-// that node's row and its next sibling's, so a block indented with plain spaces
-// would put a gap in the vertical line the eye is following down the family. The
-// row's own elbow becomes a stem — or blank air, where the node was the last of
-// its siblings — and the node's own stem is added when it has children drawn
-// below it. The two cells on the end are the flat row's WHOLE lead — the state
-// glyph and its air ([app.railLead]) — so an under-row now starts in the same
-// column as the title it belongs to. It did not while the flat row also carried
-// a ◆: the block sat two cells to its left, and squaring that up is half of why
-// the marker went.
-func (a *app) railUnderStem(e railEntry) string {
- var out strings.Builder
- for _, more := range e.stems {
- if more {
- out.WriteString(a.linearMark(treeStem, treeStemASCII))
- continue
+ d = a.taskNow(node).Sub(start)
+ case session.TaskQueued:
+ return ""
+ default:
+ d = node.elapsed
+ if d <= 0 && !node.started.IsZero() && !node.ended.IsZero() {
+ d = node.ended.Sub(node.started)
}
- out.WriteString(treeVoid)
- }
- if e.root && !e.folded {
- out.WriteString(a.linearMark(treeStem, treeStemASCII))
- }
- if out.Len() == 0 {
- return " "
- }
- return a.pal.dim(out.String()) + " "
-}
-
-func (a *app) railUnderCols(e railEntry) int {
- cols := len(e.stems) * treeIndentCols
- if e.root && !e.folded {
- cols += treeIndentCols
- }
- return cols + 2
-}
-
-// railWorst is the node whose state a folded family wears: the most urgent thing
-// under it, its root included.
-//
-// A family's position is stable, but its folded glyph still reports the most
-// urgent state among its members.
-func (a *app) railWorst(t *railTwig) *taskNode {
- worst := t.node
- for _, kid := range t.kids {
- if under := a.railWorst(kid); a.railGlyphRank(under) < a.railGlyphRank(worst) {
- worst = under
+ if d <= 0 {
+ return ""
}
}
- return worst
-}
-
-// railGlyphRank orders the readings by how loud they are on one cell, and IT IS
-// THE TIER'S ORDER: the person's call first, then work in flight, then work that
-// is over (docs/design/task-states/DESIGN.md).
-//
-// THE ONE RANK THE TIERS DO NOT DECIDE IS `over` AND UNFINISHED. The glyph on a
-// folded row is the news of the subtree, and "something in here did not come
-// off" is the loudest news there is short of a demand — so an incomplete child
-// outranks a sibling that has not started. The glyph reports what happened
-// without changing the family's creation order.
-//
-// A CHILD WHOSE DECISION IS ITS PARENT'S AGENT'S DOES NOT MAKE THE FOLDED ROW A
-// DEMAND, which is [app.railGroupOf]'s law said on one cell: the parent holds
-// the question, so a family drawn as its root alone must wear the root's own
-// news and not a question its own head is already answering. That fold is read
-// off [session.TaskAsk.Owner] and off nothing else — a design waiting to be
-// approved is asking the PERSON, and no agent above it can answer for them —
-// and it changes only where the row sorts. The row still reads its reason.
-func (a *app) railGlyphRank(node *taskNode) int {
- status := a.taskStatus(node)
switch {
- case status.Tier == session.TaskTierYourCall:
- // AND THE TIER IS ASKED FIRST. A node whose landing is somebody's call has
- // a branch that never came home by construction, so an unlanded-changes
- // test above this one would answer for every one of them and the fold
- // below could never fire.
- //
- // THE PARENT'S OWN RUN IS THE SAME FACT THE ENGINE HAS NOT PUBLISHED YET.
- // The engine routes a sub-task's landing note to its parent node's agent
- // while that parent lives (session's deliverTaskNote), which IS the model
- // holding the question — but it does not stamp [session.TaskNotice.Decider]
- // on that shape, so reading the owner alone would take the #268 fold away
- // and put a demand back on a family whose head is already answering it.
- // Both roads are the same claim; when the engine publishes the second the
- // clause goes.
- if status.Ask.Owner == session.TaskAskOwnerModel || a.taskParentDeciding(node) {
- return 4
- }
- return 0
- case node.Paused(), status.ChangesUnlanded():
- return 0
- case status.Tier == session.TaskTierMoving:
- if status.Presence == session.TaskPresenceWorking || status.Presence == session.TaskPresenceFinishing {
- return 1
- }
- return 3
- case status.Presence == session.TaskPresenceIncomplete:
- return 2
+ case d < time.Minute:
+ return itoa(max(int(d/time.Second), 0)) + "s"
+ case d < time.Hour:
+ return itoa(int(d/time.Minute)) + "m"
+ case d < 24*time.Hour:
+ return itoa(int(d/time.Hour)) + "h"
}
- return 4
+ return itoa(int(d/(24*time.Hour))) + "d"
}
// railTreeGlyph is the row's state in one cell: the roster's own glyph, with the
@@ -5076,18 +4240,6 @@ func (a *app) railTreeGlyph(node *taskNode) string {
// naming the work.
const railTitleFloor = 12
-// railMetaWord is the node's handle: the id the engine calls it by.
-//
-// A STORE TASK'S HANDLE IS THE STORE'S. A run's parts have no node number, so
-// the row lent to one carries the store's own id ([taskNode.handle]) and wears
-// it in the same slot, in the same dim, as a node wears its number.
-func railMetaWord(node *taskNode) string {
- if node.handle != "" {
- return "#" + node.handle
- }
- return "#" + itoa(int(node.id))
-}
-
// railModelWord is the model this node runs on, as a column this narrow can say
// it: the part of the id AFTER THE VENDOR, which is the part that names the
// model rather than who sells it. Empty when nobody published one, and then
@@ -6230,13 +5382,14 @@ func (a *app) dropTasks() {
// started it, down to the folder its log is written in.
a.dropJobs()
// THE ROSTER GOES WITH ITS NODES, the keyboard included. A column that kept
- // its folds and its cursor into the next conversation would be a map of work
- // that no longer exists, holding keys the draft is waiting for.
- a.railOpen = nil
+ // its cursor into the next conversation would be a map of work that no
+ // longer exists, holding keys the draft is waiting for. Which groups are
+ // open is the person's reading of the column, not of these nodes, and stays
+ // for the session (sidecol.go).
a.railTop = 0
a.railWhere = railSpot{}
a.railHold = false
- a.railWide, a.railCramped = false, false
+ a.railWide = false
// [app.railAway] STAYS. It is the one fact in this block that is not about
// these nodes: a person who put the column away said something about their
// screen, not about the conversation they have just replaced, and standing it
diff --git a/internal/tui3/taskcardhost_test.go b/internal/tui3/taskcardhost_test.go
index 2b3b81bed1..a5be1d7dc5 100644
--- a/internal/tui3/taskcardhost_test.go
+++ b/internal/tui3/taskcardhost_test.go
@@ -167,7 +167,8 @@ func TestAHostedRosterListsTheFarConversationsTasks(t *testing.T) {
if node := a.tasks[9]; node == nil || node.title != "widening the pipe" {
t.Fatalf("the far row did not become a roster node: %#v", node)
}
- if entries := a.railEntries(); len(entries) != 1 || entries[0].node == nil || entries[0].node.id != 9 {
+ railOpenAll(a)
+ if entries := a.railEntries(); len(entries) != 2 || !entries[0].head || entries[1].node == nil || entries[1].node.id != 9 {
t.Fatalf("the hosted roster stayed empty: %#v", entries)
}
}
diff --git a/internal/tui3/taskeffort.go b/internal/tui3/taskeffort.go
index 8d196de1c0..9fd64e2149 100644
--- a/internal/tui3/taskeffort.go
+++ b/internal/tui3/taskeffort.go
@@ -187,8 +187,9 @@ func (a *app) taskEffortClause(node *taskNode) string {
// It is the engine's own gate read from outside: a running or queued node in
// this session's graph, or an ordinary settled node saving its next rung.
// A node belonging to an adaptive run is outside the graph this door reaches.
-// railHoldHintWord is [railHoldHint] with the rung's chord named in it while the
-// row under the cursor can take one, and [railHoldHint] itself otherwise.
+// railHoldHintWord is [railHoldKeys] and esc, with the rung's chord named in
+// it while the row under the cursor can take one, and led by ←→ in a chat in a
+// team, where they switch the column's two words.
//
// AND A ROW THAT IS ASKING TAKES THE WHOLE LINE. A node that landed `needs your
// look` is the surface standing still waiting for a person, and the three words
@@ -210,10 +211,14 @@ func (a *app) railHoldHintWord() string {
// ranked prefix of it that fits, and `esc` is the last thing it gives up.
return a.landingHintAt(node.id, a.width, railSep+"esc")
}
+ keys := railHoldKeys
+ if a.sideKind() != sideKindPlain {
+ keys = sideSwitchKeys + " · " + keys
+ }
if !a.taskRungMovable(a.railFocusNode()) {
- return a.chords.say(railHoldHint)
+ return a.chords.say(keys + " · esc")
}
- return a.chords.say(railHoldKeys + " · " + effortKeyClause + " · esc")
+ return a.chords.say(keys + " · " + effortKeyClause + " · esc")
}
func (a *app) taskRungMovable(node *taskNode) bool {
diff --git a/internal/tui3/taskending_test.go b/internal/tui3/taskending_test.go
index 67ad6967dc..8db1315a33 100644
--- a/internal/tui3/taskending_test.go
+++ b/internal/tui3/taskending_test.go
@@ -8,11 +8,12 @@ import (
"github.com/Agent-Field/codeaf/internal/session"
)
-// endedRailText expands the finished report and reads its rows as one line, because the rail wraps a row's
-// sentence across its narrow column and a test reads the sentence, not the wrap.
+// endedRailText is what the column says about node 7, read as one line: its
+// row, and the hint line over it, which is where the finished report went when
+// every row became one line.
func endedRailText(a *app) string {
- a.railSetOpen(a.tasks[7], true)
- rows := plain(strings.Join(a.railRows(12), "\n"))
+ railOpenAll(a)
+ rows := plain(strings.Join(a.railRows(12), "\n")) + " " + railHint(a, 7)
return strings.Join(strings.Fields(strings.ReplaceAll(rows, "│", " ")), " ")
}
diff --git a/internal/tui3/taskmachinehold_test.go b/internal/tui3/taskmachinehold_test.go
index 6b802e9cd2..318d4013ee 100644
--- a/internal/tui3/taskmachinehold_test.go
+++ b/internal/tui3/taskmachinehold_test.go
@@ -31,7 +31,7 @@ func TestAHeldChildSaysTheMachineHeldItAndNotTheCap(t *testing.T) {
if held.waiting != waitWordMachine {
t.Fatalf("the held part carries %q, want %q", held.waiting, waitWordMachine)
}
- if rows := a.railUnder(held, underWidth(railCols)); len(rows) != 1 || !strings.Contains(plain(rows[0]), waitWordMachine) {
+ if rows := a.railUnder(held, underWidth(underCols)); len(rows) != 1 || !strings.Contains(plain(rows[0]), waitWordMachine) {
t.Fatalf("the held part's row is %q, want it to say %q", rows, waitWordMachine)
}
if status := a.taskStatus(held); status.Reason != waitWordMachine {
@@ -42,7 +42,7 @@ func TestAHeldChildSaysTheMachineHeldItAndNotTheCap(t *testing.T) {
// cursor wears its reason under its own row.
drive(t, a, altT())
railFocusOn(t, a, 3)
- roster := rosterText(a, 12)
+ roster := rosterText(a, 12) + "\n" + a.sideHoverWords() + "\n" + railHint(a, 3)
if !strings.Contains(roster, waitWordMachine) {
t.Fatalf("the roster does not say the machine is holding a part:\n%s", roster)
}
diff --git a/internal/tui3/taskmodel_test.go b/internal/tui3/taskmodel_test.go
index ab4d71bc30..fb152e83d1 100644
--- a/internal/tui3/taskmodel_test.go
+++ b/internal/tui3/taskmodel_test.go
@@ -116,9 +116,9 @@ func questionBlockText(a *app) string {
return plain(strings.Join(a.questionRows(a.width), "\n"))
}
-// THE NODE KEEPS ITS MODEL AFTERWARDS: the rail says it on the telemetry row
-// under the name, the room's header states it, and the landed card keeps it
-// beside the working copy.
+// THE NODE KEEPS ITS MODEL AFTERWARDS: the column's hint line says it under
+// the pointer on the node's row, the room's header states it, and the landed
+// card keeps it beside the working copy.
func TestTheModelFollowsTheNodeOntoTheRailAndTheLandedCard(t *testing.T) {
a, _, advance := taskApp(t)
drive(t, a, streamEventMsg{gen: a.gen, ev: modelProposal(a, 7, 0, "openai/gpt-5", nil)})
@@ -130,31 +130,26 @@ func TestTheModelFollowsTheNodeOntoTheRailAndTheLandedCard(t *testing.T) {
if node == nil || node.model != "openai/gpt-5" {
t.Fatalf("the node did not keep its model: %+v", node)
}
- // THE MODEL NEVER BUYS ITS CELLS FROM THE NAME. The first line is the state
- // glyph, the title and the handle — nothing else — and the model rides the
- // telemetry row under it (task.go's [app.railTelemetry]), which is a row that
- // gives up its own tail rather than the title's cells.
- full := plain(strings.Join(a.railNodeRows(node, railCols), "\n"))
- head, under, _ := strings.Cut(full, "\n")
- if strings.Contains(head, "gpt-5") || !strings.Contains(head, "#7") {
- t.Fatalf("the model is on the title's line:\n%s", full)
- }
- if !strings.Contains(under, "gpt-5") {
- t.Fatalf("the rail did not carry the model under the title:\n%s", full)
- }
- // A LONG NAME IS NOW THE SAME ROW. It used to cost the row its model, because
- // the model was measured against the title; nothing is measured against the
- // title any more.
+ // THE MODEL NEVER BUYS ITS CELLS FROM THE NAME. The row is the state glyph,
+ // the name and the clock, one line, and the model is the hint line's under
+ // the pointer (sidecol.go's [app.sideHoverWords] reads the telemetry,
+ // task.go's [app.railTelemetry]).
+ row := plain(a.railEntryRow(railEntry{node: node, group: railRunning}, 28))
+ if strings.Contains(row, "gpt-5") || !strings.Contains(row, "Fix the nil-map") {
+ t.Fatalf("the model is on the task's row, or the name is not: %q", row)
+ }
+ a.hot = hoverAt{kind: hoverRail, id: node.id}
+ if hint := a.sideHoverWords(); !strings.Contains(hint, "gpt-5") || !strings.Contains(hint, "Fix the nil-map crash") {
+ t.Fatalf("the hint line over the row does not carry the name and the model: %q", hint)
+ }
+ // A LONG MODEL IS NEVER CUT ON THE HINT LINE: it has the room the row did
+ // not.
node.model = "anthropic/claude-opus-4.8"
- long := plain(strings.Join(a.railNodeRows(node, railCols), "\n"))
- if !strings.Contains(long, "claude-opus-4.8") || !strings.Contains(long, "#7") {
- t.Fatalf("a long model cost the row one of its two facts:\n%s", long)
+ if hint := a.sideHoverWords(); !strings.Contains(hint, "claude-opus-4.8") {
+ t.Fatalf("a long model was cut from the hint line: %q", hint)
}
node.model = "openai/gpt-5"
- narrow := plain(strings.Join(a.railNodeRows(node, railSlimCols), "\n"))
- if !strings.Contains(narrow, "gpt-5") || !strings.Contains(narrow, "#7") {
- t.Fatalf("a slim rail dropped the model with cells to spare:\n%s", narrow)
- }
+ a.hot = hoverAt{}
advance(2 * time.Minute)
drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, "Fix the nil-map crash", session.TaskDone,
diff --git a/internal/tui3/taskphase_test.go b/internal/tui3/taskphase_test.go
index 656ff6cdca..bc5a1547c6 100644
--- a/internal/tui3/taskphase_test.go
+++ b/internal/tui3/taskphase_test.go
@@ -59,9 +59,9 @@ func TestANodeUnderTheCheckSaysWhatIsHappeningAtEveryWidth(t *testing.T) {
width int
want []string
}{
- {underWidth(railWideCols), []string{taskCheckingWord, "42s · 9.9k · $0.31 · gpt-5"}},
- {underWidth(railCols), []string{taskCheckingWord, "42s · 9.9k · $0.31 · gpt-5"}},
- {underWidth(railSlimCols), []string{"checking what it le…", "42s · 9.9k · $0.31"}},
+ {underWidth(underWideCols), []string{taskCheckingWord, "42s · 9.9k · $0.31 · gpt-5"}},
+ {underWidth(underCols), []string{taskCheckingWord, "42s · 9.9k · $0.31 · gpt-5"}},
+ {underWidth(underSlimCols), []string{"checking what it le…", "42s · 9.9k · $0.31"}},
} {
rows := a.railUnder(node, tc.width)
if len(rows) != len(tc.want) {
@@ -78,11 +78,11 @@ func TestANodeUnderTheCheckSaysWhatIsHappeningAtEveryWidth(t *testing.T) {
}
// And through the column a person actually reads, at the widths the rail
- // itself narrows to — where the row is cut from the right and the words that
- // name the moment are the ones that survive.
+ // itself narrows to: the row is one line, and what the block says is on the
+ // hint line over it (sidecol.go's [app.sideTaskHint]).
for width, want := range map[int]string{200: "checking what it le", 110: "checking what it le"} {
a.width = width
- roster := rosterText(a, 12)
+ roster := rosterText(a, 12) + "\n" + railHint(a, 7)
if !strings.Contains(roster, want) {
t.Fatalf("the roster at %d columns does not say the work is being checked:\n%s", width, roster)
}
@@ -99,7 +99,7 @@ func TestARepairRoundSaysTheRoundAndTheFinding(t *testing.T) {
drive(t, a, taskEventMsg{gen: a.taskGen, ev: phaseMove(7, session.TaskPhaseRepairing, 1, 1,
"not done — go test reports no test files")})
- rows := a.railUnder(node, underWidth(railWideCols))
+ rows := a.railUnder(node, underWidth(underWideCols))
want := []string{"closing gaps · round 1 of 1", "not done — go test reports no test files"}
if len(rows) != len(want) {
t.Fatalf("the under-block is %d rows, want %d:\n%q", len(rows), len(want), rows)
@@ -113,15 +113,14 @@ func TestARepairRoundSaysTheRoundAndTheFinding(t *testing.T) {
// engine that sends both is an engine whose repair round is running, and
// "closing gaps · round 1 of 1" is that same news with the round on it.
node.mending = "go test reports no test files"
- if got := plain(a.railUnder(node, underWidth(railWideCols))[0]); got != "closing gaps · round 1 of 1" {
+ if got := plain(a.railUnder(node, underWidth(underWideCols))[0]); got != "closing gaps · round 1 of 1" {
t.Fatalf("the gap line took the row back: %q", got)
}
- // And through the column a person actually reads, where the rail's own width
- // cuts both rows from the right — the round and the opener, which are the
- // halves that name the moment, always survive.
+ // And through the column a person actually reads: the hint line over the
+ // row carries both, the round and the opener.
a.width = 200
- roster := rosterText(a, 12)
+ roster := rosterText(a, 12) + "\n" + railHint(a, 7)
for _, line := range []string{"closing gaps · round 1 of", "not done — go test report"} {
if !strings.Contains(roster, line) {
t.Fatalf("the roster does not carry %q:\n%s", line, roster)
@@ -146,7 +145,7 @@ func TestANodeBackAtWorkDrawsNothingExtra(t *testing.T) {
}
drive(t, a, taskEventMsg{gen: a.taskGen, ev: phaseMove(7, session.TaskPhaseWorking, 0, 0, "")})
- rows := a.railUnder(node, underWidth(railCols))
+ rows := a.railUnder(node, underWidth(underCols))
if len(rows) != 1 {
t.Fatalf("a working node's under-block is %d rows, want the telemetry alone:\n%q", len(rows), rows)
}
@@ -258,7 +257,7 @@ func TestANodeBeingSizedSaysSoOnEveryColumn(t *testing.T) {
drive(t, a, taskEventMsg{gen: a.taskGen, ev: phaseMove(7, session.TaskPhaseSizing, 0, 0, "")})
// THE RAIL, with the telemetry a person opens this column for kept under it.
- rows := a.railUnder(node, underWidth(railWideCols))
+ rows := a.railUnder(node, underWidth(underWideCols))
want := []string{taskSizingWord, "42s · 9.9k · $0.31 · gpt-5"}
if len(rows) != len(want) {
t.Fatalf("the under-block is %d rows, want %d:\n%q", len(rows), len(want), rows)
@@ -377,7 +376,7 @@ func TestARequestASizingIsWaitingOnIsDrawnOnTheRailAndInTheRoom(t *testing.T) {
}
frame := func() (rail, room string) {
a.room.dirty = true
- return plain(strings.Join(a.railPhase(node, underWidth(railWideCols)), "\n")), roomText(a)
+ return plain(strings.Join(a.railPhase(node, underWidth(underWideCols)), "\n")), roomText(a)
}
// BEFORE: the phase and the ladder, and nothing that moves.
diff --git a/internal/tui3/taskplan_test.go b/internal/tui3/taskplan_test.go
index 57f021440f..64de98127d 100644
--- a/internal/tui3/taskplan_test.go
+++ b/internal/tui3/taskplan_test.go
@@ -220,8 +220,15 @@ func openWorkTabNow(t *testing.T, a *app) {
// planLine is the LIST line a title is on — the half of the frame left of the
// seam, because the record pane beside it previews the cursor row and would
// answer with the title twice.
+//
+// THE HEAD IS SKIPPED: the strip of chats is on every page and carries the
+// run's tab, so a title can stand on it as well as on the pane's own row.
func planLine(text, title string) (string, bool) {
- for _, line := range strings.Split(text, "\n") {
+ lines := strings.Split(text, "\n")
+ if len(lines) > placeHeadRows {
+ lines = lines[placeHeadRows:]
+ }
+ for _, line := range lines {
if at := strings.LastIndex(line, railSeam); at >= 0 {
line = line[:at]
}
diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go
index 1e8b3740bd..46c4cbc9ac 100644
--- a/internal/tui3/tasksplace.go
+++ b/internal/tui3/tasksplace.go
@@ -1227,29 +1227,15 @@ func tasksTreeOf(items []tasksItem, now time.Time, order tasksSort, chats ...ses
}
}
group := map[string]int{}
- // THE TITLELESS ROW IS NAMED BY HOME'S OWN LADDER, never by the raw id its
- // Title carries: the stem is the session's id, and an id is a machine's
- // word in the one column whose whole job is matching names. Home solved this
- // for its own lists with one spelling of the word ([listName]'s defect
- // note), and this place draws the same row, so it reads the name from there
- // rather than spelling it a second time here.
- //
- // THE TEST IS EQUALITY WITH THE ROW'S OWN ID, FOLDED, because a titleless
- // row's Title is never empty — [tasksConversationRows] fills it with
- // [homeName], whose title case has already raised the id's first letter, so
- // the composer sees `De9ea39e6f4c18c3` where the id is `de9ea39e6f4c18c3` and
- // a byte-exact check never fires. EqualFold is the one comparison that does.
+ // THE TITLELESS ROW IS NAMED BY [homeName], the one rule home's sessions
+ // list uses too. Spelling the id check here again would be a second rule,
+ // and the two would drift the day one of them changed.
//
// ONLY THE NAME CHANGES. The row keeps its place, its age stays empty
// (lastUserAt is the zero time and the emptiness law draws nothing), and
// [chatProjectWord] still sees the row, so a project that is not this name
// does not echo the word back as a project tag beside it.
- tasksName := func(row session.SessionRow) string {
- if strings.EqualFold(strings.TrimSpace(row.Title), row.ID) || (row.Title == "" && row.Transcript != "") {
- return unnamedConversationWord
- }
- return homeName(row)
- }
+ tasksName := func(row session.SessionRow) string { return homeName(row) }
for _, root := range roots {
id := tasksChatOf(root)
row, named := names[id]
diff --git a/internal/tui3/taskstatus_test.go b/internal/tui3/taskstatus_test.go
index 924b45ad64..3d65bb399a 100644
--- a/internal/tui3/taskstatus_test.go
+++ b/internal/tui3/taskstatus_test.go
@@ -166,6 +166,7 @@ func TestUnlandedEditsAskForAPersonWithoutMovingTheState(t *testing.T) {
a := statusApp(t)
node := &taskNode{id: 6, state: session.TaskDone, merge: mergeWordConflicted, branch: "task/fix-nil"}
a.tasks[6] = node
+ a.taskOrder = append(a.taskOrder, 6)
status := a.taskStatus(node)
if status.Presence != session.TaskPresenceDone {
t.Errorf("presence = %q, want %q", status.Presence, session.TaskPresenceDone)
@@ -176,8 +177,8 @@ func TestUnlandedEditsAskForAPersonWithoutMovingTheState(t *testing.T) {
if group := a.railGroupOf(node); group != railAttention {
t.Errorf("the roster files unlanded edits under %q", railGroupWords[group])
}
- if rank := a.railGlyphRank(node); rank != 0 {
- t.Errorf("unlanded edits rank %d, want the loudest", rank)
+ if items := a.sideBand(); len(items) != 1 || !items[0].ask || items[0].key != "task/6" {
+ t.Errorf("unlanded edits are not the band's one amber item: %+v", items)
}
}
diff --git a/internal/tui3/taskstrip.go b/internal/tui3/taskstrip.go
index 7b3d0dd417..edb0bf0dee 100644
--- a/internal/tui3/taskstrip.go
+++ b/internal/tui3/taskstrip.go
@@ -218,9 +218,7 @@ func (a *app) stripShowing() bool {
// is left for this row is the frame the roster cannot have — under
// [railSlimFloor], with nobody asking for the overlay — a column somebody
// closed with ctrl+g, and the harness chip above.
- // AND BESIDE THE MANAGER'S TRAFFIC, whose header already carries the
- // manager's tasks as one quiet word (teamrail.go).
- if a.railStanding() || (a.trafficOn() && a.trafficFits()) {
+ if a.railStanding() {
return false
}
for _, id := range a.taskOrder {
diff --git a/internal/tui3/taskstrip_test.go b/internal/tui3/taskstrip_test.go
index 8d10a2df60..3f80ee9838 100644
--- a/internal/tui3/taskstrip_test.go
+++ b/internal/tui3/taskstrip_test.go
@@ -256,6 +256,7 @@ func TestTheRosterOpensOverTheBodyOnANarrowFrame(t *testing.T) {
if a.railShowing() || a.railFull() {
t.Fatal("a narrow frame drew a roster nobody asked for")
}
+ railOpenAll(a)
drive(t, a, altT())
if !a.railFull() {
@@ -363,7 +364,7 @@ func TestARunsNodesReachTheForestThroughTheirNotices(t *testing.T) {
t.Fatalf("the run's own row hangs off %q, want a root", got)
}
kids, byKey := a.railKin()
- if len(kids[stripKey(a.tasks[1])]) != 2 || railRootOf(a.tasks[3], byKey) != a.tasks[1] {
+ if len(kids[stripKey(a.tasks[1])]) != 2 || byKey[a.tasks[3].ParentID()] != a.tasks[1] {
t.Fatalf("the run's nodes did not reach the forest: %v", kids)
}
// AND KINSHIP IS KEPT. A later update that says nothing about the parent has
diff --git a/internal/tui3/tasktier_test.go b/internal/tui3/tasktier_test.go
index d0ecb8096e..b48107d177 100644
--- a/internal/tui3/tasktier_test.go
+++ b/internal/tui3/tasktier_test.go
@@ -233,33 +233,40 @@ func TestTheLinearTierStillSpellsEveryState(t *testing.T) {
// ── the rail ────────────────────────────────────────────────────────────────
-// THE ORDER IS THE TIER'S: the person's call first, then work in flight, then
-// work that is over — with the one rank the design keeps for the news a folded
-// family wears, because "something in here did not come off" is louder than
-// "something in here has not started".
-func TestTheRailRanksYourCallFirstThenMovingThenOver(t *testing.T) {
+// THE ORDER IS THE TIER'S: the person's call first, in amber in the band, then
+// a failure nobody has opened, in ink in the band, then work in flight, then
+// work that has not started, then work that is over, each under its heading.
+func TestTheColumnFilesYourCallFirstThenMovingThenOver(t *testing.T) {
a, _, _ := taskApp(t)
drive(t, a,
streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})},
streamEventMsg{gen: a.gen, ev: update(2, "Mix audio", session.TaskQueued, session.TaskNotice{})},
streamEventMsg{gen: a.gen, ev: update(3, "Fix the nil-map", session.TaskRunning, session.TaskNotice{})},
+ streamEventMsg{gen: a.gen, ev: update(4, "Cut the goldens", session.TaskRunning, session.TaskNotice{})},
streamEventMsg{gen: a.gen, ev: update(4, "Cut the goldens", session.TaskFailed, session.TaskNotice{Ending: session.TaskEndingSteps})},
streamEventMsg{gen: a.gen, ev: update(5, "Port the parser", session.TaskUnverified, session.TaskNotice{Merge: mergeWordAborted, Branch: "task/parser"})},
)
- ranks := map[uint64]int{}
- for id := uint64(1); id <= 5; id++ {
- ranks[id] = a.railGlyphRank(a.tasks[id])
+ band := a.sideBand()
+ if len(band) != 2 || band[0].key != "task/5" || !band[0].ask || band[1].key != "fail/4" || band[1].ask {
+ t.Fatalf("the band is not the question in amber then the failure in ink: %+v", band)
+ }
+ railOpenAll(a)
+ var order []uint64
+ for _, e := range a.railEntries() {
+ if e.node != nil {
+ order = append(order, e.node.id)
+ }
}
- if !(ranks[5] < ranks[3] && ranks[3] < ranks[4] && ranks[4] < ranks[2] && ranks[2] < ranks[1]) {
- t.Fatalf("the rail ranks are out of order: %v", ranks)
+ if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 {
+ t.Fatalf("the list is %v, want running 3, queued 2, done 1, and nothing the band holds", order)
}
}
-// A FOLDED FAMILY WEARS ITS LOUDEST CHILD, and a child whose decision belongs to
-// the parent's own agent is not what makes the fold a demand — the parent is
-// already holding that question. It is read off the ask's owner and off nothing
-// else, and the row still says what it is asking about.
-func TestAFoldedFamilyWearsItsLoudestChild(t *testing.T) {
+// A QUESTION IS THE PERSON'S UNTIL SOMEBODY ELSE HOLDS IT. A child whose
+// decision belongs to the parent's own agent is not a demand on the person,
+// so it leaves the band; it is read off the ask's owner and off nothing else,
+// and the row still says what it is asking about.
+func TestAQuestionSomebodyElseHoldsLeavesTheBand(t *testing.T) {
a, _, _ := taskApp(t)
drive(t, a,
streamEventMsg{gen: a.gen, ev: update(1, "Port the parser", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})},
@@ -267,20 +274,18 @@ func TestAFoldedFamilyWearsItsLoudestChild(t *testing.T) {
Merge: mergeWordAborted, Branch: "task/goldens",
})},
)
- // A ROOT QUESTION NOBODY ELSE IS HOLDING IS THE LOUDEST THING ON THE COLUMN.
kid := a.tasks[2]
- if got := a.railGlyphRank(kid); got != 0 {
- t.Fatalf("an unanswered question ranks %d, want 0", got)
+ if band := a.sideBand(); len(band) != 1 || band[0].key != "task/2" || !band[0].ask {
+ t.Fatalf("an unanswered question is not the band's: %+v", band)
}
if glyph := plain(a.railTreeGlyph(kid)); glyph != glyphAsk {
t.Fatalf("the child wears %q, want %q", glyph, glyphAsk)
}
- // Hand the decision to the model and the fold stops being a demand — and the
- // row goes on reading its reason either way, because a person watching it is
- // owed what it is asking about whoever is answering.
kid.decider = session.TaskAskOwnerModel
- if got := a.railGlyphRank(kid); got == 0 {
- t.Fatalf("a question the model holds still ranks as a demand")
+ for _, item := range a.sideBand() {
+ if item.key == "task/2" {
+ t.Fatalf("a question the model holds is still in the band: %+v", item)
+ }
}
if group := a.railGroupOf(kid); group == railDone {
t.Fatalf("a question nobody has answered is filed under %q", railGroupWords[group])
@@ -530,5 +535,10 @@ func railSaid(a *app) string {
}
out = append(out, row)
}
+ // AND THE HINT LINE OVER EACH ROW, which is where what a row has no room
+ // for is said now that every row is one line.
+ for _, id := range a.taskOrder {
+ out = append(out, railHint(a, id))
+ }
return strings.Join(strings.Fields(strings.Join(out, " ")), " ")
}
diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go
index 51bb949915..38eceebf7a 100644
--- a/internal/tui3/taskview.go
+++ b/internal/tui3/taskview.go
@@ -275,24 +275,17 @@ const (
// the top of the list now ([tasksControlRow]), where the typing lands, and an
// echo under the list would be the frame saying one thing twice.
taskSheetFilterNone = "nothing matches"
- // taskSheetMoreHint is the line at the bottom of the ROSTER'S COLUMN that
- // reaches this page (task.go's [app.railFootRows]). It is shaped like the two
- // lines under it — the key, then what it reaches — and it is drawn only when
- // there is genuinely more here than the column is showing (task.go's
- // [app.railFootRows] weighs it against [app.railFoldedAny] and
- // [app.railHasRecord]).
- taskSheetMoreHint = taskSheetKey + " view more"
- // taskSheetPastHint is that SAME LINE when what is behind it is the project's
- // own record, and it is the commoner of the two by a long way: any directory
- // that has been worked in before has one.
+ // taskSheetPastHint is the line at the bottom of the ROSTER'S COLUMN that
+ // reaches this page (task.go's [app.railFootRows]), drawn when what is
+ // behind it is the project's own record: any directory that has been worked
+ // in before has one.
//
- // IT IS ONE DOOR WEARING THE NAME OF WHAT IT OPENS, not a second door. The
- // column has exactly one line onto this page and the words on it say which
- // question the page will answer — "earlier" when there is history down there,
- // "view more" when the only thing the column is holding back is a family it
- // folded. A permanent "view more" over a month of finished work never told
- // anybody the work existed, which is the whole reason the record was ever
- // footnoted onto the column in the first place.
+ // IT WEARS THE NAME OF WHAT IT OPENS. It used to say `view more` as well,
+ // when the only thing the column held back was a family it folded; the
+ // column folds its own groups now and a press on the heading opens them, so
+ // "earlier" is the one thing this line can promise. A permanent "view more"
+ // over a month of finished work never told anybody the work existed, which is
+ // the whole reason the record was ever footnoted onto the column.
//
// It is [taskSheetPastHead]'s own word rather than a second one, because it is
// the section it lands you in.
diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go
index 02078c44ac..dad024e6b6 100644
--- a/internal/tui3/taskview_test.go
+++ b/internal/tui3/taskview_test.go
@@ -688,7 +688,7 @@ func TestTheColumnOffersItsDoorOnlyWhenThereIsSomethingBehindIt(t *testing.T) {
// One node, nothing folded, and no record: the column is showing the whole of
// what there is to show.
a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{}))
- for _, gone := range []string{taskSheetMoreHint, taskSheetPastHint} {
+ for _, gone := range []string{taskSheetKey + " view more", taskSheetPastHint} {
if rail := rosterText(a, a.viewHeight()); strings.Contains(rail, gone) {
t.Fatalf("the column offered %q with nothing behind it:\n%s", gone, rail)
}
@@ -700,7 +700,7 @@ func TestTheColumnOffersItsDoorOnlyWhenThereIsSomethingBehindIt(t *testing.T) {
pastTask("1", "ship-the-port", "Ship the port", time.Minute),
}
a.comp.tasks[0].SessionID = a.taskSheetSelfID()
- for _, gone := range []string{taskSheetMoreHint, taskSheetPastHint} {
+ for _, gone := range []string{taskSheetKey + " view more", taskSheetPastHint} {
if rail := rosterText(a, a.viewHeight()); strings.Contains(rail, gone) {
t.Fatalf("the column offered %q for a row it is already drawing:\n%s", gone, rail)
}
@@ -727,41 +727,21 @@ func TestTheColumnOffersItsDoorOnlyWhenThereIsSomethingBehindIt(t *testing.T) {
t.Fatalf("the page door does not use the shared hint palette:\n%q", painted)
}
// The history door stays in the footer after the task actions and hide control.
- if hide, history := strings.Index(rail, railStowHint), strings.Index(rail, taskSheetPastHint); hide < 0 || history <= hide {
+ if hide, history := strings.Index(rail, sideHideKey), strings.Index(rail, taskSheetPastHint); hide < 0 || history <= hide {
t.Fatalf("history does not follow the hide control:\n%s", rail)
}
}
-// A FOLDED FAMILY EARNS IT TOO, because a folded root is one row standing for
-// work the column is deliberately not drawing — and with no record behind it the
-// same line says `view more` instead, because there is no earlier work to
-// promise.
-func TestAFoldedFamilyEarnsTheViewMoreLine(t *testing.T) {
- a, _, _ := taskApp(t)
- a.profileDir = t.TempDir()
- railRun(a)
- if rail := rosterText(a, a.viewHeight()); strings.Contains(rail, taskSheetMoreHint) {
- t.Fatalf("an open column with no record offered more:\n%s", rail)
- }
- a.railSetOpen(a.tasks[1], false)
- rail := rosterText(a, a.viewHeight())
- if !strings.Contains(rail, taskSheetMoreHint) {
- t.Fatalf("a folded family did not earn the line:\n%s", rail)
- }
- if strings.Contains(rail, taskSheetPastHint) {
- t.Fatalf("a column with no record promised earlier work:\n%s", rail)
- }
-}
-
// AND THE LINE IS A BUTTON AS WELL AS A KEY. A row that names a chord and cannot
// be pressed is an affordance for one of the two hands — and one that cannot
// light under the pointer is a button nothing says is a button.
func TestPressingViewMoreOpensTheTaskPage(t *testing.T) {
a, _, _ := taskApp(t)
+ a.file = "/w/.codeaf/v3/sessions/-w/current/session.jsonl"
a.profileDir = t.TempDir()
railRun(a)
- a.railSetOpen(a.tasks[1], false)
+ a.comp.tasks = []session.TaskIndexEntry{pastTask("9", "port-the-parser", "Port the parser", 40*time.Hour)}
at := railMoreLine(t, a)
if _, took := a.railPress(a.bodyWidth()+4, at+a.topHeight()); !took {
@@ -806,7 +786,7 @@ func railMoreLine(t *testing.T, a *app) int {
// ── the column keeps what is running ────────────────────────────────────────
-// The hide control stays pinned while all tasks scroll in creation order.
+// The header stays pinned while every task scrolls.
func TestSidebarHeaderStaysWhileRunningWorkScrolls(t *testing.T) {
a, _, _ := taskApp(t)
a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{}))
@@ -814,6 +794,7 @@ func TestSidebarHeaderStaysWhileRunningWorkScrolls(t *testing.T) {
a.taskUpdate(update(uint64(i), "landed "+itoa(i), session.TaskDone,
session.TaskNotice{Merge: mergeWordMerged}))
}
+ railOpenAll(a)
drive(t, a, altT())
for i := 0; i < 40; i++ {
@@ -824,7 +805,7 @@ func TestSidebarHeaderStaysWhileRunningWorkScrolls(t *testing.T) {
t.Fatalf("the running task did not scroll with the list:\n%s", rail)
}
// The hide control remains the first row.
- if !strings.Contains(railText(a, a.viewHeight())[0], railStowHint) {
+ if !strings.Contains(railText(a, a.viewHeight())[0], sideHideKey) {
t.Fatal("the hide control moved with the task list")
}
if strings.Contains(rail, "landed 2 ") {
@@ -998,12 +979,11 @@ func TestAColumnFullOfRunningWorkKeepsAllOfItAndStillOffersTheDoor(t *testing.T)
if strings.Contains(rail, "Port the parser") {
t.Fatalf("the record took a row from running work:\n%s", rail)
}
- // FOUR AND NOT FIVE, because the column opens with its section label now
- // (margin.go): the label is geography and it is pinned above the work like
- // every other line of the moving head, so a nine-row column spends one of its
- // rows saying where it is. What it must never spend a row on is the RECORD,
- // which is what this test is about and is still true above.
- for i := 1; i <= 4; i++ {
+ // THE NEWEST FOUR, because the column opens with its header and the group's
+ // heading, and the newest work is first under it. What it must never spend a
+ // row on is the RECORD, which is what this test is about and is still true
+ // above.
+ for i := 8; i >= 5; i-- {
if !strings.Contains(rail, "running "+itoa(i)) {
t.Fatalf("running %d was evicted from the column:\n%s", i, rail)
}
@@ -1030,7 +1010,7 @@ func TestAnEmptySessionSaysSoAndStillNamesTheDoorOntoTheRecord(t *testing.T) {
if strings.Contains(bare, "no tasks yet") {
t.Fatalf("an empty column announced its emptiness:\n%s", bare)
}
- for _, gone := range []string{taskSheetPastHint, taskSheetMoreHint} {
+ for _, gone := range []string{taskSheetPastHint, taskSheetKey + " view more"} {
if strings.Contains(bare, gone) {
t.Fatalf("an empty project drew %q, which it does not have:\n%s", gone, bare)
}
@@ -1081,6 +1061,7 @@ func TestTheRostersCursorStopsAtTheLastTaskOfThisConversation(t *testing.T) {
a.comp.tasks = []session.TaskIndexEntry{
pastTask("9", "port-the-parser", "Port the parser", time.Hour),
}
+ railOpenAll(a)
rosterText(a, a.viewHeight())
drive(t, a, altT())
diff --git a/internal/tui3/taskwords_test.go b/internal/tui3/taskwords_test.go
index e63c30bccd..57160c79cc 100644
--- a/internal/tui3/taskwords_test.go
+++ b/internal/tui3/taskwords_test.go
@@ -58,8 +58,8 @@ func TestAFinishingNodeSaysWhatItIsClosingAtEveryWidth(t *testing.T) {
width int
want []string
}{
- {underWidth(railCols), []string{"finishing · adding amp-la…", "42s · 9.9k · $0.31 · gpt-5"}},
- {underWidth(railSlimCols), []string{"finishing · adding …", "42s · 9.9k · $0.31"}},
+ {underWidth(underCols), []string{"finishing · adding amp-la…", "42s · 9.9k · $0.31 · gpt-5"}},
+ {underWidth(underSlimCols), []string{"finishing · adding …", "42s · 9.9k · $0.31"}},
} {
rows := a.railUnder(node, tc.width)
if len(rows) != len(tc.want) {
@@ -79,18 +79,18 @@ func TestAFinishingNodeSaysWhatItIsClosingAtEveryWidth(t *testing.T) {
// at two ([railUnderRows]) and the call the node happens to be inside of says
// nothing the finishing line and the telemetry do not already say better.
node.tool, node.toolBegan = "bash go test ./...", a.now().Add(-24*time.Second)
- rows := a.railUnder(node, underWidth(railCols))
+ rows := a.railUnder(node, underWidth(underCols))
if len(rows) != railUnderRows || plain(rows[0]) != "finishing · adding amp-la…" {
t.Fatalf("a live call displaced the finishing line:\n%q", rows)
}
node.tool, node.toolBegan = "", time.Time{}
- // AND THE COLUMN ITSELF DRAWS IT, at both widths the roster has. The rows
- // above are the block; this is the block on screen, under the node's own name
- // and in the running group.
+ // AND THE COLUMN ITSELF SAYS IT, at both widths the roster has. The rows
+ // above are the block; this is the block on screen, on the hint line over
+ // the node's own row in the running group.
for _, width := range []int{200, 110} {
a.width = width
- roster := rosterText(a, 12)
+ roster := rosterText(a, 12) + "\n" + railHint(a, 7)
if !strings.Contains(roster, "finishing · adding") {
t.Fatalf("the roster at %d columns does not say what the node is finishing:\n%s", width, roster)
}
@@ -108,7 +108,7 @@ func TestTheFinishingLineDropsWhenTheGapIsClosed(t *testing.T) {
node := a.tasks[7]
advance(42 * time.Second)
node.tool, node.toolBegan = "bash go test ./...", a.now().Add(-24*time.Second)
- if got := plain(a.railUnder(node, underWidth(railCols))[0]); !strings.HasPrefix(got, taskFinishingWord) {
+ if got := plain(a.railUnder(node, underWidth(underCols))[0]); !strings.HasPrefix(got, taskFinishingWord) {
t.Fatalf("the finishing line is not on the node at all: %q", got)
}
@@ -120,7 +120,7 @@ func TestTheFinishingLineDropsWhenTheGapIsClosed(t *testing.T) {
if node.mending != "" {
t.Fatalf("the node still carries %q", node.mending)
}
- rows := a.railUnder(node, underWidth(railCols))
+ rows := a.railUnder(node, underWidth(underCols))
if len(rows) != railUnderRows {
t.Fatalf("the under-block is %d rows after the gap closed:\n%q", len(rows), rows)
}
@@ -195,7 +195,7 @@ func TestAHeldNodeSaysWhatIsHoldingItAtEveryWidth(t *testing.T) {
for _, w := range []struct {
width int
want string
- }{{underWidth(railCols), tc.full}, {underWidth(railSlimCols), tc.slim}} {
+ }{{underWidth(underCols), tc.full}, {underWidth(underSlimCols), tc.slim}} {
rows := a.railUnder(node, w.width)
if len(rows) != 1 || plain(rows[0]) != w.want {
t.Fatalf("at %d cells the under-block is %q, want one row %q", w.width, rows, w.want)
@@ -204,10 +204,11 @@ func TestAHeldNodeSaysWhatIsHoldingItAtEveryWidth(t *testing.T) {
t.Fatalf("at %d cells the row is %d cells wide", w.width, got)
}
}
- // AND THE COLUMN ITSELF DRAWS IT, at both widths the roster has.
+ // AND THE COLUMN ITSELF SAYS IT, at both widths the roster has, on
+ // the hint line over the row.
for _, width := range []int{200, 110} {
a.width = width
- if roster := rosterText(a, 12); !strings.Contains(roster, a.taskStatus(node).Word+" · ") {
+ if roster := rosterText(a, 12) + "\n" + railHint(a, 7); !strings.Contains(roster, a.taskStatus(node).Word+" · ") {
t.Fatalf("the roster at %d columns does not say the node is held:\n%s", width, roster)
}
}
@@ -222,7 +223,7 @@ func TestAHeldNodeSaysWhatIsHoldingItAtEveryWidth(t *testing.T) {
if node.waiting != "" {
t.Fatalf("the node still carries %q", node.waiting)
}
- if rows := a.railUnder(node, underWidth(railCols)); len(rows) != 0 {
+ if rows := a.railUnder(node, underWidth(underCols)); len(rows) != 0 {
t.Fatalf("the under-block outlived the hold: %q", rows)
}
if strings.Contains(rosterText(a, 12), taskHeldWord) {
@@ -249,8 +250,8 @@ func TestAPacedNodeWaitsWhereItsToolLineWouldBe(t *testing.T) {
width int
want []string
}{
- {underWidth(railCols), []string{"waiting · rate limited", "42s · 9.9k · $0.31 · gpt-5"}},
- {underWidth(railSlimCols), []string{"waiting · rate limi…", "42s · 9.9k · $0.31"}},
+ {underWidth(underCols), []string{"waiting · rate limited", "42s · 9.9k · $0.31 · gpt-5"}},
+ {underWidth(underSlimCols), []string{"waiting · rate limi…", "42s · 9.9k · $0.31"}},
} {
rows := a.railUnder(node, tc.width)
if len(rows) != len(tc.want) {
@@ -273,7 +274,7 @@ func TestAPacedNodeWaitsWhereItsToolLineWouldBe(t *testing.T) {
// but the hold.
drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Write the report", session.TaskRunning,
session.TaskNotice{Model: "openai/gpt-5", CostUSD: 0.31})})
- rows := a.railUnder(node, underWidth(railCols))
+ rows := a.railUnder(node, underWidth(underCols))
if len(rows) != railUnderRows || plain(rows[0]) != "bash go test ./... · 24s" {
t.Fatalf("the call row did not come back:\n%q", rows)
}
@@ -291,7 +292,7 @@ func TestAWaitingDependencyOutranksTheHoldWord(t *testing.T) {
})},
)
node := a.tasks[2]
- got := plain(strings.Join(a.railUnder(node, underWidth(railCols)), "\n"))
+ got := plain(strings.Join(a.railUnder(node, underWidth(underCols)), "\n"))
if !strings.Contains(got, "waits: Collect sources") {
t.Fatalf("the blocked node says %q, want the prerequisite it waits on", got)
}
@@ -306,7 +307,7 @@ func TestAWaitingDependencyOutranksTheHoldWord(t *testing.T) {
// unblocked, it is still not running, and the row says why.
drive(t, a, streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone,
session.TaskNotice{Merge: mergeWordMerged})})
- if got := plain(strings.Join(a.railUnder(node, underWidth(railCols)), "\n")); got != "queued · slot" {
+ if got := plain(strings.Join(a.railUnder(node, underWidth(underCols)), "\n")); got != "queued · slot" {
t.Fatalf("the unblocked node says %q, want the hold", got)
}
}
@@ -423,7 +424,8 @@ func TestNoTerminalStateEverSpeaksOfTheMachinery(t *testing.T) {
card.open = true
a.touch()
}
- seen := strings.ToLower(taskText(a) + "\n" + rosterText(a, 20))
+ railOpenAll(a)
+ seen := strings.ToLower(taskText(a) + "\n" + rosterText(a, 20) + "\n" + railHint(a, 7))
// A SWEEP OVER AN EMPTY SCREEN PASSES EVERYTHING, so the screen is
// proved to have the node on it before it is proved to be clean.
if !strings.Contains(seen, "port the parser") {
diff --git a/internal/tui3/teamclose.go b/internal/tui3/teamclose.go
new file mode 100644
index 0000000000..58ee63b2f1
--- /dev/null
+++ b/internal/tui3/teamclose.go
@@ -0,0 +1,474 @@
+package tui3
+
+import (
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── CLOSING, REOPENING AND DELETING A TEAM (DESIGN.md section 8.5, c-9) ─────
+//
+// A team is open or closed. `Close…` asks what to do only when there is
+// something to decide: with nothing running it closes at once and offers Undo,
+// because a question with one useful answer is friction. With work running the
+// card offers three ([app.teamSheetOpen]'s close mode):
+//
+// - `Wrap up first` (the default while a manager has work running): the
+// manager is asked, in the team's Traffic, to have everyone finish and
+// commit, answer what it can, and bring the person a closing report. The
+// team closes only when the person picks `Close` on that report, which is
+// an ordinary card in the inbox.
+// - `Close now`: every member's current turn is stopped (the person's own
+// Stop, nothing deleted), the team's tabs close, and the team moves to
+// Closed, in one step, with Undo.
+// - `Cancel`.
+//
+// A CONVERSATION THAT IS ALSO IN ANOTHER OPEN TEAM IS NEVER STOPPED OR CLOSED
+// by this: it is still that team's. Closing a team closes the teams under it
+// (internal/teams' [teamstore.File.Close]); reopening reopens exactly what the
+// close closed. Delete is only ever offered on a closed team, and forgets the
+// grouping, the Traffic and the packets; the conversations stay in history.
+
+// teamsUndoFor is how long a close offers Undo: the wall's own span for an
+// Apply, so a person learns one length of time for taking a thing back.
+const teamsUndoFor = wallOrganizedFor
+
+// teamsUndo is the last close this window made, for Undo.
+type teamsUndo struct {
+ team string
+ name string
+ shut []string
+ at time.Time
+}
+
+// teamsCloseKeys is every conversation a close of team id stops and whose tab
+// it closes: the members of the team and of every open team under it, less any
+// that is also in an open team outside them.
+func (a *app) teamsCloseKeys(id string) []string {
+ closing := map[string]bool{id: true}
+ for _, t := range a.teamTree().Descendants(id) {
+ if !t.Closed() {
+ closing[t.ID] = true
+ }
+ }
+ elsewhere := map[string]bool{}
+ for _, t := range a.wall.teams {
+ if closing[t.ID] || t.Closed() || t.Root {
+ continue
+ }
+ for _, m := range t.Members {
+ elsewhere[m.Key] = true
+ }
+ }
+ var keys []string
+ seen := map[string]bool{}
+ for _, t := range a.wall.teams {
+ if !closing[t.ID] {
+ continue
+ }
+ for _, m := range t.Members {
+ if elsewhere[m.Key] || seen[m.Key] {
+ continue
+ }
+ seen[m.Key] = true
+ keys = append(keys, m.Key)
+ }
+ }
+ return keys
+}
+
+// teamsRunning is the members of team id (and the teams under it) that are
+// working now, by handle or name, and whether its manager is one of them or
+// has any running at all.
+func (a *app) teamsRunning(id string) (names []string, managed bool) {
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil, false
+ }
+ front := a.frontTabKey()
+ for _, key := range a.teamsCloseKeys(id) {
+ if !a.trafficHeld(key) || a.tabSignalFor(key, key == front) != tabWorking {
+ continue
+ }
+ name := key
+ for _, u := range a.wall.teams {
+ if m, ok := u.Member(key); ok {
+ name = m.Word
+ if m.Handle != "" {
+ name = "@" + m.Handle
+ }
+ if key == u.Manager {
+ name = a.teamManagerMark() + " manager"
+ }
+ break
+ }
+ }
+ names = append(names, name)
+ }
+ return names, t.Manager != "" && len(names) > 0
+}
+
+// teamsCloseAsk is `Close…`: at once with Undo when nothing runs, and the card
+// when something does.
+func (a *app) teamsCloseAsk(id string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || t.Closed() {
+ return nil
+ }
+ if t.Root {
+ a.tp.msg = "All teams does not close; remove its manager instead"
+ a.touch()
+ return nil
+ }
+ if a.teamsOff() {
+ a.tp.msg = teamHostedWord
+ a.touch()
+ return nil
+ }
+ if names, _ := a.teamsRunning(id); len(names) > 0 {
+ return a.teamSheetOpen(id, teamSheetClose)
+ }
+ return a.teamsCloseNow(id, "")
+}
+
+// teamsCloseNow closes team id at once: every member's turn stopped, the
+// team's tabs closed, the team moved to Closed, and Undo offered. report is the
+// closing report's packet id when the close was the report's `Close`.
+func (a *app) teamsCloseNow(id, report string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || t.Closed() || t.Root {
+ return nil
+ }
+ now := a.now()
+ shut := a.teamsStopMembers(id)
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(id, now, report) }); err != nil {
+ a.tp.msg = "not closed: " + err.Error()
+ a.touch()
+ return nil
+ }
+ return a.teamsAfterClose(t, shut, now, true)
+}
+
+// teamsStopMembers is the interface's half of every close (DESIGN.md 8.5):
+// each member of team id (and of the open teams under it, less any shared
+// with an open team outside them) that is working has its turn stopped, and
+// every one but the conversation in front has its tab closed. It answers the
+// tabs it closed, for Undo.
+func (a *app) teamsStopMembers(id string) []string {
+ front := a.frontTabKey()
+ var shut []string
+ for _, key := range a.teamsCloseKeys(id) {
+ if !a.trafficHeld(key) {
+ continue
+ }
+ // THE PERSON'S OWN STOP, and nothing more: the current turn ends and
+ // nothing is deleted (teamtraffic.go's [app.trafficStop]).
+ if a.tabSignalFor(key, key == front) == tabWorking {
+ a.trafficStop(key)
+ }
+ // The conversation in front is what this page hosts; its tab stays,
+ // because closing it here would move the person's focus.
+ if key != front {
+ a.tabShutKey(key)
+ shut = append(shut, key)
+ }
+ }
+ return shut
+}
+
+// teamsAfterClose is what follows a close on this window: Undo offered, the
+// selection and the wall moved off the team, and its Traffic told when tell
+// says the store did not tell it already.
+func (a *app) teamsAfterClose(t team, shut []string, now time.Time, tell bool) tea.Cmd {
+ id := t.ID
+ // On the page the Undo row says it; off it the close is said where the
+ // person is standing.
+ a.tp.undo = teamsUndo{team: id, name: t.Name, shut: shut, at: now}
+ if !a.at(pageTeams) {
+ a.note(t.Name + " is closed · its conversations are kept · Closed on the teams page reopens it")
+ }
+ if a.tp.sel == id {
+ a.tp.sel = ""
+ a.teamsSettle()
+ }
+ if a.wall.activeID == id {
+ a.wall.activeID = ""
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ if !tell {
+ return a.teamsBringManager()
+ }
+ return tea.Batch(a.teamsTell(id, teamstore.Entry{Kind: teamstore.KindClose, From: teamstore.FromYou, To: teamstore.ToEveryone,
+ Text: "the person closed the team"}), a.teamsBringManager())
+}
+
+// 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
+}
+
+// teamsUndoClose takes the last close back: the team reopened, and the tabs it
+// closed back on the strip (the conversations were held behind the whole time).
+func (a *app) teamsUndoClose() tea.Cmd {
+ if !a.teamsUndoing() {
+ return nil
+ }
+ u := a.tp.undo
+ a.tp.undo = teamsUndo{}
+ for _, key := range u.shut {
+ delete(a.tabShut, key)
+ }
+ a.chatTabBar = tabBar{}
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.Reopen(u.team) }); err != nil {
+ a.tp.msg = "not reopened: " + err.Error()
+ a.touch()
+ return nil
+ }
+ a.tp.msg = u.name + " is open again"
+ a.tp.sel = u.team
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return tea.Batch(a.teamsTell(u.team, teamstore.Entry{Kind: teamstore.KindReopen, From: teamstore.FromYou, To: teamstore.ToEveryone,
+ Text: "the person reopened the team"}), a.teamsBringManager())
+}
+
+// teamsReopen is `Reopen` on a closed team, and `Reopen too` on one
+// whose parent is closed: the closed teams above it are reopened first, top
+// down, because the store refuses a child under a closed parent
+// ([teamstore.ErrParentClosed]). Its members' tabs come back where this window
+// still holds them, and its manager resumes by being brought in front.
+func (a *app) teamsReopen(id string, withParents bool) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || !t.Closed() {
+ return nil
+ }
+ var chain []string
+ if withParents {
+ ups := a.teamAncestors(id)
+ for i := len(ups) - 1; i >= 0; i-- {
+ if ups[i].Closed() {
+ chain = append(chain, ups[i].ID)
+ }
+ }
+ } else if p, closed := a.teamsParentClosed(t); closed {
+ a.tp.msg = t.Name + " sits under " + p.Name + ", which is closed: reopen " + p.Name + " too"
+ a.touch()
+ return nil
+ }
+ chain = append(chain, id)
+ err := a.teamEdit(func(f *teamstore.File) error {
+ for _, c := range chain {
+ if u, ok := f.Team(c); ok && !u.Closed() {
+ continue
+ }
+ if err := f.Reopen(c); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ a.tp.msg = "not reopened: " + err.Error()
+ a.touch()
+ return nil
+ }
+ for _, c := range chain {
+ if u, ok := a.teamByID(c); ok {
+ for _, m := range u.Members {
+ delete(a.tabShut, m.Key)
+ }
+ }
+ }
+ a.chatTabBar = tabBar{}
+ a.tp.sel = id
+ a.tp.msg = t.Name + " is open again"
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ var tells []tea.Cmd
+ for _, c := range chain {
+ tells = append(tells, a.teamsTell(c, teamstore.Entry{Kind: teamstore.KindReopen, From: teamstore.FromYou, To: teamstore.ToEveryone,
+ Text: "the person reopened the team"}))
+ }
+ return tea.Batch(append(tells, a.teamsBringManager())...)
+}
+
+// teamsWrapUp is `Wrap up first`: the seam's wrap-up door appends the one
+// Traffic entry the manager's session reads as the request
+// (teamstore.WrapUpRequest, DESIGN.md 8.8), and the session does the rest: the
+// manager is woken, told to finish and commit, and brings a closing report,
+// which arrives here as a card; past its time or money codeaf raises the
+// report itself, marked incomplete. Over a connection whose engine has no
+// such door the page says so and offers the other two.
+func (a *app) teamsWrapUp(id string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || t.Manager == "" {
+ return nil
+ }
+ door := a.teamsSeam().WrapUp
+ if door == nil {
+ a.tp.msg = teamsNoWrapUpWord
+ a.touch()
+ return nil
+ }
+ a.tp.msg = "asked " + a.teamManagerMark() + " " + t.Name + "'s manager to wrap up · its closing report will be a card here"
+ a.touch()
+ return a.offLoop(func() func(bool) tea.Cmd {
+ err := door(id, "")
+ return func(bool) tea.Cmd {
+ if err != nil {
+ a.tp.msg = "the wrap-up was not asked for: " + err.Error()
+ a.touch()
+ }
+ return nil
+ }
+ })
+}
+
+// teamsNoWrapUpWord is what the close card and the page say where the seam has
+// no wrap-up door: an engine older than the doors, over --host.
+const teamsNoWrapUpWord = "Wrap up first is not offered over this connection: Close now, or Cancel"
+
+// teamsAcceptReport is the person's `Close` on a closing report: the interface
+// stops the team's turns and closes its tabs now, and the store closes the
+// team with the packet as its report (teamstore.AcceptClosing), after the
+// decision is written. The window then reads the teams again, because the
+// file moved under it.
+func (a *app) teamsAcceptReport(p teamstore.Packet, decision string) tea.Cmd {
+ t, ok := a.teamByID(p.Origin)
+ if !ok || t.Closed() {
+ return nil
+ }
+ seam := a.teamsSeam()
+ if seam.AcceptClosing == nil {
+ // No door to close it on its report: close it here, the report named.
+ cmd := a.teamsCloseNow(p.Origin, p.ID)
+ return tea.Batch(cmd, a.teamsDecideOnly(seam, p.ID, decision))
+ }
+ shut := a.teamsStopMembers(p.Origin)
+ reserved, now, id := teamReservedHues(a.pal), a.now(), p.ID
+ return a.offLoop(func() func(bool) tea.Cmd {
+ _, err := seam.Decide(id, teamstore.Person, decision, "")
+ closed := false
+ var teams []team
+ var stamp string
+ if err == nil {
+ closed, err = seam.AcceptClosing(id)
+ }
+ if err == nil {
+ teams, stamp, _, err = seam.ReadSince("", reserved)
+ }
+ return func(bool) tea.Cmd {
+ a.tp.packetsStamp = ""
+ if err != nil {
+ a.tp.msg = "not closed: " + err.Error()
+ a.touch()
+ return a.teamsRead(false)
+ }
+ a.teamAdopt(teamsClone(teams))
+ a.traffic.stamp = stamp
+ var cmd tea.Cmd
+ if closed {
+ cmd = a.teamsAfterClose(t, shut, now, false)
+ }
+ return tea.Batch(cmd, a.teamsRead(false))
+ }
+ })
+}
+
+// teamsDecideOnly writes the person's decision on packet id and reads the
+// packets again.
+func (a *app) teamsDecideOnly(seam TeamsSeam, id, decision string) tea.Cmd {
+ return a.offLoop(func() func(bool) tea.Cmd {
+ _, err := seam.Decide(id, teamstore.Person, decision, "")
+ return func(bool) tea.Cmd {
+ if err != nil {
+ a.tp.msg = "not decided: " + err.Error()
+ a.touch()
+ }
+ a.tp.packetsStamp = ""
+ return a.teamsRead(false)
+ }
+ })
+}
+
+// teamsTell appends one entry to team id's Traffic through the seam, off the
+// ordered door line (it is the person's gesture). A seam with no Traffic door
+// tells nothing, and the caller has already said so where it matters.
+func (a *app) teamsTell(id string, e teamstore.Entry) tea.Cmd {
+ door := a.teamsSeam().Append
+ if door == nil {
+ return nil
+ }
+ return a.offLoop(func() func(bool) tea.Cmd {
+ err := door(id, e)
+ return func(bool) tea.Cmd {
+ if err != nil {
+ a.tp.msg = "the team's Traffic did not take it: " + err.Error()
+ a.touch()
+ }
+ return nil
+ }
+ })
+}
+
+// teamsDelete forgets closed team id through the seam, off the loop, and then
+// reads the teams again, because the file moved under the window.
+func (a *app) teamsDelete(id string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil
+ }
+ if !t.Closed() {
+ a.tp.msg = "only a closed team can be deleted"
+ a.touch()
+ return nil
+ }
+ seam := a.teamsSeam()
+ if seam.Delete == nil {
+ a.tp.msg = "delete is not available over this connection"
+ a.touch()
+ return nil
+ }
+ reserved, name := teamReservedHues(a.pal), t.Name
+ return a.offLoop(func() func(bool) tea.Cmd {
+ _, err := seam.Delete(id)
+ var teams []team
+ var stamp string
+ if err == nil {
+ teams, stamp, _, err = seam.ReadSince("", reserved)
+ }
+ return func(bool) tea.Cmd {
+ if err != nil {
+ a.tp.msg = "not deleted: " + err.Error()
+ a.touch()
+ return nil
+ }
+ a.teamAdopt(teamsClone(teams))
+ a.traffic.stamp = stamp
+ a.tp.msg = name + " is forgotten; its conversations stay in your history"
+ if a.tp.sel == id {
+ a.tp.sel = ""
+ a.teamsSettle()
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil
+ }
+ })
+}
+
+// teamsCloseWords is the close card's sentence about what is running.
+func teamsCloseWords(names []string) string {
+ switch len(names) {
+ case 0:
+ return "nothing is running"
+ case 1:
+ return names[0] + " is still working"
+ }
+ return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] + " are still working"
+}
diff --git a/internal/tui3/teamcoherence_test.go b/internal/tui3/teamcoherence_test.go
index 733672c9f2..499215e48d 100644
--- a/internal/tui3/teamcoherence_test.go
+++ b/internal/tui3/teamcoherence_test.go
@@ -248,3 +248,169 @@ func TestTeamToolRowsAndCardsLinkTheirHandles(t *testing.T) {
t.Errorf("the team_send row's @gravity is not a link:\n%s", strings.Join(plainRows(a), "\n"))
}
}
+
+// teamNameLink is the first on-screen link that names team id and no member,
+// with its screen y.
+func teamNameLink(a *app, id string) (taskLink, int, bool) {
+ body, _ := a.window(a.bodyWidth(), a.viewHeight())
+ for i, r := range body {
+ for _, l := range r.links {
+ if l.team == id && l.member == "" {
+ return l, a.bodyTop() + i, true
+ }
+ }
+ }
+ return taskLink{}, 0, false
+}
+
+// assertTeamPage is the landing a team-name press owes: the teams place, that
+// team selected, the rail's cursor on its row, the conversation that was in
+// front still in front.
+func assertTeamPage(t *testing.T, a *app, id, front string) {
+ t.Helper()
+ if !a.at(pageTeams) || a.wall.on {
+ t.Fatalf("the press landed on page %q, wall %v", a.page.word(), a.wall.on)
+ }
+ if a.tp.sel != id {
+ t.Fatalf("the pane is on %q, want %q", a.tp.sel, id)
+ }
+ if a.frontTabKey() != front {
+ t.Fatalf("the press moved the front from %q to %q", front, a.frontTabKey())
+ }
+ if !a.tp.focus || a.tp.cur != (teamsRef{act: teamsActSelect, id: id}) {
+ t.Fatalf("the rail cursor is focus %v %+v", a.tp.focus, a.tp.cur)
+ }
+ if hint := a.dockHoverWords(); strings.Contains(hint, "teams page") {
+ t.Fatalf("the chat link's hint outlived the press: %q", hint)
+ }
+ a.frame()
+ got, ok := a.teamsCursorTarget()
+ if !ok || got.pane || got.act != teamsActSelect || got.id != id {
+ t.Fatalf("the drawn cursor is %+v (ok %v)", got, ok)
+ }
+}
+
+// A TEAM'S NAME IN A REPLY OPENS THE TEAMS PAGE ON IT. The rail's cursor lands
+// on the team, the pane is that team, and the conversation in front stays.
+func TestATeamNameInAReplyOpensTheTeamsPage(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ a.entries = append(a.entries, entry{kind: entryAssistant, text: "Ask the harbor team for the numbers.", settled: true})
+ a.touch()
+ link, y, ok := teamNameLink(a, harbor)
+ if !ok {
+ t.Fatalf("the reply grew no team-name link:\n%s", strings.Join(plainRows(a), "\n"))
+ }
+ front := a.frontTabKey()
+ drive(t, a, motionTo(link.span.from+1, y))
+ if hint := a.dockHoverWords(); hint != "Open harbor on the teams page · click" {
+ t.Fatalf("the hint line says %q", hint)
+ }
+ spend(t, a, a.press(link.span.from+1, y))
+ assertTeamPage(t, a, harbor, front)
+}
+
+// A SENT ●slug IS THE SAME DOOR. The @ chip a person inserted opens the teams
+// page on that team, with the same hint.
+func TestAMentionChipOpensTheTeamsPage(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ a.entries = append(a.entries, entry{kind: entryUser, text: "see ●harbor", settled: true})
+ a.touch()
+ link, y, ok := teamNameLink(a, harbor)
+ if !ok {
+ t.Fatalf("the chip grew no team link:\n%s", strings.Join(plainRows(a), "\n"))
+ }
+ front := a.frontTabKey()
+ drive(t, a, motionTo(link.span.from+1, y))
+ if hint := a.dockHoverWords(); hint != "Open harbor on the teams page · click" {
+ t.Fatalf("the chip's hint says %q", hint)
+ }
+ spend(t, a, a.press(link.span.from+1, y))
+ assertTeamPage(t, a, harbor, front)
+}
+
+// A TEAM TOOL'S ROW CARRIES THE SAME DOOR as a reply.
+func TestATeamNameOnAToolRowOpensTheTeamsPage(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 160, 40
+ a.entries = append(a.entries, entry{kind: entryTool, tool: "team_send", text: "team_send the harbor team the numbers", settled: true})
+ a.touch()
+ link, y, ok := teamNameLink(a, harbor)
+ if !ok {
+ t.Fatalf("the tool row grew no team-name link:\n%s", strings.Join(plainRows(a), "\n"))
+ }
+ front := a.frontTabKey()
+ spend(t, a, a.press(link.span.from+1, y))
+ assertTeamPage(t, a, harbor, front)
+}
+
+// A CLOSED TEAM IS SELECTED INSIDE CLOSED, and the fold is opened so the row
+// is on the rail.
+func TestAClosedTeamLinkSelectsItInsideClosed(t *testing.T) {
+ a, _, _, _ := trafficApp(t)
+ var behind []chatTab
+ for _, tab := range a.tabList() {
+ if tab.key != a.frontTabKey() {
+ behind = append(behind, tab)
+ break
+ }
+ }
+ orbit, err := a.teamMake("orbit", behind)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(orbit, a.now(), "") }); err != nil {
+ t.Fatal(err)
+ }
+ teamsFlush(t, a)
+ if got, _ := a.teamByID(orbit); !got.Closed() {
+ t.Fatal("orbit did not close")
+ }
+ a.width, a.height = 160, 40
+ a.entries = append(a.entries, entry{kind: entryAssistant, text: "The orbit team is put away.", settled: true})
+ a.touch()
+ link, y, ok := teamNameLink(a, orbit)
+ if !ok {
+ t.Fatalf("the closed team's name is not a link:\n%s", strings.Join(plainRows(a), "\n"))
+ }
+ front := a.frontTabKey()
+ spend(t, a, a.press(link.span.from+1, y))
+ assertTeamPage(t, a, orbit, front)
+ if !a.tp.closedOpen {
+ t.Fatal("the Closed fold stayed shut")
+ }
+ if text := teamsFrameText(a); !strings.Contains(text, "Closed") || !strings.Contains(text, "orbit") || !strings.Contains(text, "closed without a report") {
+ t.Fatalf("the closed team is not in the pane:\n%s", text)
+ }
+}
+
+// OVER --host WITH NO TEAMS DOORS the press keeps the conversations view, and
+// the hint says that rather than the teams page.
+func TestATeamLinkOverHostFallsBackToTheWall(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.host = "devbox"
+ if !a.teamsOff() {
+ t.Fatal("a hosted window with no seam still has teams")
+ }
+ a.width, a.height = 160, 40
+ a.entries = append(a.entries, entry{kind: entryAssistant, text: "Ask the harbor team.", settled: true})
+ a.touch()
+ link, y, ok := teamNameLink(a, harbor)
+ if !ok {
+ t.Fatalf("the reply grew no team-name link:\n%s", strings.Join(plainRows(a), "\n"))
+ }
+ drive(t, a, motionTo(link.span.from+1, y))
+ want := "Show harbor on the conversations view · " + wallMembersWord(len(mustTeam(t, a, harbor).Members)) + " · click"
+ if hint := a.dockHoverWords(); hint != want {
+ t.Fatalf("the fallback hint says %q, want %q", hint, want)
+ }
+ front := a.frontTabKey()
+ spend(t, a, a.press(link.span.from+1, y))
+ if a.at(pageTeams) || !a.wall.on || a.wall.activeID != harbor {
+ t.Fatalf("the fallback landed on page %q wall %v team %q", a.page.word(), a.wall.on, a.wall.activeID)
+ }
+ if a.frontTabKey() != front {
+ t.Fatalf("the wall moved the front from %q to %q", front, a.frontTabKey())
+ }
+}
diff --git a/internal/tui3/teamcrew.go b/internal/tui3/teamcrew.go
new file mode 100644
index 0000000000..ec485f80f6
--- /dev/null
+++ b/internal/tui3/teamcrew.go
@@ -0,0 +1,753 @@
+package tui3
+
+import (
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+ "github.com/Agent-Field/codeaf/internal/tui2/tokens"
+)
+
+// ── A TEAM'S MEMBERS: ACTIVE ONES ON THE HEADER, EVERYONE ON A CARD ─────────
+//
+// The pane's header is one line (owner feedback 2026-09-24, which replaced two
+// wrapped rows of every member as prose):
+//
+// ● harbor ◆ Manager @news ⠿ working @review ? asking +4 idle $0.42 today Settings Close… Open ▦
+//
+// ONLY A MEMBER WITH SOMETHING HAPPENING IS A CHIP: working, asking (in the
+// needs-you amber) or failed, each a door to its conversation with its title in
+// the hint. Everyone else is one quiet word, `+4 idle`, or `6 members` when
+// nobody is doing anything: the emptiness law, which says an idle thing draws
+// nothing, applied to people. Whether a conversation is open in this window is
+// a fact about the window, not the team, so the page never says `not open`;
+// the members card's `Open` or `Resume` carries it.
+//
+// NARROW, THE LINE GIVES UP ITS PARTS IN ORDER OF WHAT IT CAN LIVE WITHOUT: the
+// idle word first (the card is still `p`), then the spend, then the chips from
+// the last, then the manager's chip, then the action buttons from the right.
+// The team's name is never dropped.
+//
+// THE MEMBERS CARD (`+4 idle`, `6 members`, or `p`) is every member, one row
+// each: handle, title, state, when it last moved, `also in test` for a
+// conversation that reports to another team's manager, and `Open` or `Resume`.
+// Its rows are also where a member is dragged from to ADD it to another team
+// on the rail (teamdrag.go), so the card hangs over the pane and never over the
+// rail it drops onto.
+
+// teamCrewLetter is the page's letter for the members card.
+const teamCrewLetter = "p"
+
+// teamsCrewRow is one member as the header and the card draw it.
+type teamsCrewRow struct {
+ key, handle, title string
+ // word is the state in one word: working, asking, failed or idle.
+ word string
+ asking bool
+ manager bool
+ held bool
+ at time.Time
+ // also is the team whose manager a shared member reports to, "" for this
+ // team's own.
+ also string
+ file string
+}
+
+// active reports whether the member has something happening, which is what
+// earns it a chip on the header.
+func (r teamsCrewRow) active() bool { return r.word != "idle" }
+
+// name is the member as the page spells it: `@handle`, else its title.
+func (r teamsCrewRow) name() string {
+ if r.handle != "" {
+ return "@" + r.handle
+ }
+ if r.title != "" {
+ return r.title
+ }
+ return "member"
+}
+
+// teamsCrewMembers is who the header and the card list for team t: its members,
+// or, on the root with a manager, the managers of the top-level teams, which
+// are the global manager's members (DESIGN.md 8.10), never their chats.
+func (a *app) teamsCrewMembers(t team) []teamMember {
+ if t.Root && t.Manager != "" {
+ tops := a.teamTree().TopManagers()
+ out := make([]teamMember, 0, len(tops)+1)
+ if m, ok := t.Member(t.Manager); ok {
+ out = append(out, m)
+ }
+ return append(out, tops...)
+ }
+ return t.Members
+}
+
+// teamsCrew is every member of team t, the manager first. Memory only.
+func (a *app) teamsCrew(t team) []teamsCrewRow {
+ tree := a.teamTree()
+ members := a.teamsCrewMembers(t)
+ out := make([]teamsCrewRow, 0, len(members))
+ for pass := 0; pass < 2; pass++ {
+ for _, m := range members {
+ if (m.Key == t.Manager) != (pass == 0) {
+ continue
+ }
+ st := a.teamsMember(m)
+ r := teamsCrewRow{key: m.Key, handle: m.Handle, title: strings.TrimSpace(m.Word), word: "idle",
+ manager: m.Key == t.Manager, held: a.trafficHeld(m.Key), at: st.at, file: m.File}
+ switch {
+ case st.asking:
+ r.word, r.asking = "asking", true
+ case st.word == "running":
+ r.word = "working"
+ case a.teamsFailed(t, m):
+ r.word = "failed"
+ }
+ if home, ok := tree.Home(m.Key); ok && home.Team != t.ID && home.Via != t.ID && !r.manager {
+ r.also = a.teamNameOf(home.Team)
+ }
+ if r.title == "" && r.handle == "" {
+ continue
+ }
+ out = append(out, r)
+ }
+ }
+ return out
+}
+
+// teamsFailed reports whether member m's newest event in team t's Traffic says
+// its last turn failed. It reads the Traffic this window already holds and
+// allocates nothing.
+func (a *app) teamsFailed(t team, m teamMember) bool {
+ rows := a.traffic.rows[t.ID]
+ for i := len(rows) - 1; i >= 0; i-- {
+ e := rows[i]
+ if e.Kind != teamstore.KindEvent || (e.Member != m.Key && (m.Handle == "" || e.From != m.Handle)) {
+ continue
+ }
+ return e.State == teamstore.StateFailed
+ }
+ return false
+}
+
+// teamsCrewMark is a state's mark: `⠿` working, `?` asking, `✗` failed.
+func (a *app) teamsCrewMark(word string) string {
+ switch word {
+ case "working":
+ return a.linearMark("⠿", "*")
+ case "asking":
+ return "?"
+ case "failed":
+ return a.linearMark(a.icon(tokens.GFailed), "x")
+ }
+ return ""
+}
+
+// teamsCrewInk is the ink a state is drawn in: asking in the needs-you amber,
+// failed in the failure red, working muted, idle dim.
+func (a *app) teamsCrewInk(word string) func(string) string {
+ switch word {
+ case "asking":
+ return a.pal.ask
+ case "failed":
+ return a.pal.bad
+ case "working":
+ return a.pal.muted
+ }
+ return a.pal.dim
+}
+
+// teamsCrewHint is what the hint line says over a member: who it is, what it
+// is doing, and what a press does.
+//
+// @news · weekly news digest · working 3m · click opens
+func (a *app) teamsCrewHint(r teamsCrewRow) string {
+ words := r.name()
+ if r.title != "" && r.handle != "" {
+ words += hintSegment + r.title
+ }
+ state := r.word
+ if age := sinceAt(r.at, a.now()); age != "" && r.word != "working" {
+ state += " " + age
+ }
+ words += hintSegment + state
+ if r.also != "" {
+ words += hintSegment + "reports to " + r.also + "'s manager"
+ }
+ if r.held {
+ return words + hintSegment + "click opens"
+ }
+ return words + hintSegment + "click resumes it behind, in its own tab"
+}
+
+// ── THE HEADER ──────────────────────────────────────────────────────────────
+
+// teamsHeadPiece is one optional piece of the header line, in the order the
+// line drops them when it is narrow.
+type teamsHeadPiece struct {
+ s string
+ w int
+ t teamsTarget
+ btn bool
+ drop int
+}
+
+// teamsHeader is the header row: the team's name, its manager, the members
+// with something happening, the idle word, the spend, and the three buttons.
+//
+// ● harbor ◆ Manager @news ⠿ working +4 idle $0.42 today Settings Close… Open ▦
+func (a *app) teamsHeader(d *teamsDraw, t team, width, y int) string {
+ pal := a.pal
+ name := t.Name
+ if t.Root {
+ name = teamstore.RootName
+ }
+ left := " " + a.tabTeamDot(t) + " " + pal.bold(pal.ink(name))
+ type btn struct {
+ word string
+ t teamsTarget
+ }
+ var bs []btn
+ if t.Closed() {
+ bs = append(bs,
+ btn{"Reopen", teamsTarget{act: teamsActReopen, id: t.ID, hint: "Reopen " + t.Name + ": its tabs come back and its manager resumes" + hintSegment + "r"}},
+ btn{"Delete" + a.linearMark("…", "..."), teamsTarget{act: teamsActDelete, id: t.ID, hint: "Forget this team, its Traffic and its packets; the conversations stay" + hintSegment + "d"}})
+ if p, ok := a.teamsParentClosed(t); ok {
+ bs = append([]btn{{"Reopen " + p.Name + " too", teamsTarget{act: teamsActReopenParent, id: t.ID,
+ hint: t.Name + " sits under " + p.Name + ", which is closed" + hintSegment + "r"}}}, bs[1:]...)
+ }
+ } else {
+ bs = append(bs,
+ btn{"Settings", teamsTarget{act: teamsActSettings, id: t.ID, hint: "What this team overrides, and what it inherits" + hintSegment + "s"}})
+ if !t.Root {
+ bs = append(bs, btn{"Close" + a.linearMark("…", "..."), teamsTarget{act: teamsActClose, id: t.ID, hint: "Close " + t.Name + ": wrap up first, or now" + hintSegment + "c"}})
+ }
+ bs = append(bs, btn{"Open " + a.linearMark("▦", "#"), teamsTarget{act: teamsActWall, id: t.ID, hint: "The wall, showing " + name + "'s open conversations" + hintSegment + "w"}})
+ }
+ // THE OPTIONAL PIECES, each with the rank at which a narrow line drops it:
+ // the higher the rank, the sooner it goes.
+ var pieces []teamsHeadPiece
+ const (
+ dropIdle = 1000
+ dropSpend = 900
+ dropChip = 800 // minus the chip's place, so the last chip goes first
+ dropBoss = 100
+ )
+ if !t.Closed() {
+ crew := a.teamsCrew(t)
+ if t.Manager != "" {
+ word := a.teamManagerMark() + " Manager"
+ hint := "Talk to " + name + "'s manager"
+ for _, r := range crew {
+ if r.manager && r.active() {
+ word += " " + a.teamsCrewMark(r.word)
+ hint += hintSegment + r.word
+ }
+ }
+ if a.teamsHosting() {
+ hint += hintSegment + "it is the conversation below"
+ } else {
+ hint += hintSegment + "click brings it in front"
+ }
+ pieces = append(pieces, teamsHeadPiece{s: word, w: ansi.StringWidth(word),
+ t: teamsTarget{act: teamsActManagerGo, id: t.ID, hint: hint}, btn: true, drop: dropBoss})
+ }
+ idle, total := 0, 0
+ for _, r := range crew {
+ if r.manager {
+ continue
+ }
+ total++
+ if !r.active() {
+ idle++
+ continue
+ }
+ mark := a.teamsCrewMark(r.word)
+ plain := r.name() + " " + mark + " " + r.word
+ ink := a.teamsCrewInk(r.word)
+ s := pal.ink(r.name()) + " " + ink(mark+" "+r.word)
+ pieces = append(pieces, teamsHeadPiece{s: s, w: ansi.StringWidth(plain),
+ t: teamsTarget{act: teamsActMember, id: t.ID, arg: r.key, hint: a.teamsCrewHint(r)}, drop: dropChip - len(pieces)})
+ }
+ if total > 0 {
+ word := "+" + itoa(idle) + " idle"
+ if idle == total {
+ word = itoa(total) + " members"
+ if total == 1 {
+ word = "1 member"
+ }
+ }
+ if idle > 0 {
+ pieces = append(pieces, teamsHeadPiece{s: word, w: ansi.StringWidth(word),
+ t: teamsTarget{act: teamsActCrew, id: t.ID, hint: "Every member of " + name + ": open one, resume one, or drag one onto another team to add it" + hintSegment + teamCrewLetter}, btn: true, drop: dropIdle})
+ }
+ }
+ }
+ spend := a.teamsSpendWords(t)
+ if t.Closed() {
+ spend = a.teamsClosedWords(t)
+ }
+ if spend != "" {
+ pieces = append(pieces, teamsHeadPiece{s: pal.dim(spend), w: ansi.StringWidth(spend), drop: dropSpend})
+ }
+ // 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
+ for _, b := range bs {
+ bw += ansi.StringWidth(b.word) + 2
+ }
+ leftW := ansi.StringWidth(left)
+ room := width - 1 - leftW - bw
+ for room < 0 && len(bs) > 0 {
+ // Too narrow for the buttons: they go from the right, the name never.
+ last := bs[len(bs)-1]
+ bs = bs[:len(bs)-1]
+ bw -= ansi.StringWidth(last.word) + 2
+ room = width - 1 - leftW - bw
+ }
+ keep := make([]bool, len(pieces))
+ used := 0
+ for {
+ best := -1
+ for i, p := range pieces {
+ if !keep[i] && (best < 0 || p.drop < pieces[best].drop) {
+ best = i
+ }
+ }
+ if best < 0 {
+ break
+ }
+ w := pieces[best].w + 3
+ if pieces[best].btn {
+ w++
+ }
+ if used+w > room {
+ break
+ }
+ keep[best] = true
+ used += w
+ }
+ head := left
+ x := leftW
+ for i, p := range pieces {
+ if !keep[i] {
+ continue
+ }
+ if p.btn {
+ // A button's own cell of air is the third cell of the gap.
+ head += " "
+ x += 2
+ tg := p.t
+ tg.x0, tg.y = x, y
+ s, w := d.button(p.s, tg, pal.muted)
+ head += s
+ x += w
+ continue
+ }
+ head += " "
+ x += 3
+ if p.t.act != teamsActNone {
+ tg := p.t
+ tg.x0, tg.x1, tg.y = x, x+p.w, y
+ d.targets = append(d.targets, tg)
+ s := p.s
+ hot, cur := d.lit(tg.ref())
+ switch {
+ case cur:
+ s = pal.selected(s, 0)
+ case hot:
+ s = pal.cursor(s, 0)
+ }
+ head += s
+ } else {
+ head += p.s
+ }
+ x += p.w
+ }
+ head = teamsPad(head, max(width-bw, 0))
+ x = max(width-bw, 0)
+ for _, b := range bs {
+ b.t.x0, b.t.y = x, y
+ s, w := d.button(b.word, b.t, pal.ink)
+ head += s
+ x += w
+ }
+ return head
+}
+
+// teamsManagerGo is the header's `◆ Manager`: the manager's conversation in
+// front, which on this page is the pane.
+func (a *app) teamsManagerGo(id string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || t.Manager == "" {
+ return nil
+ }
+ if t.Manager == a.frontTabKey() {
+ // It is in front already: the keyboard goes to its box.
+ a.tp.focus = false
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil
+ }
+ a.tp.sel = id
+ return a.teamsBringManager()
+}
+
+// ── THE MEMBERS CARD ────────────────────────────────────────────────────────
+
+// teamCrew is the members card's state.
+type teamCrew struct {
+ on bool
+ team string
+ // cursor is the row the keyboard is on; hot the row under the pointer and
+ // hotButton whether it is on that row's Open or Resume (-1 none).
+ cursor, hot int
+ hotButton bool
+ hotClose bool
+ top int
+ rect wallRect
+ hits []wallHit
+}
+
+// The card's targets, as its hits carry them in arg.
+const (
+ crewHitRow = iota
+ crewHitButton
+ crewHitClose
+ crewHitTag
+)
+
+// teamCrewOpen puts the card up for team id.
+func (a *app) teamCrewOpen(id string) tea.Cmd {
+ if _, ok := a.teamByID(id); !ok {
+ return nil
+ }
+ a.tcrew = teamCrew{on: true, team: id, hot: -1}
+ a.touch()
+ return nil
+}
+
+// teamCrewShut puts the card away.
+func (a *app) teamCrewShut() {
+ a.tcrew = teamCrew{}
+ a.touch()
+}
+
+// teamCrewRows is the card's members, the manager first.
+func (a *app) teamCrewRows() []teamsCrewRow {
+ t, ok := a.teamByID(a.tcrew.team)
+ if !ok {
+ return nil
+ }
+ return a.teamsCrew(t)
+}
+
+// teamCrewKey is a key while the card is up: it has the keyboard.
+func (a *app) teamCrewKey(msg tea.KeyPressMsg) tea.Cmd {
+ rows := a.teamCrewRows()
+ c := &a.tcrew
+ a.touch()
+ switch msg.String() {
+ case "esc", teamCrewLetter:
+ a.teamCrewShut()
+ case "up", "k":
+ c.cursor = max(c.cursor-1, 0)
+ case "down", "j":
+ c.cursor = min(c.cursor+1, max(len(rows)-1, 0))
+ case "enter", "space":
+ if c.cursor >= 0 && c.cursor < len(rows) {
+ return a.teamCrewGo(rows[c.cursor])
+ }
+ }
+ return nil
+}
+
+// teamCrewGo is `Open` or `Resume` on a member: one this window holds is
+// opened and the card goes; one it does not is resumed behind and the card
+// stays, so the person sees it change to `Open`.
+func (a *app) teamCrewGo(r teamsCrewRow) tea.Cmd {
+ id := a.tcrew.team
+ if r.held {
+ a.teamCrewShut()
+ }
+ return a.teamsMemberGo(id, r.key)
+}
+
+// teamCrewHitAt is the card's target under the pointer.
+func (a *app) teamCrewHitAt(x, y int) (wallHit, bool) {
+ for _, hit := range a.tcrew.hits {
+ if x >= hit.x0 && x < hit.x1 && y >= hit.y0 && y < hit.y1 {
+ return hit, true
+ }
+ }
+ return wallHit{}, false
+}
+
+// teamCrewMouse is a pointer event while the card is up. A press on a row puts
+// the cursor there and holds it for a drag; a press on `Open` or `Resume` does
+// it; `Close` and a press off the card put it away. It reports whether the
+// card took the event.
+func (a *app) teamCrewMouse(msg tea.Msg, m tea.Mouse) (tea.Cmd, bool) {
+ c := &a.tcrew
+ hit, on := a.teamCrewHitAt(m.X, m.Y)
+ switch msg.(type) {
+ case tea.MouseClickMsg:
+ if m.Button != tea.MouseLeft {
+ return nil, true
+ }
+ if !on {
+ if !c.rect.holds(m.X, m.Y) {
+ a.teamCrewShut()
+ }
+ return nil, true
+ }
+ rows := a.teamCrewRows()
+ switch hit.kind {
+ case crewHitClose:
+ a.teamCrewShut()
+ return nil, true
+ case crewHitButton:
+ if hit.arg >= 0 && hit.arg < len(rows) {
+ c.cursor = hit.arg
+ return a.teamCrewGo(rows[hit.arg]), true
+ }
+ default:
+ if hit.arg >= 0 && hit.arg < len(rows) {
+ c.cursor = hit.arg
+ a.teamDragPress(m.X, m.Y, true, c.team, rows[hit.arg].key, teamsTarget{}, false)
+ a.touch()
+ }
+ }
+ return nil, true
+ case tea.MouseMotionMsg:
+ if a.tdrag.press {
+ a.teamDragMotion(m.X, m.Y, m.Button == tea.MouseLeft)
+ return nil, true
+ }
+ hot, button, closing := -1, false, false
+ if on {
+ switch hit.kind {
+ case crewHitClose:
+ closing = true
+ default:
+ hot, button = hit.arg, hit.kind == crewHitButton
+ }
+ }
+ if hot != c.hot || button != c.hotButton || closing != c.hotClose {
+ c.hot, c.hotButton, c.hotClose = hot, button, closing
+ a.touch()
+ }
+ return nil, c.rect.holds(m.X, m.Y)
+ case tea.MouseReleaseMsg:
+ if cmd, took := a.teamDragRelease(); took {
+ return cmd, true
+ }
+ return nil, c.rect.holds(m.X, m.Y)
+ }
+ return nil, c.rect.holds(m.X, m.Y)
+}
+
+// teamCrewHint is what the hint line says with the card up.
+func (a *app) teamCrewHint() string {
+ c := a.tcrew
+ rows := a.teamCrewRows()
+ if c.hotClose {
+ return "Close the members" + hintSegment + "esc"
+ }
+ at := c.cursor
+ if c.hot >= 0 {
+ at = c.hot
+ }
+ if at < 0 || at >= len(rows) {
+ return "↑↓ walk" + hintSegment + "enter open" + hintSegment + "esc close"
+ }
+ r := rows[at]
+ if c.hot >= 0 && c.hotButton {
+ if r.held {
+ return "Open " + r.name() + hintSegment + "enter"
+ }
+ return "Resume " + r.name() + " behind, in its own tab; you stay here" + hintSegment + "enter"
+ }
+ words := r.name()
+ if r.title != "" && r.handle != "" {
+ words += hintSegment + r.title
+ }
+ if r.also != "" {
+ words += hintSegment + "reports to " + r.also + "'s manager"
+ }
+ return words + hintSegment + "drag onto a team on the left to add it there" + hintSegment + "enter opens"
+}
+
+// teamCrewOver lays the card over a finished frame, over the pane and clear of
+// the rail, under the header.
+func (a *app) teamCrewOver(frame string) string {
+ if !a.tcrew.on || !a.at(pageTeams) {
+ return frame
+ }
+ width, height := a.width, a.height
+ x := 1
+ if a.tp.railW > 0 {
+ x = a.tp.railW + 1
+ }
+ y := placeHeadRows
+ for _, t := range a.tp.targets {
+ if t.act == teamsActSettings || t.act == teamsActCrew {
+ y = t.y + 1
+ break
+ }
+ }
+ card := a.teamCrewCard(x, y, width-x-1, height-y)
+ a.tcrew.hits = card.hits
+ if len(card.rows) == 0 {
+ a.tcrew.rect = wallRect{}
+ return frame
+ }
+ a.tcrew.rect = wallRect{card.x, card.y, card.x + card.w, card.y + len(card.rows)}
+ rows := strings.Split(frame, "\n")
+ for len(rows) < height {
+ rows = append(rows, "")
+ }
+ for dy, cr := range card.rows {
+ if yy := card.y + dy; yy >= 0 && yy < len(rows) {
+ rows[yy] = wallSplice(rows[yy], cr, card.x, width)
+ }
+ }
+ return strings.Join(rows[:height], "\n")
+}
+
+// teamCrewCard is the card at x, y, at most w wide and h tall.
+//
+// ╭─ harbor · 6 members ──────────────────────────────────────────────╮
+// │ ◆ @boss harbor's manager working Open │
+// │ @review code review asking also in test Resume │
+// │ Close esc │
+// ╰───────────────────────────────────────────────────────────────────╯
+func (a *app) teamCrewCard(x, y, w, h int) wallCard {
+ pal := a.pal
+ t, ok := a.teamByID(a.tcrew.team)
+ if !ok {
+ return wallCard{}
+ }
+ rows := a.teamCrewRows()
+ w = min(w, 88)
+ if w < 36 || h < 6 {
+ return wallCard{}
+ }
+ const padX = 1
+ inner := w - 2 - 2*padX
+ room := max(h-4, 1)
+ c := &a.tcrew
+ c.cursor = min(max(c.cursor, 0), max(len(rows)-1, 0))
+ top := min(c.top, max(len(rows)-room, 0))
+ if c.cursor < top {
+ top = c.cursor
+ }
+ if c.cursor >= top+room {
+ top = c.cursor - room + 1
+ }
+ c.top = top
+ // The columns: handle, state and age, the tag and the button are sized;
+ // the title takes what is left.
+ const nameW, stateW, ageW, buttonW = 13, 10, 5, 8
+ tagW := 0
+ for _, r := range rows {
+ if r.also != "" {
+ tagW = max(tagW, ansi.StringWidth("also in "+r.also)+2)
+ }
+ }
+ tagW = min(tagW, 20)
+ titleW := inner - nameW - stateW - ageW - tagW - buttonW
+ if titleW < 8 {
+ titleW, tagW = max(inner-nameW-stateW-ageW-buttonW, 0), 0
+ }
+ var lines []wallCardLine
+ if len(rows) == 0 {
+ lines = append(lines, wallCardLine{s: pal.dim("no members yet")})
+ }
+ now := a.now()
+ for i, r := range rows[top:min(top+room, len(rows))] {
+ at := top + i
+ name := r.name()
+ if r.manager {
+ name = a.teamManagerMark() + " " + name
+ }
+ title := r.title
+ if r.manager && title == "" {
+ title = t.Name + "'s manager"
+ }
+ age := ""
+ if r.word != "working" {
+ age = sinceAt(r.at, now)
+ }
+ ink := a.teamsCrewInk(r.word)
+ text := pal.ink(teamsPad(fitConversationTitle(name, nameW-1), nameW)) +
+ pal.muted(teamsPad(fitConversationTitle(title, max(titleW-2, 1)), titleW)) +
+ ink(teamsPad(r.word, stateW)) + pal.dim(teamsPad(age, ageW))
+ hits := []wallHit{{x0: 0, x1: inner - buttonW, y1: 1, kind: crewHitRow, arg: at}}
+ if tagW > 0 {
+ tag := ""
+ if r.also != "" {
+ tag = "also in " + r.also
+ hits = append(hits, wallHit{x0: inner - buttonW - tagW, x1: inner - buttonW - 2, y1: 1, kind: crewHitTag, arg: at})
+ }
+ text += pal.dim(teamsPad(fit(tag, tagW-2), tagW))
+ }
+ word := "Resume"
+ if r.held {
+ word = "Open"
+ }
+ chip := " " + word + " "
+ lit := c.hot == at && c.hotButton
+ switch {
+ case lit:
+ chip = pal.cursor(pal.ink(chip), 0)
+ default:
+ chip = pal.ink(chip)
+ }
+ text = teamsPad(text, inner-buttonW) + chip
+ hits = append(hits, wallHit{x0: inner - buttonW, x1: inner - buttonW + ansi.StringWidth(" "+word+" "), y1: 1, kind: crewHitButton, arg: at})
+ if at == c.cursor || (c.hot == at && !c.hotButton) {
+ text = pal.cursor(teamsPad(text, inner), inner)
+ }
+ // A dragged row is dim until it drops, so the person sees what moves.
+ if a.tdrag.on && a.tdrag.member && a.tdrag.key == r.key {
+ text = pal.dim(ansi.Strip(teamsPad(text, inner)))
+ }
+ lines = append(lines, wallCardLine{s: text, hits: hits})
+ }
+ closeWord := " Close " + pal.dim("esc") + " "
+ cw := ansi.StringWidth(" Close esc ")
+ cs := closeWord
+ if c.hotClose {
+ cs = pal.cursor(" Close esc ", 0)
+ }
+ lines = append(lines, wallCardLine{s: strings.Repeat(" ", max(inner-cw, 0)) + cs,
+ hits: []wallHit{{x0: inner - cw, x1: inner, y1: 1, kind: crewHitClose, arg: -1}}})
+ // THE COUNT IS THE HEADER'S COUNT: the members beside the manager, with
+ // the manager named apart, so the header's `◆ Manager 1 member` and this
+ // title never disagree about the same team.
+ members, managed := 0, false
+ for _, r := range rows {
+ if r.manager {
+ managed = true
+ continue
+ }
+ members++
+ }
+ count := itoa(members) + " members"
+ if members == 1 {
+ count = "1 member"
+ }
+ if managed {
+ count = a.teamManagerMark() + " Manager " + a.teamsDot() + " " + count
+ }
+ title := t.Name + " " + a.teamsDot() + " " + count
+ if t.Root {
+ title = teamstore.RootName + " " + a.teamsDot() + " the top teams' managers"
+ }
+ return wallCardBuild(pal, title, lines, x, y, w, padX, 0)
+}
diff --git a/internal/tui3/teamdrag.go b/internal/tui3/teamdrag.go
new file mode 100644
index 0000000000..6023143bae
--- /dev/null
+++ b/internal/tui3/teamdrag.go
@@ -0,0 +1,296 @@
+package tui3
+
+import (
+ tea "charm.land/bubbletea/v2"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── DRAG AND DROP IN THE TEAMS RAIL: THE SHORTCUT (rulings c-12, c-13) ──────
+//
+// `Move into…` is the main road; a drag is the shortcut for a person whose
+// hand is already on the pointer. Two things can be dragged:
+//
+// - A TEAM ROW of the rail, onto another team row (`Drop to move api into
+// harbor`) or onto the empty rail under the tree, which is the top level.
+// Teams picked with `space` travel together when the one dragged is among
+// them. The move then goes exactly the way the picker's does
+// ([app.teamMoveAsk]): at once with Undo, or after the one consequence line.
+// - A MEMBER, from the members card, onto a team row. That ADDS the
+// conversation to the team (`Add @security to harbor`) and never takes it
+// out of the one it was dragged from: removing a conversation from a team is
+// always its own explicit act, so a drag cannot lose one.
+//
+// A DRAG STARTS ONLY AFTER TWO CELLS OF HELD MOVEMENT. A press that lets go
+// where it landed, or a cell away, is a click and does what a click does (a
+// team row selects its team, a member opens), because a hand on a mouse is
+// never still and a one-cell wobble must not turn a click into a move.
+//
+// ONLY A TARGET THAT CAN TAKE THE DROP IS GROUNDED. Every other row under the
+// pointer stays as it is and the hint line says why it will not take it, in
+// the picker's own words. `esc` drops the drag and nothing happens.
+//
+// Like everything on the page it is memory: the rows and their targets are the
+// last frame's ([teamsTarget]), and the write is an ordinary edit.
+
+// teamDragCells is how far a held press must move before it is a drag.
+const teamDragCells = 2
+
+// teamDrag is one press on something that can be dragged, and the drag it
+// became.
+type teamDrag struct {
+ // press says a press is held on a draggable thing; on says it moved far
+ // enough to be a drag.
+ press, on bool
+ // member says a member is dragged (key, from team id) rather than team id.
+ member bool
+ id string
+ key string
+ // x0 and y0 are where the press landed.
+ x0, y0 int
+ // over is what a release here would drop on: a team id, [teamMoveTop], or
+ // "" for nothing; ok says it takes the drop and why says why it does not.
+ over string
+ ok bool
+ why string
+ // click is what the press does when it lets go without a drag, for a
+ // target whose press waits for the release.
+ click teamsTarget
+ clicked bool
+}
+
+// teamDragPress holds a press on team row id, or on member key of team from.
+func (a *app) teamDragPress(x, y int, member bool, id, key string, click teamsTarget, deferred bool) {
+ a.tdrag = teamDrag{press: true, member: member, id: id, key: key, x0: x, y0: y, click: click, clicked: deferred}
+}
+
+// teamDragging reports whether a drag is under way.
+func (a *app) teamDragging() bool { return a.tdrag.on }
+
+// teamDragIDs is the teams a team drag moves: every team picked with `space`
+// when the dragged one is among them, else the dragged one alone.
+func (a *app) teamDragIDs() []string {
+ if a.tp.picked[a.tdrag.id] {
+ return a.teamsPickedIDs()
+ }
+ return []string{a.tdrag.id}
+}
+
+// teamDragMotion is the pointer moving while a press is held. It reports
+// whether the drag took the motion.
+func (a *app) teamDragMotion(x, y int, held bool) bool {
+ d := &a.tdrag
+ if !d.press {
+ return false
+ }
+ if !held {
+ // The release was never heard (a terminal that drops it): the press is
+ // over, and nothing is dropped.
+ *d = teamDrag{}
+ a.touch()
+ return false
+ }
+ if !d.on {
+ if abs(x-d.x0) < teamDragCells && abs(y-d.y0) < teamDragCells {
+ return true
+ }
+ d.on = true
+ a.tp.hot = teamsRef{}
+ }
+ over, ok, why := a.teamDropAt(x, y)
+ if over != d.over || ok != d.ok || why != d.why {
+ d.over, d.ok, d.why = over, ok, why
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+ return true
+}
+
+// teamDropAt is what a release at x, y would drop on: a team row, the All
+// teams row or the empty rail under the tree (the top level), each with
+// whether it takes this drag and why not.
+func (a *app) teamDropAt(x, y int) (string, bool, string) {
+ d := a.tdrag
+ lastTree := -1
+ for _, t := range a.tp.targets {
+ if t.pane || t.act != teamsActSelect {
+ continue
+ }
+ if tt, ok := a.teamByID(t.id); ok && tt.Closed() {
+ continue
+ }
+ lastTree = max(lastTree, t.y)
+ }
+ for _, t := range a.tp.targets {
+ if t.pane || y != t.y || x < t.x0 || x >= t.x1 {
+ continue
+ }
+ switch {
+ case t.act == teamsActSelect && t.id != teamsAllRow:
+ tt, ok := a.teamByID(t.id)
+ if !ok {
+ return "", false, ""
+ }
+ if tt.Root {
+ return a.teamDropJudge(teamMoveTop)
+ }
+ return a.teamDropJudge(tt.ID)
+ case t.act == teamsActSelect || t.act == teamsActRootManager:
+ return a.teamDropJudge(teamMoveTop)
+ }
+ }
+ // THE EMPTY RAIL UNDER THE TREE IS THE TOP LEVEL, for a team; a member has
+ // no top level to go to.
+ if !d.member && a.tp.railW > 0 && x < a.tp.railW-1 && lastTree >= 0 && y > lastTree {
+ return a.teamDropJudge(teamMoveTop)
+ }
+ return "", false, ""
+}
+
+// teamDropJudge is whether the drag can drop on target, and why not.
+func (a *app) teamDropJudge(target string) (string, bool, string) {
+ d := a.tdrag
+ if !d.member {
+ ok, why := a.teamMoveCheck(a.teamDragIDs(), target)
+ return target, ok, why
+ }
+ name := a.teamDragMemberName()
+ if target == teamMoveTop {
+ return target, false, "drop " + name + " on a team to add it"
+ }
+ t, ok := a.teamByID(target)
+ switch {
+ case !ok:
+ return "", false, ""
+ case t.Closed():
+ return target, false, t.Name + " is closed"
+ case t.Holds(d.key):
+ return target, false, name + " is in " + t.Name + " already"
+ }
+ return target, true, ""
+}
+
+// teamDragMember is the member being dragged, as its team keeps it.
+func (a *app) teamDragMember() (teamMember, bool) {
+ if t, ok := a.teamByID(a.tdrag.id); ok {
+ return t.Member(a.tdrag.key)
+ }
+ return teamMember{}, false
+}
+
+// teamDragMemberName is the dragged member as a sentence says it: `@handle`,
+// else its title.
+func (a *app) teamDragMemberName() string {
+ m, ok := a.teamDragMember()
+ switch {
+ case !ok:
+ return "the conversation"
+ case m.Handle != "":
+ return "@" + m.Handle
+ case m.Word != "":
+ return m.Word
+ }
+ return "the conversation"
+}
+
+// teamDragRelease is the press let go: a drag drops, a press that never moved
+// far enough does what its click does.
+func (a *app) teamDragRelease() (tea.Cmd, bool) {
+ d := a.tdrag
+ if !d.press {
+ return nil, false
+ }
+ a.tdrag = teamDrag{}
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ if !d.on {
+ if d.clicked {
+ return a.teamsDo(d.click), true
+ }
+ return nil, true
+ }
+ if d.over == "" || !d.ok {
+ if d.why != "" {
+ a.tp.msg = d.why
+ }
+ return nil, true
+ }
+ if d.member {
+ return a.teamDropMember(d), true
+ }
+ ids := []string{d.id}
+ if a.tp.picked[d.id] {
+ ids = a.teamsPickedIDs()
+ a.tp.picked = nil
+ }
+ return a.teamMoveAsk(ids, d.over, teamMoveFromPage), true
+}
+
+// teamDropMember adds the dragged member to the team it was dropped on, with
+// what its own team kept of it, which is enough to open it again there.
+func (a *app) teamDropMember(d teamDrag) tea.Cmd {
+ src, ok := a.teamByID(d.id)
+ if !ok {
+ return nil
+ }
+ m, ok := src.Member(d.key)
+ if !ok {
+ return nil
+ }
+ target, _ := a.teamByID(d.over)
+ add := teamstore.Member{Key: m.Key, File: m.File, Where: m.Where, Word: m.Word}
+ name := a.teamDragMemberName()
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.AddMember(d.over, add) }); err != nil {
+ a.tp.msg = name + " was not added: " + err.Error()
+ } else {
+ a.tp.msg = name + " is in " + target.Name + " too"
+ }
+ a.touch()
+ return nil
+}
+
+// teamDragCancel drops the drag, and nothing happens.
+func (a *app) teamDragCancel() {
+ a.tdrag = teamDrag{}
+ a.tp.top = teamsTopCache{}
+ a.touch()
+}
+
+// teamDragHint is what the hint line says while a drag is under way, "" with
+// none.
+//
+// Drop to move api into harbor · esc cancel
+func (a *app) teamDragHint() string {
+ d := a.tdrag
+ if !d.on {
+ return ""
+ }
+ tail := hintSegment + "esc cancel"
+ switch {
+ case d.over == "":
+ if d.member {
+ return "Drag " + a.teamDragMemberName() + " onto a team to add it" + tail
+ }
+ return "Drag onto a team, or below the teams for the top level" + tail
+ case !d.ok:
+ return d.why + tail
+ case d.member:
+ return "Add " + a.teamDragMemberName() + " to " + a.teamNameOf(d.over) + tail
+ }
+ return "Drop to " + lowerFirst(a.teamMoveDoing(a.teamDragIDs(), d.over)) + tail
+}
+
+// lowerFirst is s with its first letter lowered: `Move api` inside a sentence.
+func lowerFirst(s string) string {
+ if s == "" {
+ return s
+ }
+ return string(s[0]|0x20) + s[1:]
+}
+
+// teamDropLit reports whether team id's rail row is the drop the pointer is
+// over and takes it, which is the only row grounded during a drag.
+func (a *app) teamDropLit(id string) bool {
+ d := a.tdrag
+ return d.on && d.ok && d.over == id
+}
diff --git a/internal/tui3/teamjump_test.go b/internal/tui3/teamjump_test.go
index cf3316e230..832ee73715 100644
--- a/internal/tui3/teamjump_test.go
+++ b/internal/tui3/teamjump_test.go
@@ -41,19 +41,24 @@ func TestTrafficJumpOpensTheMemberAtTheMessage(t *testing.T) {
price, priceKey := trafficHandle(t, a, harbor, "openrouter")
rail, _ := trafficHandle(t, a, harbor, "Refactor")
q := threadScenario(t, a, harbor, price, rail)
- // The rail's doors carry where they land.
+ // The column's handles carry where they land: the work row's at the
+ // directive, and a reply's, laid open, at the member's own post.
+ a.sideToggleThread(sideThreadKey(harbor, q))
_ = railLines(t, a)
var header, answer string
- for _, row := range a.traffic.drawn.doors {
- for _, d := range row {
- if d.member == priceKey && d.land == q && header == "" {
- header = d.land
- } else if d.member == priceKey && d.land != q && d.land != "" {
- answer = d.land
+ for _, row := range a.side.last {
+ for _, d := range row.doors {
+ if d.act.kind != sideActJump || d.act.key != priceKey {
+ continue
+ }
+ if row.key == "thread/"+q {
+ header = d.act.entry
+ } else if strings.HasPrefix(row.key, "reply/") {
+ answer = d.act.entry
}
}
}
- if header != q || answer == "" {
+ if header != q || answer == "" || answer == q {
t.Fatalf("the handles do not carry their message: header %q answer %q", header, answer)
}
// In the member's conversation the directive sits above a long tail.
@@ -108,9 +113,9 @@ func TestTrafficJumpToAnOlderMessage(t *testing.T) {
}
}
-// IN THE MANAGER'S CONVERSATION a press on an answer's words on the rail lays
-// them out and brings the thread card into view, lifted; a handle on the
-// card's answer opens the member at its own post.
+// IN THE MANAGER'S CONVERSATION a press on a row of work brings the thread
+// card into view, lifted, and the conversation in front stays in front; a
+// handle on the card's answer opens the member at its own post.
func TestTrafficJumpBringsTheCardIntoView(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 160, 30
@@ -124,13 +129,12 @@ func TestTrafficJumpBringsTheCardIntoView(t *testing.T) {
if shown, _ := landedRow(a, "│ status?"); shown {
t.Fatal("the card is on screen before the press")
}
- rows := railLines(t, a)
- y := railRowOf(rows, "prices are cached")
- x := a.width - a.trafficWidth() + strings.Index(rows[y], "prices") + 1
- cmd, took := a.trafficPress(x, y)
- spend(t, a, cmd)
- if shown, lifted := landedRow(a, "│ status?"); !took || !shown || !lifted {
- t.Fatalf("the press did not bring the card into view, lifted (shown %v lifted %v)", shown, lifted)
+ front := a.frontTabKey()
+ _ = railLines(t, a)
+ x, y := sideRowOn(t, a, "thread/"+q)
+ sideClick(t, a, x, y)
+ if shown, lifted := landedRow(a, "│ status?"); !shown || !lifted || a.frontTabKey() != front {
+ t.Fatalf("the press did not bring the card into view, lifted, here (shown %v lifted %v)", shown, lifted)
}
// The card's answer row names the answer for its handle.
body, _ := bodyRows(a)
diff --git a/internal/tui3/teamlink.go b/internal/tui3/teamlink.go
index 9dc6b07e13..f733ce870c 100644
--- a/internal/tui3/teamlink.go
+++ b/internal/tui3/teamlink.go
@@ -30,8 +30,14 @@ import (
//
// A PRESS ON A MEMBER OPENS IT, and resumes it first when this window does not
// have it open, through the strip's own door ([app.tabGo]). A press on a team
-// opens the conversations view shown on that team. The hint line says which,
-// with the member's title: `Open @security · santosh dev2 branch… · click`.
+// opens the teams page with that team selected: the rail's cursor on its row,
+// the pane showing it, and the keyboard left on that row rather than moved
+// into the pane. A closed team is selected inside `Closed`, and that fold is
+// opened so the row is there. Over --host against an engine without the teams
+// doors ([app.teamsOff]) the page cannot open, so the press still opens the
+// conversations view on that team, and the hint says that rather than the
+// page. A member's hint names the title: `Open @security · santosh dev2
+// branch… · click`.
//
// THE HOVER IS A GROUND, as every other control on this surface answers the
// pointer, and not the task link's brightening: the owner's bar is that
@@ -251,17 +257,23 @@ func indexFoldAny(s, needle string, from int) int {
}
// teamLinkPress is a press on a team reference: the member opened, resumed
-// first when this window does not have it open, or the team shown on the
-// conversations view.
+// first when this window does not have it open, or the team opened on the
+// teams page ([app.openTeamFromLink]).
func (a *app) teamLinkPress(link taskLink) tea.Cmd {
t, ok := a.teamByID(link.team)
if !ok {
return nil
}
if link.member == "" {
- a.wall.activeID = t.ID
- a.chatTabBar = tabBar{}
- return a.openWall()
+ if a.teamsOff() {
+ // THE PAGE IS NOT THERE. An older engine over --host has no teams
+ // doors, so the press keeps the door it had: the conversations
+ // view narrowed to this team. The hint says that, and only that.
+ a.wall.activeID = t.ID
+ a.chatTabBar = tabBar{}
+ return a.openWall()
+ }
+ return a.openTeamFromLink(t)
}
m, ok := t.Member(link.member)
if !ok || m.Key == a.frontTabKey() {
@@ -279,6 +291,25 @@ func (a *app) teamLinkPress(link taskLink) tea.Cmd {
return a.tabGo(chatTab{key: m.Key, file: m.File, where: m.Where, word: word, full: word})
}
+// openTeamFromLink stands the teams page on team t. The rail's cursor is the
+// landing and the pane shows the team. The keyboard stays on that row: a
+// managed team's page otherwise hands it to the manager's composer, which
+// would move the person into a sentence they did not ask to type. A closed
+// team is selected inside the Closed fold, opened so the row exists.
+func (a *app) openTeamFromLink(t team) tea.Cmd {
+ if t.Closed() {
+ a.tp.closedOpen = true
+ }
+ // The pointer was on a link in the chat that is no longer drawn; kept, it
+ // would leave that link's hint on a page that has no such link.
+ a.dropHover()
+ a.tp.sel = t.ID
+ cmd := a.showPage(pageTeams)
+ a.tp.focus = true
+ a.tp.cur = teamsRef{act: teamsActSelect, id: t.ID}
+ return cmd
+}
+
// teamLinkHint is what the hint line says with the pointer on a team
// reference, "" when it is not on one.
func (a *app) teamLinkHint() string {
@@ -294,7 +325,10 @@ func (a *app) teamLinkHint() string {
return ""
}
if key == "" {
- return "Show " + t.Name + " on the conversations view" + hintSegment + wallMembersWord(len(t.Members)) + hintSegment + "click"
+ if a.teamsOff() {
+ return "Show " + t.Name + " on the conversations view" + hintSegment + wallMembersWord(len(t.Members)) + hintSegment + "click"
+ }
+ return "Open " + t.Name + " on the teams page" + hintSegment + "click"
}
m, ok := t.Member(key)
if !ok {
diff --git a/internal/tui3/teammanager.go b/internal/tui3/teammanager.go
index 473d0a9f6e..194638b894 100644
--- a/internal/tui3/teammanager.go
+++ b/internal/tui3/teammanager.go
@@ -329,7 +329,27 @@ func (a *app) teamManagerMenuWord(t team, key string) string {
// team's doors: the manager's place on the strip, the team chip, and the
// Traffic's rows and words. "" anywhere else.
func (a *app) teamHoverWords() string {
- if words := a.trafficHoverWords(); words != "" {
+ // The move picker and a team's card, over whatever is drawn; a drag, the
+ // members card, and then the teams page's own buttons while it hosts the
+ // manager.
+ if a.tmove.on {
+ return a.teamMoveHint()
+ }
+ if a.tsheet.on {
+ return a.teamSheetHint()
+ }
+ if a.at(pageTeams) {
+ if words := a.teamDragHint(); words != "" {
+ return words
+ }
+ if a.tcrew.on {
+ return a.teamCrewHint()
+ }
+ }
+ if words := a.teamsPageHint(); words != "" {
+ return words
+ }
+ if words := a.sideHoverWords(); words != "" {
return words
}
if words := a.teamLinkHint(); words != "" {
diff --git a/internal/tui3/teammanager_test.go b/internal/tui3/teammanager_test.go
index fef0304111..967c9a415e 100644
--- a/internal/tui3/teammanager_test.go
+++ b/internal/tui3/teammanager_test.go
@@ -49,7 +49,7 @@ func TestTeamManagerSlotStartsAManager(t *testing.T) {
}
}
- cmd, took := a.tabPress(slot.span.from+1, placeTabRow)
+ cmd, took := a.tabPress(slot.span.from+1, tabStripRow)
if !took {
t.Fatal("the strip did not take the press")
}
@@ -128,7 +128,7 @@ func plainCells(row string, from, to int) string {
func TestTeamMenuMakesAndRemovesTheManager(t *testing.T) {
a, harbor := managerApp(t)
front := a.frontTabKey()
- if _, took := a.tabPress(a.wall.chip.from+1, placeTabRow); !took || !a.teamMenu.on {
+ if _, took := a.tabPress(a.wall.chip.from+1, tabStripRow); !took || !a.teamMenu.on {
t.Fatal("the chip did not open the switcher")
}
frame, _ := menuFrame(t, a)
diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go
index d3e9aeb24a..63dde51f2c 100644
--- a/internal/tui3/teammenu.go
+++ b/internal/tui3/teammenu.go
@@ -35,7 +35,7 @@ import (
// member the team's manager, and on the manager it reads `Remove manager`
// (teammanager.go).
//
-// WITH NO TEAM SHOWN THE CHIP IS STILL THERE, AS A QUIET ` Teams ▾ `, whenever
+// WITH NO TEAM SHOWN THE CHIP IS STILL THERE, AS A QUIET ` teams ▾ `, whenever
// there is a team to switch to. The strip is the one control on every page,
// and a switcher that appeared only once a team was already chosen could not be
// used to choose the first; with no team at all there is nothing to switch to
@@ -64,6 +64,7 @@ const (
teamMenuNew = -12 // + New team…
teamMenuSettings = -13 // Team settings…
teamMenuManager = -14 // ◆ Make manager, or Remove manager
+ teamMenuClosed = -15 // Closed · N, folded, which opens the teams page
)
// teamMenuRow is one row of the switcher, as the painter and the keys both
@@ -72,6 +73,9 @@ type teamMenuRow struct {
code int
id string
rule bool
+ // depth is how far a team's row is indented: a sub-team stands under the
+ // team it is in, as it does on the teams page's rail.
+ depth int
}
// teamMenuRows is the switcher's rows in order: each team, All, a rule, then
@@ -79,10 +83,18 @@ type teamMenuRow struct {
// while a team is shown, and Add only for a conversation that can be a member.
func (a *app) teamMenuRows() []teamMenuRow {
var rows []teamMenuRow
- for _, t := range a.wall.teams {
- rows = append(rows, teamMenuRow{code: wallPopTeam, id: t.ID})
+ // THE TEAMS ARE THE TREE, each sub-team indented under its team, in the
+ // order the teams page's rail draws them (ruling c-12).
+ for _, r := range a.teamsOpenTree() {
+ rows = append(rows, teamMenuRow{code: wallPopTeam, id: r.id, depth: r.depth})
}
- rows = append(rows, teamMenuRow{code: teamMenuAll}, teamMenuRow{rule: true})
+ rows = append(rows, teamMenuRow{code: teamMenuAll})
+ // THE CLOSED TEAMS ARE ONE FOLDED ROW (ruling c-9), never a team on the
+ // switcher: a press opens the teams page with its Closed fold open.
+ if len(a.teamsClosed()) > 0 {
+ rows = append(rows, teamMenuRow{code: teamMenuClosed})
+ }
+ rows = append(rows, teamMenuRow{rule: true})
_, shown := a.teamActive()
if shown && a.frontTabKey() != "" {
rows = append(rows, teamMenuRow{code: teamMenuToggle})
@@ -177,15 +189,15 @@ func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd {
case teamMenuNew:
a.closeTeamMenu()
return a.teamMenuNewTeam()
+ case teamMenuClosed:
+ a.closeTeamMenu()
+ a.tp.closedOpen = true
+ return a.showPage(pageTeams)
case teamMenuSettings:
a.closeTeamMenu()
- id := a.wall.activeID
- var open tea.Cmd
- if !a.wall.on {
- open = a.openWall()
- }
- a.wallOpenSettings(id, a.wallAnchorTeam(wallHitChipMenu, id))
- return open
+ // The team's card, over whatever is drawn (teamsheet.go): its name,
+ // its colour, the settings it overrides and its close.
+ return a.teamSheetOpen(a.wall.activeID, teamSheetSettings)
}
return nil
}
@@ -194,7 +206,19 @@ func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd {
// front already picked. A team shown that it is not a member of would hide its
// tile, so the wall widens to All first: the card makes a team of the tiles it
// can see.
-func (a *app) teamMenuNewTeam() tea.Cmd {
+func (a *app) teamMenuNewTeam() tea.Cmd { return a.teamMenuNewTeamIn("") }
+
+// teamMenuNewTeamIn is [app.teamMenuNewTeam] with the new team made inside
+// team parent ("" the top level): the teams page's `+ New team in harbor`. A
+// parent that cannot take one more level says why and opens nothing.
+func (a *app) teamMenuNewTeamIn(parent string) tea.Cmd {
+ if parent != "" {
+ if ok, why := a.teamsCanNest(parent); !ok {
+ a.tp.msg = why
+ a.touch()
+ return nil
+ }
+ }
var open tea.Cmd
if !a.wall.on {
open = a.openWall()
@@ -211,7 +235,9 @@ func (a *app) teamMenuNewTeam() tea.Cmd {
a.wallMove(i, len(tiles))
}
}
- return tea.Batch(open, a.wallStartNaming(tiles))
+ naming := a.wallStartNaming(tiles)
+ a.wall.nameParent = parent
+ return tea.Batch(open, naming)
}
// teamMenuKey is a key while the switcher is up: the arrows walk its rows,
@@ -359,8 +385,9 @@ func (a *app) teamMenuCard(width, height int) wallCard {
n++
}
}
- ln.left = pal.ink(radio) + " " + a.tabTeamDot(t) + " " + pal.ink(name)
- ln.leftW = ansi.StringWidth(radio) + 3 + ansi.StringWidth(name)
+ indent := strings.Repeat(" ", min(r.depth, 4))
+ ln.left = pal.ink(radio) + " " + indent + a.tabTeamDot(t) + " " + pal.ink(name)
+ ln.leftW = ansi.StringWidth(radio) + 3 + len(indent) + ansi.StringWidth(name)
ln.right = strconv.Itoa(n)
case teamMenuAll:
radio := off
@@ -370,6 +397,10 @@ func (a *app) teamMenuCard(width, height int) wallCard {
ln.left = pal.ink(radio) + " " + pal.ink("All")
ln.leftW = ansi.StringWidth(radio) + 3 + 3
ln.right = strconv.Itoa(len(open))
+ case teamMenuClosed:
+ word := "Closed " + a.teamsDot() + " " + strconv.Itoa(len(a.teamsClosed())) + " " + a.linearMark("▸", ">")
+ ln.left = strings.Repeat(" ", ansi.StringWidth(off)+3) + pal.dim(word)
+ ln.leftW = ansi.StringWidth(off) + 3 + ansi.StringWidth(word)
case teamMenuToggle:
word := "+ Add this conversation"
if teamHolds(shown, front) {
@@ -410,7 +441,7 @@ func (a *app) teamMenuCard(width, height int) wallCard {
const padX = 1
w := inner + 2 + 2*padX
h := len(lines) + 2
- top := placeTabRow + 1
+ top := tabStripRow + 1
if w > width-2 || top+h > height {
return wallCard{}
}
diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go
index 8fd4948f22..ed4ac0fc57 100644
--- a/internal/tui3/teammenu_test.go
+++ b/internal/tui3/teammenu_test.go
@@ -76,7 +76,7 @@ func menuHit(t *testing.T, a *app, code int, id string) wallHit {
func TestTeamMenuIsTheStripsSwitcher(t *testing.T) {
a, harbor, orbit := menuApp(t)
front := a.frontTabKey()
- if _, took := a.tabPress(a.wall.chip.from+1, placeTabRow); !took || !a.teamMenu.on {
+ if _, took := a.tabPress(a.wall.chip.from+1, tabStripRow); !took || !a.teamMenu.on {
t.Fatal("the chip did not open the switcher")
}
frame, rows := menuFrame(t, a)
@@ -86,7 +86,7 @@ func TestTeamMenuIsTheStripsSwitcher(t *testing.T) {
}
}
card := a.teamMenu.card
- if card.y0 != placeTabRow+1 || card.x0 != a.wall.chip.from {
+ if card.y0 != tabStripRow+1 || card.x0 != a.wall.chip.from {
t.Fatalf("the switcher hangs at %+v, the chip is at %+v", card, a.wall.chip)
}
for i, h := range a.teamMenu.hits {
@@ -203,7 +203,7 @@ func TestTeamMenuClosesOnAPressOffItAndOnEsc(t *testing.T) {
// The chip that opened it closes it.
a.openTeamMenu()
_, _ = menuFrame(t, a)
- _, _ = a.Update(tea.MouseClickMsg{X: a.wall.chip.from + 1, Y: placeTabRow, Button: tea.MouseLeft})
+ _, _ = a.Update(tea.MouseClickMsg{X: a.wall.chip.from + 1, Y: tabStripRow, Button: tea.MouseLeft})
if a.teamMenu.on {
t.Fatal("the chip did not close its own switcher")
}
@@ -228,13 +228,16 @@ func TestTeamMenuOpensTheCardAndTheSettings(t *testing.T) {
_, _ = menuFrame(t, a)
hit = menuHit(t, a, teamMenuSettings, "")
a.teamMenuPress(hit.x0+1, hit.y0)
- if !a.wall.on || a.wall.pop.kind != wallPopSettings || a.wall.pop.team != harbor {
- t.Fatalf("Team settings: wall %v pop %+v", a.wall.on, a.wall.pop)
+ if !a.tsheet.on || a.tsheet.mode != teamSheetSettings || a.tsheet.team != harbor {
+ t.Fatalf("Team settings: card %+v", a.tsheet)
}
// And on the wall the chip is the switcher too.
- a.wall.pop = wallPop{}
+ a.tsheet = teamSheet{}
+ if !a.wall.on {
+ _ = a.openWall()
+ }
_, _ = menuFrame(t, a)
- if _, took := a.wallPress(a.wall.chip.from+1, placeTabRow); !took || !a.teamMenu.on {
+ if _, took := a.wallPress(a.wall.chip.from+1, tabStripRow); !took || !a.teamMenu.on {
t.Fatal("the chip on the wall did not open the switcher")
}
if frame, _ := menuFrame(t, a); !strings.Contains(frame, "╭─ Teams ─") {
@@ -242,7 +245,7 @@ func TestTeamMenuOpensTheCardAndTheSettings(t *testing.T) {
}
}
-// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾` while there are teams, and
+// WITH NO TEAM SHOWN THE CHIP IS A QUIET `teams ▾` while there are teams, and
// is not there at all while there are none.
func TestTeamMenuQuietChipWithNoTeamShown(t *testing.T) {
a, _, _ := tabApp(t)
@@ -254,10 +257,10 @@ func TestTeamMenuQuietChipWithNoTeamShown(t *testing.T) {
a.teamActivate("")
a.touch()
row := plain(a.tabsRow(a.width))
- if !strings.Contains(row, " Teams ▾ ") || !a.wall.chip.pressable() {
+ if !strings.Contains(row, " teams ▾ ") || !a.wall.chip.pressable() {
t.Fatalf("no quiet chip: %q", row)
}
- if _, took := a.tabPress(a.wall.chip.from+1, placeTabRow); !took || !a.teamMenu.on {
+ if _, took := a.tabPress(a.wall.chip.from+1, tabStripRow); !took || !a.teamMenu.on {
t.Fatal("the quiet chip did not open the switcher")
}
frame, _ := menuFrame(t, a)
diff --git a/internal/tui3/teammove.go b/internal/tui3/teammove.go
new file mode 100644
index 0000000000..22f967322b
--- /dev/null
+++ b/internal/tui3/teammove.go
@@ -0,0 +1,817 @@
+package tui3
+
+import (
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── MOVE INTO…: PUTTING A TEAM INSIDE ANOTHER (rulings c-12, c-13) ──────────
+//
+// A team is moved by choosing where it goes, never by indenting it: `m` on a
+// team row of the teams page's rail (or on the teams picked with `space`), and
+// `Inside: harbor ▾` on the team's card, open the same picker:
+//
+// ╭─ Move api into ───────────────────────────────╮
+// │ Filter ha▏ │
+// │ ──────────────────────────────────────────── │
+// │ Top level │
+// │ ● harbor │
+// │ ● dock (dim: past the depth limit)│
+// │ ──────────────────────────────────────────── │
+// │ dock is 2 levels deep · limit 2 · Settings │
+// ╰───────────────────────────────────────────────╯
+//
+// EVERY TEAM IS A ROW, AND A ROW THAT CANNOT TAKE THE MOVE IS DIMMED WITH ITS
+// REASON, not hidden: the team itself, a team under it, a closed team, one too
+// deep for the depth limit, the place it already is. Hiding them would leave a
+// person looking for harbor and not finding it; a dim row with its reason in
+// the foot says why at the moment they look. The reasons are the store's
+// ([teamstore.File.MoveCheck]); the words are here.
+//
+// TYPING FILTERS, THE TREE STAYS A TREE. The picker has one box and every
+// printable key goes into it, so the arrows walk and nothing else does; a
+// filtered row keeps its indent, so `api` under two teams called api is still
+// told apart by where it stands.
+//
+// A MOVE THAT CHANGES WHO DECIDES ASKS FIRST, ONE LINE ([app.teamMoveAsk]).
+// When the move would give the teams' conversations another manager to report
+// to, put their spend in another capped pool, or send their conflicts to
+// another manager, one line says so with `Move` and `Cancel`; every other move
+// is made at once. Either way the move is offered back with `Undo` for the
+// same six seconds a close is, because a move made by a slip of the pointer is
+// still a slip.
+//
+// THE WRITE IS AN ORDINARY EDIT ([app.teamEdit]), made twice and written off
+// the loop through the teams seam, so it reaches the session's own file over
+// --host too. Nothing here reads a disk or a wire, and the frame draws from
+// what the window holds.
+
+// Where a move was asked from, which is where its question and its Undo are
+// drawn: the teams page's pane, or the team's card.
+const (
+ teamMoveFromPage = iota
+ teamMoveFromCard
+)
+
+// teamMoveTop is the picker's `Top level` row, and a drop on the empty rail
+// under the tree.
+const teamMoveTop = "\x00top"
+
+// teamMove is the picker, a move waiting on the person's word, and the last
+// move for Undo.
+type teamMove struct {
+ on bool
+ // ids are the teams being moved, from is where it was asked.
+ ids []string
+ from int
+ // filter is the box; cursor the row the keyboard is on and hot the one
+ // under the pointer (-1 none), both by the row's parent id, so a filter
+ // that moved the rows keeps them on the same team.
+ filter editor
+ cursor, hot string
+ // top is the first row drawn when the rows are more than the card holds.
+ top int
+ // rect and hits are where the last frame drew it, in frame cells.
+ rect wallRect
+ hits []wallHit
+ // pend is a move whose consequence line is up; undo the last move made.
+ pend teamMovePend
+ undo teamMoveUndo
+}
+
+// teamMovePend is a move waiting on `Move` or `Cancel`.
+type teamMovePend struct {
+ ids []string
+ parent string
+ words string
+ from int
+}
+
+// teamMoveUndo is the last move, while Undo is offered: every moved team's
+// parent before it, and every carried conversation's home team before it.
+type teamMoveUndo struct {
+ back map[string]string
+ homes map[string]string
+ word string
+ from int
+ at time.Time
+}
+
+// teamMoveRow is one row of the picker: a parent (teamMoveTop for the top
+// level), how deep it is drawn, and whether it can take the move and why not.
+type teamMoveRow struct {
+ parent string
+ depth int
+ name string
+ t team
+ ok bool
+ why string
+}
+
+// ── WHAT A MOVE IS CALLED ───────────────────────────────────────────────────
+
+// teamMoveSubject is what is being moved, as a sentence names it: the team's
+// name, or `3 teams`.
+func (a *app) teamMoveSubject(ids []string) string {
+ roots := a.teamTree().MoveRoots(ids)
+ if len(roots) == 1 {
+ if t, ok := a.teamByID(roots[0]); ok {
+ return t.Name
+ }
+ }
+ return itoa(len(roots)) + " teams"
+}
+
+// teamNameOf is team id's name as a sentence says it: `All teams` for the root,
+// "" for none.
+func (a *app) teamNameOf(id string) string {
+ t, ok := a.teamByID(id)
+ if !ok {
+ return ""
+ }
+ if t.Root {
+ return teamstore.RootName
+ }
+ return t.Name
+}
+
+// teamMoveWhy is a block as the hint line says it.
+//
+// harbor is 3 levels deep · limit 3 · Settings
+func (a *app) teamMoveWhy(b teamstore.MoveBlock, ids []string, target string) string {
+ subject := a.teamMoveSubject(ids)
+ dot := " " + a.teamsDot() + " "
+ switch b.Kind {
+ case teamstore.MoveBlockSelf:
+ return "a team cannot go inside itself"
+ case teamstore.MoveBlockInside:
+ return a.teamNameOf(target) + " is inside " + b.Name
+ case teamstore.MoveBlockClosed:
+ return b.Name + " is closed"
+ case teamstore.MoveBlockHere:
+ if b.Name == "" || target == teamMoveTop {
+ return subject + " is at the top level already"
+ }
+ return subject + " is in " + b.Name + " already"
+ case teamstore.MoveBlockRoot:
+ return teamstore.RootName + " holds every team and does not move"
+ case teamstore.MoveBlockDepth:
+ levels := func(n int) string {
+ if n == 1 {
+ return "1 level"
+ }
+ return itoa(n) + " levels"
+ }
+ from := "Settings"
+ switch b.LimitFrom.Kind {
+ case teamstore.OriginTeam, teamstore.OriginAncestor:
+ from = "set on " + b.LimitFrom.Name
+ }
+ words := b.Name + " is " + levels(b.Depth) + " deep" + dot + "limit " + itoa(b.Limit) + dot + from
+ if b.Need > 1 {
+ words = subject + " takes " + levels(b.Need) + dot + words
+ }
+ return words
+ }
+ return "that team is gone"
+}
+
+// teamMoveParent is the parent a row or a drop names, as the store takes it:
+// "" for the top level.
+func teamMoveParent(parent string) string {
+ if parent == teamMoveTop {
+ return ""
+ }
+ return parent
+}
+
+// teamMoveCheck is whether ids may go into parent (a team id or
+// [teamMoveTop]) and, when not, the reason in words. Frame-safe: memory only.
+func (a *app) teamMoveCheck(ids []string, parent string) (bool, string) {
+ b, ok := a.teamTree().MoveCheck(ids, teamMoveParent(parent), a.tp.defaults)
+ if ok {
+ return true, ""
+ }
+ return false, a.teamMoveWhy(b, ids, parent)
+}
+
+// teamMoveDoing is the sentence for a move that can be made: `Move api into
+// harbor`, `Move api to the top level`.
+func (a *app) teamMoveDoing(ids []string, parent string) string {
+ subject := a.teamMoveSubject(ids)
+ if parent == teamMoveTop || parent == "" {
+ return "Move " + subject + " to the top level"
+ }
+ return "Move " + subject + " into " + a.teamNameOf(parent)
+}
+
+// ── THE PICKER ──────────────────────────────────────────────────────────────
+
+// teamMoveOpen puts the picker up for ids, asked from where.
+func (a *app) teamMoveOpen(ids []string, from int) tea.Cmd {
+ a.teamsEnsure()
+ if a.teamsOff() {
+ a.tp.msg = teamHostedWord
+ a.touch()
+ return nil
+ }
+ var keep []string
+ for _, id := range ids {
+ if t, ok := a.teamByID(id); ok && !t.Root && !t.Closed() {
+ keep = append(keep, id)
+ }
+ }
+ if len(keep) == 0 {
+ return nil
+ }
+ a.tmove.on, a.tmove.ids, a.tmove.from = true, keep, from
+ a.tmove.filter.reset()
+ a.tmove.hot, a.tmove.top = "", 0
+ a.tmove.pend = teamMovePend{}
+ // The keyboard starts on the first row the teams can go to.
+ a.tmove.cursor = ""
+ for _, r := range a.teamMoveRows() {
+ if r.ok {
+ a.tmove.cursor = r.parent
+ break
+ }
+ }
+ a.touch()
+ if !a.tp.defaultsOK {
+ return a.teamsRead(false)
+ }
+ return nil
+}
+
+// teamMoveShut puts the picker away.
+func (a *app) teamMoveShut() {
+ a.tmove.on, a.tmove.hits, a.tmove.rect = false, nil, wallRect{}
+ a.touch()
+}
+
+// teamMoveRows is the picker's rows: `Top level`, then every open team but the
+// root in tree order, each with whether it can take the move; with a filter,
+// only the rows whose name holds it. Frame-safe: memory only.
+func (a *app) teamMoveRows() []teamMoveRow {
+ ids := a.tmove.ids
+ want := strings.ToLower(strings.TrimSpace(a.tmove.filter.String()))
+ var out []teamMoveRow
+ add := func(parent, name string, depth int, t team) {
+ if want != "" && !strings.Contains(strings.ToLower(name), want) {
+ return
+ }
+ ok, why := a.teamMoveCheck(ids, parent)
+ out = append(out, teamMoveRow{parent: parent, depth: depth, name: name, t: t, ok: ok, why: why})
+ }
+ add(teamMoveTop, "Top level", 0, team{})
+ for _, r := range a.teamsOpenTree() {
+ t, _ := a.teamByID(r.id)
+ add(t.ID, t.Name, r.depth+1, t)
+ }
+ return out
+}
+
+// teamMoveKey is a key while the picker is up: it has the whole keyboard.
+func (a *app) teamMoveKey(msg tea.KeyPressMsg) tea.Cmd {
+ rows := a.teamMoveRows()
+ at := -1
+ for i, r := range rows {
+ if r.parent == a.tmove.cursor {
+ at = i
+ }
+ }
+ a.touch()
+ switch msg.String() {
+ case "esc":
+ a.teamMoveShut()
+ return nil
+ case "up":
+ if len(rows) > 0 {
+ a.tmove.cursor = rows[max(at-1, 0)].parent
+ }
+ return nil
+ case "down":
+ if len(rows) > 0 {
+ a.tmove.cursor = rows[min(at+1, len(rows)-1)].parent
+ }
+ return nil
+ case "enter":
+ if at >= 0 {
+ return a.teamMoveChoose(rows[at])
+ }
+ return nil
+ case "backspace":
+ a.tmove.filter.deleteBackward()
+ default:
+ text := msg.Key().Text
+ if text == "" {
+ return nil
+ }
+ a.tmove.filter.insert(text)
+ }
+ // The filter moved the rows: the cursor goes to the first that can take
+ // the move, or the first at all.
+ a.tmove.top = 0
+ rows = a.teamMoveRows()
+ a.tmove.cursor = ""
+ for _, r := range rows {
+ if r.ok {
+ a.tmove.cursor = r.parent
+ break
+ }
+ }
+ if a.tmove.cursor == "" && len(rows) > 0 {
+ a.tmove.cursor = rows[0].parent
+ }
+ return nil
+}
+
+// teamMoveChoose is a row chosen: a row that cannot take the move says why and
+// stays up; one that can closes the picker and asks, or moves.
+func (a *app) teamMoveChoose(r teamMoveRow) tea.Cmd {
+ a.tmove.cursor = r.parent
+ if !r.ok {
+ a.touch()
+ return nil
+ }
+ ids, from := a.tmove.ids, a.tmove.from
+ a.teamMoveShut()
+ return a.teamMoveAsk(ids, r.parent, from)
+}
+
+// teamMoveHitAt is the picker's row under the pointer.
+func (a *app) teamMoveHitAt(x, y int) (wallHit, bool) {
+ for _, hit := range a.tmove.hits {
+ if x >= hit.x0 && x < hit.x1 && y >= hit.y0 && y < hit.y1 {
+ return hit, true
+ }
+ }
+ return wallHit{}, false
+}
+
+// teamMovePress is a left press while the picker is up: a row is chosen, the
+// card between rows does nothing, and off the card the picker goes away.
+func (a *app) teamMovePress(x, y int) tea.Cmd {
+ if hit, ok := a.teamMoveHitAt(x, y); ok {
+ for _, r := range a.teamMoveRows() {
+ if r.parent == hit.id {
+ return a.teamMoveChoose(r)
+ }
+ }
+ return nil
+ }
+ if !a.tmove.rect.holds(x, y) {
+ a.teamMoveShut()
+ }
+ return nil
+}
+
+// teamMoveMotion lights the row under the pointer.
+func (a *app) teamMoveMotion(x, y int) {
+ hot := ""
+ if hit, ok := a.teamMoveHitAt(x, y); ok {
+ hot = hit.id
+ }
+ if hot != a.tmove.hot {
+ a.tmove.hot = hot
+ a.touch()
+ }
+}
+
+// teamMoveHint is what the hint line says while the picker is up: the row
+// under the pointer, else the cursor's, with what enter does or why it cannot.
+func (a *app) teamMoveHint() string {
+ r, ok := a.teamMoveFocus()
+ if !ok {
+ return "type to filter · esc cancel"
+ }
+ if !r.ok {
+ return r.why + hintSegment + "esc cancel"
+ }
+ return a.teamMoveDoing(a.tmove.ids, r.parent) + hintSegment + "enter" + hintSegment + "esc cancel"
+}
+
+// teamMoveFocus is the row the hint speaks for: under the pointer, else the
+// cursor's.
+func (a *app) teamMoveFocus() (teamMoveRow, bool) {
+ rows := a.teamMoveRows()
+ for _, want := range []string{a.tmove.hot, a.tmove.cursor} {
+ if want == "" {
+ continue
+ }
+ for _, r := range rows {
+ if r.parent == want {
+ return r, true
+ }
+ }
+ }
+ return teamMoveRow{}, false
+}
+
+// teamMoveOver lays the picker over a finished frame.
+func (a *app) teamMoveOver(frame string) string {
+ if !a.tmove.on {
+ return frame
+ }
+ width, height := a.width, a.height
+ card := a.teamMoveCard(width, height)
+ a.tmove.hits = card.hits
+ if len(card.rows) == 0 {
+ a.tmove.rect = wallRect{}
+ return frame
+ }
+ a.tmove.rect = wallRect{card.x, card.y, card.x + card.w, card.y + len(card.rows)}
+ rows := strings.Split(frame, "\n")
+ for len(rows) < height {
+ rows = append(rows, "")
+ }
+ for dy, cr := range card.rows {
+ if y := card.y + dy; y >= 0 && y < len(rows) {
+ rows[y] = wallSplice(rows[y], cr, card.x, width)
+ }
+ }
+ return strings.Join(rows[:height], "\n")
+}
+
+// teamMoveCard is the picker as a card, centred, a third of the way down.
+func (a *app) teamMoveCard(width, height int) wallCard {
+ pal := a.pal
+ inner := min(max(width-12, 30), 52)
+ rows := a.teamMoveRows()
+ // The rows the card holds: the frame less the border, the padding, the
+ // filter, two rules and the foot.
+ room := max(height-2-2*wallCardPadY-5, 3)
+ at := 0
+ for i, r := range rows {
+ if r.parent == a.tmove.cursor {
+ at = i
+ }
+ }
+ top := min(a.tmove.top, max(len(rows)-room, 0))
+ if at < top {
+ top = at
+ }
+ if at >= top+room {
+ top = at - room + 1
+ }
+ a.tmove.top = top
+ var lines []wallCardLine
+ box := a.tmove.filter.String()
+ field := pal.ink(box) + pal.ink(a.linearMark("▏", "|"))
+ if box == "" {
+ field = pal.ink(a.linearMark("▏", "|")) + pal.dim("type to filter")
+ }
+ lines = append(lines, wallCardLine{s: pal.dim(teamsPad("Filter", 10)) + field}, wallCardLine{rule: true})
+ if len(rows) == 0 {
+ lines = append(lines, wallCardLine{s: pal.dim("no team is called that")})
+ }
+ for _, r := range rows[top:min(top+room, len(rows))] {
+ text := strings.Repeat(" ", r.depth)
+ if r.parent == teamMoveTop {
+ text += r.name
+ } else {
+ name := r.name
+ if ansi.StringWidth(name) > teamNameCells {
+ name = ansi.Truncate(name, teamNameCells, a.linearMark("…", "~"))
+ }
+ text += a.tabTeamDot(r.t) + " " + name
+ }
+ if r.ok {
+ text = pal.ink(text)
+ } else {
+ text = pal.dim(ansi.Strip(text))
+ }
+ lit := r.parent == a.tmove.cursor || r.parent == a.tmove.hot
+ lines = append(lines, wallCardLine{
+ s: wallPopRowPaint(pal, text, inner, lit),
+ hits: []wallHit{{x0: 0, x1: inner, y1: 1, kind: wallHitPopRow, id: r.parent}},
+ })
+ }
+ lines = append(lines, wallCardLine{rule: true})
+ foot := "esc cancel"
+ if r, ok := a.teamMoveFocus(); ok {
+ if r.ok {
+ foot = pal.muted(fit(a.teamMoveDoing(a.tmove.ids, r.parent), inner-8)) + pal.dim(" enter")
+ } else {
+ foot = pal.muted(fit(r.why, inner))
+ }
+ } else {
+ foot = pal.dim(foot)
+ }
+ lines = append(lines, wallCardLine{s: foot})
+ title := "Move " + a.teamMoveSubject(a.tmove.ids) + " into"
+ w := inner + 2 + 2*wallCardPadX
+ h := len(lines) + 2 + 2*wallCardPadY
+ if w > width-2 || h > height {
+ return wallCard{}
+ }
+ x := a.teamsCardX(width, w)
+ y := max((height-h)/3, 1)
+ return wallCardBuild(pal, title, lines, x, y, w, wallCardPadX, wallCardPadY)
+}
+
+// ── ASKING, MOVING AND TAKING IT BACK ───────────────────────────────────────
+
+// teamMoveAsk moves ids into parent (a team id or [teamMoveTop]): at once
+// when nothing a person decides on changes, and otherwise after the one line.
+func (a *app) teamMoveAsk(ids []string, parent string, from int) tea.Cmd {
+ if ok, why := a.teamMoveCheck(ids, parent); !ok {
+ a.tp.msg = why
+ a.touch()
+ return nil
+ }
+ words := a.teamMoveEffectWords(ids, parent)
+ if words == "" {
+ return a.teamMoveApply(ids, parent, from)
+ }
+ a.tmove.pend = teamMovePend{ids: ids, parent: parent, words: words, from: from}
+ // The keyboard goes to the question's first answer, where `enter` moves.
+ if from == teamMoveFromPage {
+ a.tp.focus, a.tp.cur = true, teamsRef{act: teamsActMoveYes}
+ } else {
+ a.tsheet.cursor = tsMoveYes
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil
+}
+
+// teamMoveEffectWords is the one consequence line for a move, "" when it
+// changes nothing a person is asked about.
+//
+// api will report to harbor's manager · its $3/day becomes part of harbor's $10 pool
+func (a *app) teamMoveEffectWords(ids []string, parent string) string {
+ tree := a.teamTree()
+ e, err := tree.MoveEffects(ids, teamMoveParent(parent), a.tp.defaults)
+ if err != nil || !e.Changes() {
+ return ""
+ }
+ subject := a.teamMoveSubject(ids)
+ manager := func(team string) string {
+ return a.teamNameOf(team) + "'s manager"
+ }
+ var parts []string
+ if len(e.Reports) > 0 {
+ // Said once for the whole move: where the first changed line now runs.
+ r := e.Reports[0]
+ switch {
+ case r.Has:
+ parts = append(parts, subject+" will report to "+manager(r.After.Team))
+ case r.Had:
+ parts = append(parts, subject+" will stop reporting to "+manager(r.Before.Team))
+ }
+ }
+ if len(e.Pools) > 0 {
+ p := e.Pools[0]
+ its := "its spend"
+ if t, ok := a.teamByID(p.Team); ok && t.Settings.CapUSDDay != nil && *t.Settings.CapUSDDay > 0 {
+ its = "its " + teamsMoney(*t.Settings.CapUSDDay) + "/day"
+ }
+ switch {
+ case p.After != "" && its == "its spend":
+ parts = append(parts, "its spend counts toward "+a.teamNameOf(p.After)+"'s "+teamsMoney(p.AfterCap)+" pool")
+ case p.After != "":
+ parts = append(parts, its+" becomes part of "+a.teamNameOf(p.After)+"'s "+teamsMoney(p.AfterCap)+" pool")
+ default:
+ parts = append(parts, "its spend leaves "+a.teamNameOf(p.Before)+"'s "+teamsMoney(p.BeforeCap)+" pool")
+ }
+ }
+ // Conflicts are said only when they go somewhere the report line has not
+ // already named: a team that will report to harbor's manager and take its
+ // conflicts there too is one fact, not two.
+ if len(e.Judges) > 0 {
+ j := e.Judges[0]
+ named := len(e.Reports) > 0 && e.Reports[0].Has && e.Reports[0].After.Team == j.After
+ if !named {
+ who := "you"
+ if j.After != "" {
+ who = manager(j.After)
+ }
+ parts = append(parts, "conflicts in "+a.teamNameOf(j.Team)+" go to "+who)
+ }
+ }
+ return strings.Join(parts, " "+a.teamsDot()+" ")
+}
+
+// teamMoveApply makes the move, as the person's edit, and offers it back.
+func (a *app) teamMoveApply(ids []string, parent string, from int) tea.Cmd {
+ tree := a.teamTree()
+ roots := tree.MoveRoots(ids)
+ back := map[string]string{}
+ homes := map[string]string{}
+ for _, id := range roots {
+ t, ok := a.teamByID(id)
+ if !ok {
+ continue
+ }
+ back[id] = t.Parent
+ for _, u := range append([]team{t}, tree.Descendants(id)...) {
+ for _, m := range u.Members {
+ if _, seen := homes[m.Key]; seen {
+ continue
+ }
+ homes[m.Key] = ""
+ if r, ok := tree.Home(m.Key); ok {
+ homes[m.Key] = r.Via
+ }
+ }
+ }
+ }
+ target := teamMoveParent(parent)
+ word := a.teamMoveDone(roots, parent)
+ // The notices are taken from the tree before the edit and the tree after
+ // it. The edit runs twice (teams.go), so the append is not inside it: it
+ // would be written twice, and the first time on the loop. One command
+ // writes them, through the store, after the move has been accepted.
+ before := &teamstore.File{Teams: teamsClone(a.wall.teams)}
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.Move(roots, target) }); err != nil {
+ a.tp.msg = "not moved: " + err.Error()
+ a.touch()
+ return nil
+ }
+ a.tmove.pend = teamMovePend{}
+ a.tmove.undo = teamMoveUndo{back: back, homes: homes, word: word, from: from, at: a.now()}
+ a.tp.msg = ""
+ a.tp.top = teamsTopCache{}
+ if a.tp.cur.act == teamsActMoveYes || a.tp.cur.act == teamsActMoveNo {
+ a.tp.cur = teamsRef{act: teamsActUndo}
+ }
+ a.touch()
+ return a.teamMoveTraffic(teamstore.MoveNotices(before, a.teamTree(), roots))
+}
+
+// teamMoveTraffic appends the move's Traffic lines through the seam, off the
+// loop. A seam with no Traffic door tells nothing. Nothing here runs on a frame.
+func (a *app) teamMoveTraffic(notes []teamstore.MoveNotice) tea.Cmd {
+ var cmds []tea.Cmd
+ for _, n := range notes {
+ cmds = append(cmds, a.teamsTell(n.Team, n.Entry))
+ }
+ return tea.Batch(cmds...)
+}
+
+// teamMoveDone is what the Undo row says of a move made: `api is in harbor
+// now`, `api is at the top level now`.
+func (a *app) teamMoveDone(ids []string, parent string) string {
+ subject := a.teamMoveSubject(ids)
+ if parent == teamMoveTop || parent == "" {
+ return subject + " is at the top level now"
+ }
+ return subject + " is in " + a.teamNameOf(parent) + " now"
+}
+
+// teamMoveCancel drops the move waiting on the person.
+func (a *app) teamMoveCancel() {
+ a.tmove.pend = teamMovePend{}
+ if a.tp.cur.act == teamsActMoveYes || a.tp.cur.act == teamsActMoveNo {
+ a.teamsCursorHome()
+ }
+ if a.tsheet.cursor == tsMoveYes || a.tsheet.cursor == tsMoveNo {
+ a.tsheet.cursor = tsInside
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+}
+
+// teamMoveConfirm is `Move` on the consequence line.
+func (a *app) teamMoveConfirm() tea.Cmd {
+ p := a.tmove.pend
+ if len(p.ids) == 0 {
+ return nil
+ }
+ return a.teamMoveApply(p.ids, p.parent, p.from)
+}
+
+// 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
+}
+
+// teamMoveUndo puts the last move back: every moved team under its parent
+// before, and every carried conversation reporting where it did.
+func (a *app) teamMoveUndo() tea.Cmd {
+ if !a.teamMoveUndoing() {
+ return nil
+ }
+ u := a.tmove.undo
+ a.tmove.undo = teamMoveUndo{}
+ err := a.teamEdit(func(f *teamstore.File) error {
+ for id, parent := range u.back {
+ if _, ok := f.Team(id); !ok {
+ continue
+ }
+ if err := f.SetParent(id, parent); err != nil {
+ return err
+ }
+ }
+ // A home is a flag the store's tidy may have set on the move; it is put
+ // back where it was when that membership can still be one, and a
+ // conversation that had none is left to the rule.
+ for key, via := range u.homes {
+ if via != "" {
+ _ = f.SetHome(key, via)
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ a.tp.msg = "not moved back: " + err.Error()
+ } else {
+ a.tp.msg = "moved back"
+ }
+ a.tp.top = teamsTopCache{}
+ if a.tp.cur.act == teamsActUndo {
+ a.teamsCursorHome()
+ }
+ a.touch()
+ return nil
+}
+
+// teamMoveSig is everything about a move the teams page's head is drawn from,
+// for its cache: "" while nothing is asked or offered.
+func (a *app) teamMoveSig() string {
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromPage {
+ return "ask:" + p.parent + ":" + p.words
+ }
+ if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromPage {
+ return "undo:" + a.tmove.undo.word
+ }
+ return ""
+}
+
+// teamsMoveRows is the pane's row for a move: the consequence line with `Move`
+// and `Cancel` while one waits, or the move just made with `Undo`. The line
+// wraps under itself rather than being cut, because it is the thing being
+// decided.
+func (a *app) teamsMoveRows(d *teamsDraw, width, y int) []string {
+ pal := a.pal
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromPage {
+ yes, no := "Move", "Cancel"
+ bw := ansi.StringWidth(yes) + ansi.StringWidth(no) + 6
+ said := wrap(p.words, max(width-2, 12))
+ var out []string
+ for i, l := range said {
+ line := " " + pal.ink(l)
+ if i < len(said)-1 {
+ out = append(out, line)
+ continue
+ }
+ x := ansi.StringWidth(line) + 2
+ if x+bw > width {
+ out = append(out, line)
+ line, x = " ", 1
+ } else {
+ line += " "
+ }
+ s1, w1 := d.button(yes, teamsTarget{act: teamsActMoveYes, x0: x, y: y + len(out),
+ hint: a.teamMoveDoing(p.ids, p.parent) + hintSegment + "enter"}, pal.accent)
+ s2, _ := d.button(no, teamsTarget{act: teamsActMoveNo, x0: x + w1 + 1, y: y + len(out),
+ hint: "Leave it where it is" + hintSegment + "esc"}, pal.ink)
+ out = append(out, line+s1+" "+s2)
+ }
+ return out
+ }
+ if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromPage {
+ word := " " + pal.dim(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}
+ }
+ return nil
+}
+
+// teamsCanNest reports whether a new team may be made inside parent, and when
+// not, why, in the picker's words. With the defaults not read yet the limit is
+// not known, and the answer is yes: the store's own tidy keeps the tree whole.
+func (a *app) teamsCanNest(parent string) (bool, string) {
+ t, ok := a.teamByID(parent)
+ if !ok {
+ return false, "that team is gone"
+ }
+ if t.Closed() {
+ return false, t.Name + " is closed"
+ }
+ tree := a.teamTree()
+ e := tree.Effective(parent, a.tp.defaults)
+ if e.DepthLimit <= 0 || tree.Depth(parent)+1 <= e.DepthLimit {
+ return true, ""
+ }
+ return false, a.teamMoveWhy(teamstore.MoveBlock{Kind: teamstore.MoveBlockDepth, Team: t.ID, Name: t.Name,
+ Depth: tree.Depth(parent), Need: 1, Limit: e.DepthLimit, LimitFrom: e.DepthFrom}, nil, parent)
+}
+
+// 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)
+}
diff --git a/internal/tui3/teamnest_test.go b/internal/tui3/teamnest_test.go
new file mode 100644
index 0000000000..e65a4dfc4e
--- /dev/null
+++ b/internal/tui3/teamnest_test.go
@@ -0,0 +1,569 @@
+package tui3
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/Agent-Field/codeaf/internal/config"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── NESTING ON THE TEAMS PAGE (rulings c-12, c-13) ──────────────────────────
+
+// nestLab is the teams page with three teams: harbor at the top with orbit
+// inside it, and dock at the top holding one conversation this window does not
+// have open. The depth limit is two, from Settings, so orbit (two deep) can
+// take nothing more. dock is selected.
+func nestLab(t *testing.T) (a *app, harbor, orbit, dock string) {
+ t.Helper()
+ a, harbor, orbit = teamsPlaceLabIDs(t)
+ dock = newTeamID()
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ f.Teams = append(f.Teams, teamstore.Team{ID: dock, Name: "dock", Members: []teamstore.Member{
+ {Key: a.convKey("/tmp/lab/crane.jsonl"), File: "/tmp/lab/crane.jsonl", Where: "/tmp/lab", Word: "crane work", Handle: "crane"}}})
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // The limit is the profile's own `teams.depth_limit`, so every read the
+ // page makes through the seam answers the same two.
+ if err := os.WriteFile(config.BudgetConfigPath(a.profileDir), []byte(`{"teams.depth_limit": 2}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ a.tp.defaults, a.tp.defaultsOK = teamstore.DefaultsAt(a.profileDir), true
+ drive(t, a, runCmd(a.teamsSelect(dock))...)
+ a.frame()
+ return a, harbor, orbit, dock
+}
+
+// nestRow is the frame row that holds want, -1 for none.
+//
+// The head is skipped: the strip of chats is on every page, and its team chip
+// reads `● harbor ▾` over the rail's own `● harbor`.
+func nestRow(a *app, want string) int {
+ for y, l := range strings.Split(teamsFrameText(a), "\n") {
+ if y >= placeHeadRows && strings.Contains(l, want) {
+ return y
+ }
+ }
+ return -1
+}
+
+// railPoint is a cell on team id's rail row, two cells in.
+func railPoint(t *testing.T, a *app, id string) (int, int) {
+ t.Helper()
+ tg := teamsTargetOf(t, a, teamsActSelect, id)
+ return tg.x0 + 2, tg.y
+}
+
+// press, drag and release are the pointer's three moves with the left button.
+func nestPress(x, y int) tea.MouseClickMsg {
+ return tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}
+}
+func dragTo(x, y int) tea.MouseMotionMsg {
+ return tea.MouseMotionMsg{X: x, Y: y, Button: tea.MouseLeft}
+}
+func release(x, y int) tea.MouseReleaseMsg {
+ return tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft}
+}
+
+// parentOf is team id's parent as the window holds it.
+func parentOf(a *app, id string) string {
+ t, _ := a.teamByID(id)
+ return t.Parent
+}
+
+// THE PICKER LISTS EVERY TEAM AND DIMS THE ONES THAT CANNOT TAKE THE MOVE with
+// the reason in its foot and the hint: the team itself, a team under it, and a
+// team too deep for the limit (`orbit is 2 levels deep · limit 2 · Settings`).
+// Typing filters, and the tree keeps its indent.
+func TestMoveIntoPickerDimsInvalidTargetsWithTheReason(t *testing.T) {
+ a, harbor, orbit, dock := nestLab(t)
+ drive(t, a, key("m"))
+ if !a.tmove.on || len(a.tmove.ids) != 1 || a.tmove.ids[0] != dock {
+ t.Fatalf("m did not open the picker for dock: %+v", a.tmove)
+ }
+ rows := a.teamMoveRows()
+ want := map[string]string{
+ teamMoveTop: "dock is at the top level already",
+ harbor: "",
+ orbit: "orbit is 2 levels deep · limit 2 · Settings",
+ dock: "a team cannot go inside itself",
+ }
+ if len(rows) != 4 {
+ t.Fatalf("the picker has %d rows, want 4: %+v", len(rows), rows)
+ }
+ for _, r := range rows {
+ why, ok := want[r.parent]
+ if !ok {
+ t.Fatalf("an unexpected row %q", r.name)
+ }
+ if r.ok != (why == "") || r.why != why {
+ t.Fatalf("row %s: ok %v why %q, want why %q", r.name, r.ok, r.why, why)
+ }
+ }
+ // The keyboard starts on the first row that can take the move.
+ if a.tmove.cursor != harbor {
+ t.Fatalf("the cursor starts on %q", a.tmove.cursor)
+ }
+ drive(t, a, key("down"))
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "Move dock into") || !strings.Contains(text, "orbit is 2 levels deep · limit 2 · Settings") {
+ t.Fatalf("the dimmed row's reason is not in the picker's foot:\n%s", text)
+ }
+ if hint := a.teamMoveHint(); !strings.Contains(hint, "limit 2") {
+ t.Fatalf("the hint line does not say why: %q", hint)
+ }
+ // enter on a dimmed row moves nothing and keeps the picker up.
+ drive(t, a, key("enter"))
+ if !a.tmove.on || parentOf(a, dock) != "" {
+ t.Fatal("enter on a blocked row moved the team or closed the picker")
+ }
+ // The indent survives: orbit stands deeper than harbor.
+ hy, oy := nestRow(a, "● harbor"), nestRow(a, "● orbit")
+ lines := strings.Split(teamsFrameText(a), "\n")
+ if hy < 0 || oy < 0 || strings.Index(lines[oy], "●") <= strings.Index(lines[hy], "●") {
+ t.Fatalf("the picker's tree lost its indent:\n%s", text)
+ }
+ // Typing filters.
+ drive(t, a, key("o"), key("r"), key("b"))
+ if rows := a.teamMoveRows(); len(rows) != 1 || rows[0].parent != orbit {
+ t.Fatalf("the filter `orb` left %+v", rows)
+ }
+ drive(t, a, key("esc"))
+ if a.tmove.on {
+ t.Fatal("esc did not close the picker")
+ }
+}
+
+// A MOVE THAT CHANGES NOTHING A PERSON DECIDES ON IS MADE AT ONCE, and Undo
+// puts it back.
+func TestMoveIntoAQuietTeamIsInstantWithUndo(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ drive(t, a, key("m"), key("enter"))
+ if a.tmove.on || parentOf(a, dock) != harbor {
+ t.Fatalf("dock is under %q, the picker on %v", parentOf(a, dock), a.tmove.on)
+ }
+ if len(a.tmove.pend.ids) > 0 {
+ t.Fatal("a quiet move asked first")
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "dock is in harbor now") || !strings.Contains(text, "Undo") {
+ t.Fatalf("the move offers no Undo:\n%s", text)
+ }
+ drive(t, a, key("u"))
+ if parentOf(a, dock) != "" {
+ t.Fatalf("Undo left dock under %q", parentOf(a, dock))
+ }
+}
+
+// A MOVE THAT CHANGES WHO DECIDES ASKS ONE LINE FIRST: with harbor managed and
+// capped, dock into harbor says whose manager and whose pool, with Move and
+// Cancel. Cancel leaves it; Move moves it; Undo is offered after it too.
+func TestMoveConsequenceLineOnlyWhenSomethingChanges(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ ten := 10.0
+ three := 3.0
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ if err := f.AddMember(harbor, teamstore.Member{Key: "boss-key", Handle: "boss", Word: "the boss"}); err != nil {
+ return err
+ }
+ if err := f.SetManager(harbor, "boss-key"); err != nil {
+ return err
+ }
+ if err := f.SetSettings(dock, func(s *teamstore.Settings) { s.CapUSDDay = &three }); err != nil {
+ return err
+ }
+ return f.SetSettings(harbor, func(s *teamstore.Settings) { s.CapUSDDay = &ten })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ drive(t, a, runCmd(a.teamMoveAsk([]string{dock}, harbor, teamMoveFromPage))...)
+ p := a.tmove.pend
+ if len(p.ids) == 0 {
+ t.Fatal("a move under a manager and a cap did not ask")
+ }
+ for _, want := range []string{"dock will report to harbor's manager", "its $3/day becomes part of harbor's $10 pool"} {
+ if !strings.Contains(p.words, want) {
+ t.Fatalf("the line %q lacks %q", p.words, want)
+ }
+ }
+ if parentOf(a, dock) != "" {
+ t.Fatal("the move was made before the person said Move")
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "report to harbor's manager") || !strings.Contains(text, "Move") || !strings.Contains(text, "Cancel") {
+ t.Fatalf("the consequence line is not on the pane:\n%s", text)
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActMoveNo, "")))...)
+ if parentOf(a, dock) != "" || len(a.tmove.pend.ids) > 0 {
+ t.Fatal("Cancel moved the team or left the line up")
+ }
+ drive(t, a, runCmd(a.teamMoveAsk([]string{dock}, harbor, teamMoveFromPage))...)
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActMoveYes, "")))...)
+ if parentOf(a, dock) != harbor {
+ t.Fatal("Move did not move the team")
+ }
+ if !a.teamMoveUndoing() || !strings.Contains(teamsFrameText(a), "Undo") {
+ t.Fatal("a confirmed move offers no Undo")
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActUndo, "")))...)
+ if parentOf(a, dock) != "" {
+ t.Fatal("Undo did not put the confirmed move back")
+ }
+}
+
+// A DRAG STARTS ONLY AFTER TWO CELLS OF HELD MOVEMENT: a one-cell wobble and a
+// release is a click that selects; two cells onto harbor is a drag that says
+// `Drop to move dock into harbor`, grounds harbor, and moves it on release.
+func TestDragStartsAfterTwoCellsAndDropsOnAValidTeam(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ dx, dy := railPoint(t, a, dock)
+ drive(t, a, nestPress(dx, dy), dragTo(dx+1, dy))
+ if a.tdrag.on {
+ t.Fatal("a one-cell wobble started a drag")
+ }
+ drive(t, a, release(dx+1, dy))
+ if a.tdrag.press || parentOf(a, dock) != "" || a.tp.sel != dock {
+ t.Fatalf("a click moved the team or did not select it (sel %q)", a.tp.sel)
+ }
+ hx, hy := railPoint(t, a, harbor)
+ drive(t, a, nestPress(dx, dy), dragTo(dx, dy-1), dragTo(hx, hy))
+ if !a.tdrag.on {
+ t.Fatal("two cells of held movement did not start a drag")
+ }
+ if hint := a.teamDragHint(); !strings.HasPrefix(hint, "Drop to move dock into harbor") {
+ t.Fatalf("the drag's hint: %q", hint)
+ }
+ if !a.teamDropLit(harbor) {
+ t.Fatal("harbor is not grounded as the drop")
+ }
+ drive(t, a, release(hx, hy))
+ if parentOf(a, dock) != harbor || a.tdrag.press {
+ t.Fatalf("the drop left dock under %q", parentOf(a, dock))
+ }
+}
+
+// ONLY A VALID TARGET TAKES A DROP: orbit is past the depth limit, so it is not
+// grounded, the hint says why, and a release there moves nothing. The empty rail
+// under the tree is the top level, and esc drops a drag with nothing moved.
+func TestDragDropsOnlyOnValidTargetsAndEscCancels(t *testing.T) {
+ a, harbor, orbit, dock := nestLab(t)
+ dx, dy := railPoint(t, a, dock)
+ ox, oy := railPoint(t, a, orbit)
+ drive(t, a, nestPress(dx, dy), dragTo(dx+4, dy+3), dragTo(ox, oy))
+ if a.teamDropLit(orbit) || !strings.Contains(a.teamDragHint(), "orbit is 2 levels deep") {
+ t.Fatalf("a blocked target is grounded or unexplained: %q", a.teamDragHint())
+ }
+ drive(t, a, release(ox, oy))
+ if parentOf(a, dock) != "" {
+ t.Fatal("a drop on a blocked target moved the team")
+ }
+ // orbit to the top level, by the empty rail under the tree.
+ below := -1
+ for _, tg := range a.tp.targets {
+ if tg.act == teamsActNewTeam {
+ below = tg.y - 1
+ }
+ }
+ drive(t, a, nestPress(ox, oy), dragTo(ox, oy+2), dragTo(3, below))
+ if !a.teamDropLit(teamMoveTop) || !strings.Contains(teamsFrameText(a), "Top level") {
+ t.Fatalf("the empty rail is not the top level: %q\n%s", a.teamDragHint(), teamsFrameText(a))
+ }
+ drive(t, a, release(3, below))
+ if parentOf(a, orbit) != "" {
+ t.Fatalf("orbit is still under %q", parentOf(a, orbit))
+ }
+ // esc drops a drag and nothing happens.
+ hx, hy := railPoint(t, a, harbor)
+ drive(t, a, nestPress(dx, dy), dragTo(hx, hy+3), dragTo(hx, hy))
+ drive(t, a, key("esc"))
+ if a.tdrag.press || a.tdrag.on {
+ t.Fatal("esc did not drop the drag")
+ }
+ drive(t, a, release(hx, hy))
+ if parentOf(a, dock) != "" {
+ t.Fatal("a drag esc cancelled still moved the team")
+ }
+}
+
+// DRAGGING A MEMBER ONTO A TEAM ADDS IT, AND NEVER TAKES IT OUT of the team it
+// came from: @crane dragged from dock's members card onto harbor is in both.
+func TestDraggingAMemberRowOntoATeamAddsIt(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ drive(t, a, key(teamCrewLetter))
+ if !a.tcrew.on {
+ t.Fatal("p did not open the members card")
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "@crane") || !strings.Contains(text, "Resume") || strings.Contains(text, "not open") {
+ t.Fatalf("the members card:\n%s", text)
+ }
+ var row wallHit
+ for _, h := range a.tcrew.hits {
+ if h.kind == crewHitRow && h.arg == 0 {
+ row = h
+ }
+ }
+ if row.x1 == 0 {
+ t.Fatalf("no row on the members card:\n%s", text)
+ }
+ hx, hy := railPoint(t, a, harbor)
+ drive(t, a, nestPress(row.x0+2, row.y0), dragTo(row.x0+5, row.y0+1), dragTo(hx, hy))
+ if hint := a.teamDragHint(); !strings.HasPrefix(hint, "Add @crane to harbor") {
+ t.Fatalf("the member drag's hint: %q", hint)
+ }
+ drive(t, a, release(hx, hy))
+ key := a.convKey("/tmp/lab/crane.jsonl")
+ h, _ := a.teamByID(harbor)
+ d, _ := a.teamByID(dock)
+ if !h.Holds(key) || !d.Holds(key) {
+ t.Fatalf("the drop moved rather than added: harbor %v dock %v", h.Holds(key), d.Holds(key))
+ }
+}
+
+// SPACE PICKS TEAMS ON THE RAIL, AND ONE MOVE INTO… MOVES THEM ALL.
+func TestMultiSelectedTeamsMoveTogether(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ pier := newTeamID()
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ f.Teams = append(f.Teams, teamstore.Team{ID: pier, Name: "pier"})
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(config.BudgetConfigPath(a.profileDir), []byte(`{"teams.depth_limit": 4}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ a.tp.defaults = teamstore.DefaultsAt(a.profileDir)
+ a.frame()
+ for _, id := range []string{dock, harbor} {
+ a.tp.focus, a.tp.cur = true, teamsRef{act: teamsActSelect, id: id}
+ drive(t, a, key(" "))
+ }
+ if got := a.teamsPickedIDs(); len(got) != 2 {
+ t.Fatalf("picked %v", got)
+ }
+ if !strings.Contains(teamsFrameText(a), wallGlyphsFor(false).marked) {
+ t.Fatal("the rail does not mark the picked teams")
+ }
+ drive(t, a, key("m"))
+ if !strings.Contains(teamsFrameText(a), "Move 2 teams into") {
+ t.Fatalf("the picker is not for both:\n%s", teamsFrameText(a))
+ }
+ for _, r := range a.teamMoveRows() {
+ if r.parent == pier {
+ drive(t, a, runCmd(a.teamMoveChoose(r))...)
+ }
+ }
+ if parentOf(a, dock) != pier || parentOf(a, harbor) != pier {
+ t.Fatalf("dock under %q, harbor under %q", parentOf(a, dock), parentOf(a, harbor))
+ }
+}
+
+// `+ NEW TEAM` WITH A TEAM CHOSEN READS `+ New team in harbor` and makes the
+// team inside it; a team at the depth limit dims it and says why.
+func TestNewTeamInTheChosenTeam(t *testing.T) {
+ a, harbor, orbit, _ := nestLab(t)
+ drive(t, a, runCmd(a.teamsSelect(harbor))...)
+ if !strings.Contains(teamsFrameText(a), "+ New team in harbor") {
+ t.Fatalf("the rail does not offer a team inside harbor:\n%s", teamsFrameText(a))
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActNewTeam, harbor)))...)
+ if !a.wall.on || !a.wall.naming || a.wall.nameParent != harbor {
+ t.Fatalf("the new-team card is not for a team in harbor: on %v naming %v parent %q", a.wall.on, a.wall.naming, a.wall.nameParent)
+ }
+ if !strings.Contains(teamsFrameText(a), "New team in harbor") {
+ t.Fatalf("the card does not say where the team goes:\n%s", teamsFrameText(a))
+ }
+ a.wall.name = "slip"
+ drive(t, a, runCmd(a.wallMakeTeam(a.wallShown(a.now())))...)
+ var made team
+ for _, u := range a.wall.teams {
+ if u.Name == "slip" {
+ made = u
+ }
+ }
+ if made.Parent != harbor {
+ t.Fatalf("the new team is under %q, want harbor", made.Parent)
+ }
+ a.closeWall()
+ drive(t, a, key("alt+2"))
+ drive(t, a, runCmd(a.teamsSelect(orbit))...)
+ tg := teamsTargetOf(t, a, teamsActNewTeam, orbit)
+ if !strings.Contains(tg.hint, "limit 2") {
+ t.Fatalf("a team at the limit does not say why: %q", tg.hint)
+ }
+ drive(t, a, runCmd(a.teamsDo(tg))...)
+ if a.wall.naming {
+ t.Fatal("a team at the limit opened the new-team card")
+ }
+}
+
+// THE TEAM'S CARD HAS `Inside: … ▾`, which opens the same picker, and the move
+// it makes is offered back on the card.
+func TestTheCardsInsideFieldMovesTheTeam(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ drive(t, a, runCmd(a.teamSheetOpen(dock, teamSheetSettings))...)
+ if text := teamsFrameText(a); !strings.Contains(text, "Inside") || !strings.Contains(text, "Top level ▾") {
+ t.Fatalf("the card has no Inside field:\n%s", text)
+ }
+ drive(t, a, runCmd(a.teamSheetDo(tsInside))...)
+ if !a.tmove.on || a.tmove.from != teamMoveFromCard {
+ t.Fatal("Inside did not open the picker")
+ }
+ drive(t, a, key("enter"))
+ if parentOf(a, dock) != harbor {
+ t.Fatalf("dock is under %q", parentOf(a, dock))
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "harbor ▾") || !strings.Contains(text, "Undo") {
+ t.Fatalf("the card does not show the move and its Undo:\n%s", text)
+ }
+ drive(t, a, key("u"))
+ if parentOf(a, dock) != "" {
+ t.Fatal("u on the card did not undo the move")
+ }
+}
+
+// THE SWITCHER IS THE TREE, INDENTED; THE WALL'S TEAMS ROW STAYS FLAT and says
+// `harbor › orbit` for the team inside harbor.
+func TestSwitcherIsTheTreeAndTheWallSaysParentAndChild(t *testing.T) {
+ a, harbor, orbit, _ := nestLab(t)
+ a.leavePlace()
+ a.openTeamMenu()
+ frame, _, _ := a.frame()
+ lines := strings.Split(plain(frame), "\n")
+ hy, oy := -1, -1
+ // The switcher's rows, not the strip's chip above it: a radio leads each.
+ for y, l := range lines {
+ if hy < 0 && strings.Contains(l, "◉ ● harbor") {
+ hy = y
+ }
+ if oy < 0 && strings.Contains(l, "● orbit") && strings.Contains(l, "○") {
+ oy = y
+ }
+ }
+ if hy < 0 || oy != hy+1 || strings.Index(lines[oy], "●") <= strings.Index(lines[hy], "●") {
+ t.Fatalf("the switcher does not indent orbit under harbor:\n%s", plain(frame))
+ }
+ a.closeTeamMenu()
+ o, _ := a.teamByID(orbit)
+ if got := a.wallTeamLabel(o); got != "harbor › orbit" {
+ t.Fatalf("the wall calls orbit %q", got)
+ }
+ h, _ := a.teamByID(harbor)
+ if got := a.wallTeamLabel(h); got != "harbor" {
+ t.Fatalf("the wall calls harbor %q", got)
+ }
+ a.width = 160
+ drive(t, a, runCmd(a.openWall())...)
+ if !strings.Contains(teamsFrameText(a), "harbor › orbit") {
+ t.Fatalf("the wall's Teams row does not say harbor › orbit:\n%s", teamsFrameText(a))
+ }
+}
+
+// THE HEADER IS ONE LINE AT EVERY WIDTH: the name always, the buttons unless
+// nothing else fits, a working member as a chip, everyone else one idle word,
+// and narrow, the idle word goes before the chip.
+func TestTheTeamHeaderIsOneLineAndDropsInOrder(t *testing.T) {
+ for _, width := range []int{80, 110, 160} {
+ t.Run(itoa(width), func(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ a.width = width
+ a.state = stateWorking
+ drive(t, a, runCmd(a.teamsSelect(harbor))...)
+ text := teamsFrameText(a)
+ y := nestRow(a, "Settings")
+ if y < 0 {
+ t.Fatalf("no header at %d:\n%s", width, text)
+ }
+ line := strings.Split(text, "\n")[y]
+ if !strings.Contains(line, "harbor") {
+ t.Fatalf("the header lost the team's name at %d: %q", width, line)
+ }
+ chip := strings.Contains(line, "working")
+ idle := strings.Contains(line, "idle")
+ if idle && !chip {
+ t.Fatalf("the idle word stayed while the working chip went at %d: %q", width, line)
+ }
+ if width >= 110 && (!chip || !idle) {
+ t.Fatalf("at %d the header should have room for the chip and the idle word: %q", width, line)
+ }
+ next := strings.Split(text, "\n")[y+1]
+ if strings.Contains(next, "idle") || strings.Contains(next, "not open") {
+ t.Fatalf("the members spilled onto a second row at %d: %q", width, next)
+ }
+ })
+ }
+}
+
+// A RULING AND A SUB-TEAM START READ AS WHAT THEY ARE on the threaded
+// Traffic rail (DESIGN.md 8.10).
+func TestTrafficSaysRulingsAndSubTeamStarts(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ h, _ := a.teamByID(harbor)
+ lay := func(e teamstore.Entry) (string, string) {
+ // A ruling is a row of work and a start is chatter, a line under
+ // General: each is laid the way the manager's Traffic lays it.
+ s := &sideSheet{a: a, t: h, width: 90, hotDoor: -1}
+ th := teamstore.Thread{Root: e, Latest: e.ID}
+ if sideChatter(th) {
+ s.reply(e)
+ } else {
+ s.work(th, e)
+ }
+ var rows, hints []string
+ for _, l := range s.lines {
+ rows = append(rows, plain(l.text))
+ if l.side != nil {
+ hints = append(hints, l.side.hint)
+ for _, d := range l.side.doors {
+ hints = append(hints, d.hint)
+ }
+ }
+ }
+ return strings.Join(rows, "\n"), strings.Join(hints, "\n")
+ }
+ ruling := teamstore.Entry{ID: "000000000007", Kind: teamstore.KindDirective, From: teamstore.FromManager, To: "web", Packet: "p9",
+ Text: "ruling on the conflict p9, by ◆ @boss (manager of \"harbor\"): JSON: the form stays"}
+ got, hint := lay(ruling)
+ if !strings.Contains(got, "ruling") || strings.Contains(got, " do") || !strings.Contains(got, "JSON: the form stays") {
+ t.Fatalf("the ruling rows: %q", got)
+ }
+ if !strings.Contains(hint, "p9") {
+ t.Fatalf("the ruling's hint does not name its packet: %q", hint)
+ }
+ start := teamstore.Entry{ID: "000000000008", Kind: teamstore.KindStart, From: teamstore.FromManager, To: "api", Team: orbit, Text: "run it"}
+ if got, _ := lay(start); !strings.Contains(got, "started @api to run orbit") {
+ t.Fatalf("the sub-team start rows: %q", got)
+ }
+}
+
+// THE MOVE ACTION WRITES TRAFFIC. dock (member @crane) into harbor is one
+// line on the team that moved and one on the team it joined, each once.
+func TestMoveIntoWritesTraffic(t *testing.T) {
+ a, harbor, _, dock := nestLab(t)
+ if log, _ := teamstore.ReadTraffic(a.profileDir, dock, "", 0); len(log) != 0 {
+ t.Fatalf("traffic before the move: %+v", log)
+ }
+ drive(t, a, key("m"), key("enter"))
+ if parentOf(a, dock) != harbor {
+ t.Fatalf("dock is under %q", parentOf(a, dock))
+ }
+ one := func(team, text string) {
+ t.Helper()
+ log, err := teamstore.ReadTraffic(a.profileDir, team, "", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(log) != 1 || log[0].Kind != teamstore.KindEvent || log[0].Text != text {
+ t.Fatalf("%s traffic: %+v, want one %q", team, log, text)
+ }
+ }
+ one(dock, "@crane moved to harbor")
+ one(harbor, "@crane joined from the top")
+}
diff --git a/internal/tui3/teamorganize.go b/internal/tui3/teamorganize.go
index eb590a7b2e..5d002a8612 100644
--- a/internal/tui3/teamorganize.go
+++ b/internal/tui3/teamorganize.go
@@ -52,8 +52,17 @@ type orgProp struct {
reason string
folder bool
take bool
+ // closes is the teams a `Close N quiet teams` suggestion closes (ruling
+ // c-9): open teams with no activity for [teamstore.QuietAfter] and nothing
+ // waiting. It is the one suggestion that is not about membership, and its
+ // team is [orgCloseRow].
+ closes []string
}
+// orgCloseRow is the team field of the close-quiet suggestion: no team's id,
+// so Apply's membership pass passes over it.
+const orgCloseRow = "\x00close"
+
// wallOrganize is Organize's whole state, and the painter's input once the
// frame has filled in loose and clean.
type wallOrganize struct {
@@ -78,10 +87,16 @@ type wallOrganize struct {
doneAt time.Time
// 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
- undoJoins []orgJoin
- made int
- added int
+ undoMade []string
+ undoJoins []orgJoin
+ undoClosed []string
+ made int
+ added int
+ closed int
+ // quiet is the open teams the last off-loop read found quiet, and quietGen
+ // the ask it answered.
+ quiet []string
+ quietGen int
// cleanSig is the state of the conversations and teams when the last run
// found nothing to suggest, and cleanSet says there is one.
cleanSig uint64
@@ -392,6 +407,7 @@ func (a *app) wallOrganizeOpen() tea.Cmd {
a.wall.hover = wallHitRef{}
a.wall.stirred = true
a.touch()
+ quietAsk := a.wallOrganizeQuietAsk(o.gen)
convs := a.orgConvs()
var proposer teamProposer
if a.agent != nil {
@@ -399,7 +415,17 @@ func (a *app) wallOrganizeOpen() tea.Cmd {
}
if proposer == nil || len(convs) < 2 {
a.wallOrganizeShow(nil, proposer == nil && len(convs) >= 2, "")
- return nil
+ if quietAsk == nil {
+ return nil
+ }
+ gen := o.gen
+ return a.besideLine(func() func(here bool) tea.Cmd {
+ ids := quietAsk()
+ return func(bool) tea.Cmd {
+ a.wallOrganizeQuietTake(gen, ids)
+ return nil
+ }
+ })
}
in := session.TeamProposalInput{}
for _, c := range convs {
@@ -415,16 +441,86 @@ func (a *app) wallOrganizeOpen() tea.Cmd {
gen := o.gen
o.thinking = true
return a.besideLine(func() func(here bool) tea.Cmd {
+ var quiet []string
+ if quietAsk != nil {
+ quiet = quietAsk()
+ }
ctx, cancel := context.WithTimeout(context.Background(), organizeWait)
defer cancel()
res, err := proposer.ProposeTeams(ctx, in)
return func(bool) tea.Cmd {
+ a.wallOrganizeQuietTake(gen, quiet)
a.wallOrganized(gen, res, err)
return nil
}
})
}
+// wallOrganizeQuietAsk is the question, to be asked off the loop in the same
+// read as the model's, of which open teams have been quiet for
+// [teamstore.QuietAfter] with nothing waiting, for the card's `Close N quiet
+// teams`. It reads this machine's profile, so over --host it is nil: the
+// engine has no door for it yet, and the card simply does not offer it.
+func (a *app) wallOrganizeQuietAsk(gen int) func() []string {
+ o := &a.wall.org
+ o.quiet, o.quietGen = nil, gen
+ if a.hosted() || a.teamsOff() {
+ return nil
+ }
+ dir, now := a.profileDir, a.now()
+ tree := &teamstore.File{Teams: teamsClone(a.wall.teams)}
+ return func() []string {
+ ids, err := teamstore.Quiet(dir, tree, now, teamstore.QuietAfter)
+ if err != nil {
+ return nil
+ }
+ return ids
+ }
+}
+
+// wallOrganizeQuietTake folds the quiet teams in: kept for the card, and put
+// on it at once when the card is already showing its suggestions.
+func (a *app) wallOrganizeQuietTake(gen int, ids []string) {
+ o := &a.wall.org
+ if gen != o.quietGen || !o.on {
+ return
+ }
+ o.quiet = ids
+ if !o.thinking {
+ o.props = a.orgWithQuiet(o.props)
+ a.touch()
+ }
+}
+
+// orgWithQuiet is props with the close-quiet suggestion last, when any team
+// is quiet and the suggestion is not there yet.
+func (a *app) orgWithQuiet(props []orgProp) []orgProp {
+ o := &a.wall.org
+ if len(o.quiet) == 0 {
+ return props
+ }
+ for _, p := range props {
+ if p.team == orgCloseRow {
+ return props
+ }
+ }
+ p := orgProp{team: orgCloseRow, take: true, reason: "Nothing has happened in these for a week; Undo takes it back"}
+ for _, id := range o.quiet {
+ if t, ok := a.teamByID(id); ok && !t.Closed() && !t.Root {
+ p.closes = append(p.closes, id)
+ p.names = append(p.names, t.Name)
+ }
+ }
+ if len(p.closes) == 0 {
+ return props
+ }
+ p.name = "Close " + strconv.Itoa(len(p.closes)) + " quiet team"
+ if len(p.closes) != 1 {
+ p.name += "s"
+ }
+ return append(props, p)
+}
+
// wallOrganized takes answer gen into the card, if it is still the one being
// waited for and the card is still up. A failure shows the folder pass alone.
func (a *app) wallOrganized(gen int, res session.TeamProposal, err error) {
@@ -474,6 +570,7 @@ func (a *app) wallOrganizeShow(model []orgProp, folderOnly bool, cost string) {
used = append(used, p.hue)
}
}
+ props = a.orgWithQuiet(props)
o.thinking, o.props, o.folderOnly, o.cost = false, props, folderOnly, cost
o.cursor, o.top = 0, 0
o.cleanSet = len(props) == 0 && !folderOnly
@@ -537,10 +634,15 @@ func (a *app) wallOrganizeApply() {
// file as it is on disk. The same two lists are what Undo takes back.
var fresh []team
var joins []orgJoin
+ var closes []string
for _, p := range o.props {
if !p.take {
continue
}
+ if p.team == orgCloseRow {
+ closes = append(closes, p.closes...)
+ continue
+ }
members := teamFromTabs(p.name, a.wallTabsFor(p.keys, nil), now).Members
i := teamIndex(a.wall.teams, p.team)
if p.team == "" {
@@ -580,11 +682,11 @@ func (a *app) wallOrganizeApply() {
}
a.wallOrganizeClose()
made, added := len(fresh), len(joins)
- if made == 0 && added == 0 {
+ if made == 0 && added == 0 && len(closes) == 0 {
return
}
- o.undo, o.doneAt, o.made, o.added = prior, now, made, added
- o.undoMade, o.undoJoins = nil, joins
+ o.undo, o.doneAt, o.made, o.added, o.closed = prior, now, made, added, len(closes)
+ o.undoMade, o.undoJoins, o.undoClosed = nil, joins, closes
for _, t := range fresh {
o.undoMade = append(o.undoMade, t.ID)
}
@@ -602,11 +704,25 @@ func (a *app) wallOrganizeApply() {
return err
}
}
+ // A quiet team closes with no report: nothing was running to wrap up.
+ for _, id := range closes {
+ if t, ok := f.Team(id); !ok || t.Closed() {
+ continue
+ }
+ if err := f.Close(id, now, ""); err != nil {
+ return err
+ }
+ }
return nil
})
if err != nil {
a.note("the teams are kept for this window, but " + err.Error())
}
+ for _, id := range closes {
+ if a.wall.activeID == id {
+ a.wall.activeID = ""
+ }
+ }
}
// orgJoin is one conversation Apply put into a team.
@@ -635,9 +751,9 @@ func (a *app) wallOrganizeUndo() {
if o.undo == nil || o.doneAt.IsZero() || a.now().Sub(o.doneAt) >= wallOrganizedFor {
return
}
- made, joins := o.undoMade, o.undoJoins
+ made, joins, closed := o.undoMade, o.undoJoins, o.undoClosed
o.undo, o.doneAt = nil, time.Time{}
- o.undoMade, o.undoJoins = nil, nil
+ o.undoMade, o.undoJoins, o.undoClosed = nil, nil, nil
a.wall.hover = wallHitRef{}
a.wall.stirred = true
a.touch()
@@ -653,6 +769,14 @@ func (a *app) wallOrganizeUndo() {
return err
}
}
+ for _, id := range closed {
+ if t, ok := f.Team(id); !ok || !t.Closed() {
+ continue
+ }
+ if err := f.Reopen(id); err != nil {
+ return err
+ }
+ }
return nil
})
if teamIndex(a.wall.teams, a.wall.activeID) < 0 {
@@ -774,6 +898,9 @@ func wallOrganizeButton(pal palette, g wallGlyphs, v wallView, y int) wallOrgPie
if o.added > 0 {
said = append(said, strconv.Itoa(o.added)+" added")
}
+ if o.closed > 0 {
+ said = append(said, strconv.Itoa(o.closed)+" closed")
+ }
word := "Organized " + g.sep + " " + strings.Join(said, ", ") + " "
undo := wallButton{act: wallActOrgUndo, label: "Undo"}
hot := v.hover == wallHitRef{kind: wallHitAction, arg: int(wallActOrgUndo)}
@@ -877,9 +1004,12 @@ func wallOrgCard(pal palette, g wallGlyphs, v wallView, width, height int) wallC
if i == 0 && p.team == "" {
heading("New teams")
}
- if p.team != "" && (i == 0 || o.props[i-1].team == "") {
+ if p.team != "" && p.team != orgCloseRow && (i == 0 || o.props[i-1].team == "") {
heading("Add to existing")
}
+ if p.team == orgCloseRow {
+ heading("Quiet for a week")
+ }
if i == o.cursor {
cursorLine = len(list)
}
@@ -935,6 +1065,9 @@ func wallOrgCard(pal palette, g wallGlyphs, v wallView, width, height int) wallC
// wallOrgCount is a row's count: how many a new team holds, or `+ 2` for how
// many an existing team gains.
func wallOrgCount(p orgProp) string {
+ if p.team == orgCloseRow {
+ return strconv.Itoa(len(p.closes))
+ }
if p.team != "" {
return "+ " + strconv.Itoa(len(p.keys))
}
@@ -961,6 +1094,14 @@ func wallOrgRow(pal palette, g wallGlyphs, k wallKeys, v wallView, p orgProp, i,
count := wallOrgCount(p)
left := box + " " + mark + " " + pal.ink(name) + strings.Repeat(" ", nameW-ansi.StringWidth(name)+1) +
strings.Repeat(" ", countW-len(count)) + pal.dim(count) + " "
+ // THE QUIET-TEAMS ROW IS NOT A TEAM. It has no colour to wear and its
+ // count is already in its words, so it is its sentence whole (`Close 2
+ // quiet teams`), then the teams it names; cut to a team name's width it
+ // read `● Close 2 quiet t… 2`.
+ if p.team == orgCloseRow {
+ name = fit(p.name, max(inner/2, 8))
+ left = box + " " + pal.ink(name) + " "
+ }
detail := "from the folder"
if !p.folder || p.team != "" {
cut := make([]string, 0, len(p.names))
diff --git a/internal/tui3/teamrail.go b/internal/tui3/teamrail.go
index fdd7f8a40c..38bcf2c514 100644
--- a/internal/tui3/teamrail.go
+++ b/internal/tui3/teamrail.go
@@ -8,238 +8,28 @@ import (
teamstore "github.com/Agent-Field/codeaf/internal/teams"
)
-// ── THE RAIL: WHERE A PERSON READS THE TRAFFIC ─────────────────────────────
+// ── THE TRAFFIC'S WORDS ─────────────────────────────────────────────────────
//
-// While the manager is the conversation in front, the right of the body is the
-// team's Traffic as threads, the one that moved last at the top, under a
-// header that says what it is and how to put it away (teamthread.go draws
-// the threads):
+// The Traffic is read on the side column, as its second word (sidecol.go and
+// sidetraffic.go draw it). This file is the vocabulary both it and the thread
+// cards in the conversation spell an entry with: an address, whether an entry
+// is drawn at all, whether it is a member asking the person, how old it is,
+// and what the person has already had in front of them.
//
-// Traffic hide alt+l
-// ◆ manager → @web @parser do 2m
-// take the scope model and the lexer
-// ├ @parser ✓ the lexer is in 1m
-// └ @web asking: may I run the migration now (the needs-you amber)
-//
-// THE RIGHT COLUMN IS TRAFFIC'S FIRST. The task column and the rail are both
-// right-hand panels, and two of them side by side left the conversation ninety
-// columns on a wide frame and folded the rail to nothing at 110. So while the
-// manager is in front and the rail stands, the task column folds to its own
-// edge (task.go's [app.railStowed]), with its whisper of what the work is doing
-// still in it, and a press on that edge or ctrl+g brings the tasks back by
-// putting the Traffic away. It is one column with two things to show, the way
-// an editor's side panel is.
-//
-// PUT AWAY, OR ON A FRAME TOO NARROW FOR IT, the rail is an edge: the word
-// `Traffic` down the right, with a quiet count of what arrived since the person
-// last had it in front of them. On a wide frame a press on the edge brings the
-// column back; on a narrow one it lays the Traffic over the lower part of the
-// body as a card with its own border, `Close esc` in its foot, and the top of
-// the conversation still in view above it. [trafficKey] does the same from
-// the keyboard, and `esc` closes the card.
-//
-// EVERY HANDLE IS A DOOR to its member, and a message's words are a door that
-// lays them out in full; a word about the whole team (`everyone`) is not, and
-// does not light. Under the pointer the hint line says what a press does, and
-// over a message its whole text.
-//
-// THE FRAME DRAWS THE CACHE AND NOTHING ELSE, and the column's rows are kept
-// between frames ([trafficCache]) until the entries, the width, the pointer or
-// the minute move.
+// EVERY HANDLE IS A DOOR to its member, and a message's words are a door to
+// the message; a word about the whole team (`everyone`) is not, and does not
+// light. Under the pointer the hint line says what a press does, and over a
+// message its whole text.
const (
- // The column takes three tenths of the frame left after the task column's
- // edge, between trafficColsMin and trafficColsMax, and never leaves the
- // conversation under trafficBodyFloor. At 110 columns that is 32 of 108.
- trafficColsMin = 28
- trafficColsMax = 44
- trafficBodyFloor = 56
- // trafficGripCols is the edge's width, the task column's own
- // ([railGripCols]).
- trafficGripCols = 2
// trafficHandleCap is the most cells one address takes on a row.
trafficHandleCap = 10
- // trafficKey shows or hides the Traffic, and teamManagerKey goes to the
+ // trafficKey shows or hides the side column, and teamManagerKey goes to the
// team's manager. Both were free in keys.md and in every key map here.
trafficKey = "alt+l"
teamManagerKey = "alt+m"
- // trafficCardShare is how much of the body's height the narrow frame's card
- // takes, in hundredths.
- trafficCardShare = 60
- // trafficWord is the rail's name, in its header, on its edge and on the card.
- trafficWord = "Traffic"
)
-// The rail's three shapes on the last frame.
-const (
- trafficNone = iota
- trafficColumn
- trafficEdge
- trafficCard
-)
-
-// trafficDrawn is where the last frame put the rail, for the pointer. Columns
-// are the frame's; rows count from the body's top.
-type trafficDrawn struct {
- mode int
- x0, x1 int
- y0, y1 int
- // doors is, per body row, the row's doors in the frame's columns
- // (teamthread.go), and hints what the hint line says with the pointer on
- // the row's header words (the hide word's row).
- doors [][]trafficDoor
- hints []string
- // hide is the header's `hide` word, on row hideY, tab its `Tasks 2` word
- // on the same row, and close the card's `Close esc`, on row closeY.
- hide hudSpan
- tab hudSpan
- hideY int
- close hudSpan
- closeY int
-}
-
-// trafficCacheKey is everything the column's rows are drawn from.
-type trafficCacheKey struct {
- team, last, seen string
- rows, width, height int
- hot, hotDoor int
- open, tasks int
- hideHot, tabHot bool
- ascii bool
- minute int64
-}
-
-// trafficCache is the column's rows as last drawn.
-type trafficCache struct {
- key trafficCacheKey
- out []string
- doors [][]trafficDoor
- hints []string
- hide hudSpan
- tab hudSpan
-}
-
-// ── WHERE IT STANDS ─────────────────────────────────────────────────────────
-
-// trafficColsFor is the column the rail takes from room columns, 0 when it
-// cannot stand there.
-func trafficColsFor(room int) int {
- cols := min(max(room*3/10, trafficColsMin), trafficColsMax)
- if room-cols < trafficBodyFloor {
- return 0
- }
- return cols
-}
-
-// trafficOn reports whether the frame has a rail in any shape: the manager in
-// front, on a window that can read the engine's teams. Frame-safe, allocation
-// free: it is asked wherever [app.bodyWidth] is.
-func (a *app) trafficOn() bool {
- if a.teamsOff() {
- return false
- }
- _, ok := a.teamFrontManaged()
- return ok
-}
-
-// WITH THE MANAGER IN FRONT THE RIGHT COLUMN IS THE TRAFFIC (ruled
-// 2026-09-24). The task column is not drawn and not reserved, whatever the
-// saved ctrl+g answer says, so no folded task edge stands beside the traffic
-// and no folded traffic edge beside an empty task column. Only when the manager
-// has live tasks of its own does the header offer them, `Traffic · Tasks 2`,
-// and choosing Tasks lays the task list in the same column
-// ([app.trafficTasksShowing]); the task column's own code draws it, at this
-// column's width ([app.railColumns]).
-
-// trafficTasksWord is the header's word for the manager's own tasks, and
-// trafficTabSep what stands between it and `Traffic`.
-const (
- trafficTasksWord = "Tasks"
- trafficTabSep = " · "
-)
-
-// trafficTasksHead is the column's header while it shows the manager's tasks:
-// `Traffic · Tasks 2` with Tasks the current word, and the key back at the
-// right. The whole line is the way back to the traffic.
-func (a *app) trafficTasksHead(width int) string {
- word := trafficTasksWord + " " + itoa(a.trafficTaskCount())
- head := a.pal.dim(trafficWord) + a.pal.dim(trafficTabSep) + a.pal.bold(a.pal.ink(word))
- used := len(trafficWord) + ansi.StringWidth(trafficTabSep) + ansi.StringWidth(word)
- back := railStowKey + " traffic"
- if gap := width - used - ansi.StringWidth(back); gap >= 2 {
- head += strings.Repeat(" ", gap) + a.pal.dim(back)
- }
- return fit(head, width)
-}
-
-// trafficFits reports whether this frame is wide enough for the column.
-func (a *app) trafficFits() bool {
- width, _ := a.size()
- return trafficColsFor(width) > 0
-}
-
-// trafficTaskCount is how many of the manager's own tasks are live: asking,
-// running, admitted or parked. 0 without the manager in front.
-func (a *app) trafficTaskCount() int {
- if !a.trafficOn() || len(a.taskOrder) == 0 {
- return 0
- }
- members := a.railMembers()
- n := 0
- for g := railAttention; g < railDone; g++ {
- n += len(members[g])
- }
- return n
-}
-
-// trafficTasksShowing reports whether the column shows the manager's tasks
-// instead of the traffic: the person chose Tasks, there are live ones, and the
-// column is up on a frame wide enough for it.
-func (a *app) trafficTasksShowing() bool {
- return a.traffic.tasks && !a.traffic.hidden && a.trafficOn() && a.trafficFits() && a.trafficTaskCount() > 0
-}
-
-// trafficTasksShow swaps the column between the traffic and the manager's
-// tasks, and brings the column back if it was put away. With no live tasks it
-// is the traffic, and there is nothing to swap to.
-func (a *app) trafficTasksShow(on bool) {
- a.traffic.tasks = on && a.trafficTaskCount() > 0
- if a.traffic.hidden && a.trafficFits() {
- a.traffic.hidden = false
- }
- if !a.traffic.tasks {
- a.railHold = false
- }
- a.dropHover()
- a.touch()
-}
-
-// trafficHoldsRail reports whether the Traffic has the right-hand column now.
-func (a *app) trafficHoldsRail() bool {
- return a.trafficOn() && !a.traffic.hidden && a.trafficFits() && !a.trafficTasksShowing()
-}
-
-// trafficWidth is what the rail costs the conversation, in columns: its column
-// where it holds the right, the edge where it is away or cannot stand, and
-// nothing without the manager in front.
-func (a *app) trafficWidth() int {
- if !a.trafficOn() || a.trafficTasksShowing() {
- return 0
- }
- if !a.traffic.hidden {
- width, _ := a.size()
- if cols := trafficColsFor(width); cols > 0 {
- return cols
- }
- }
- return trafficGripCols
-}
-
-// trafficOverShowing reports whether the card is laid over the body.
-func (a *app) trafficOverShowing() bool {
- return a.traffic.over && a.trafficOn() && !a.trafficFits()
-}
-
// ── ONE ROW ─────────────────────────────────────────────────────────────────
// trafficAddr is one address of an entry as the rail spells it: `@handle`, the
@@ -270,6 +60,11 @@ func (a *app) trafficAddr(s string) string {
// (teamthread.go), so one that answers nothing, and a member waking the
// manager, which the manager's own turn already shows, are not drawn.
func trafficShown(e teamstore.Entry) bool {
+ // A RULING THE PERSON MADE is still a ruling every party's log carries,
+ // so it is drawn, as `you ruling → …` (teamthread.go).
+ if teamstore.IsRuling(e) {
+ return true
+ }
if e.Kind == teamstore.KindYou || e.From == teamstore.FromYou {
return false
}
@@ -309,20 +104,6 @@ func (a *app) trafficAge(e teamstore.Entry) string {
return itoa(int(d/86400e9)) + "d"
}
-// trafficUnseen is how many drawn entries of team t arrived after the newest
-// the person had in front of them.
-func (a *app) trafficUnseen(t team) int {
- seen := a.traffic.seen[t.ID]
- rows := a.traffic.rows[t.ID]
- n := 0
- for i := len(rows) - 1; i >= 0 && rows[i].ID > seen; i-- {
- if trafficShown(rows[i]) && !rows[i].Wake() {
- n++
- }
- }
- return n
-}
-
// trafficMarkSeen records that the person has team t's newest entry in front of
// them. It is memory, written by the frame that showed it.
func (a *app) trafficMarkSeen(t team) {
@@ -335,3 +116,17 @@ func (a *app) trafficMarkSeen(t team) {
}
a.traffic.seen[t.ID] = rows[len(rows)-1].ID
}
+
+// trafficRulingText is a ruling's words without the lead the store writes
+// (`ruling on the conflict p…, `), which the row's head already says.
+func trafficRulingText(text string) string {
+ const lead = "ruling on the conflict "
+ if !strings.HasPrefix(text, lead) {
+ return text
+ }
+ rest := text[len(lead):]
+ if at := strings.Index(rest, ", "); at >= 0 && at < 40 {
+ return rest[at+2:]
+ }
+ return rest
+}
diff --git a/internal/tui3/teamraildraw.go b/internal/tui3/teamraildraw.go
deleted file mode 100644
index 791cca150b..0000000000
--- a/internal/tui3/teamraildraw.go
+++ /dev/null
@@ -1,309 +0,0 @@
-package tui3
-
-import (
- "strings"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// ── DRAWING THE RAIL (teamrail.go says what it is) ─────────────────────────
-
-// trafficBody is the rail's rows for team t, height tall and width wide: the
-// header, and under it the threads, the newest at the top, as many as fit. It
-// records each row's doors, and keeps what it drew for the next frame.
-// Frame-safe: the cache only.
-func (a *app) trafficBody(t team, height, width int) ([]string, [][]trafficDoor, []string, hudSpan) {
- rows := a.traffic.rows[t.ID]
- last := ""
- if len(rows) > 0 {
- last = rows[len(rows)-1].ID
- }
- hot, hotDoor := -1, -1
- if a.hot.kind == hoverTraffic {
- hot, hotDoor = a.hot.index, a.hot.entry
- }
- key := trafficCacheKey{
- team: t.ID, last: last, seen: a.traffic.seen[t.ID], rows: len(rows),
- width: width, height: height, hot: hot, hotDoor: hotDoor, open: a.traffic.opened, tasks: a.trafficTaskCount(),
- hideHot: a.hot.kind == hoverTrafficHide, tabHot: a.hot.kind == hoverTrafficTab, ascii: a.pal.ascii, minute: a.now().Unix() / 60,
- }
- if c := &a.traffic.cache; c.out != nil && c.key == key {
- return c.out, c.doors, c.hints, c.hide
- }
- out := make([]string, height)
- doors := make([][]trafficDoor, height)
- hints := make([]string, height)
- var hide, tab hudSpan
- if height <= 0 || width <= 0 {
- return out, doors, hints, hide
- }
- blank := strings.Repeat(" ", width)
- for i := range out {
- out[i] = blank
- }
- // THE HEADER SAYS WHAT THE COLUMN IS AND HOW TO PUT IT AWAY, in the shape
- // the task column's own foot says it (`ctrl+g hide`): the name in ink, the
- // way out dim at the right, and the way out is a word a hand can press.
- hideWord := "hide " + trafficKey
- head := a.pal.bold(a.pal.ink(trafficWord))
- headW := len(trafficWord)
- // AND THE MANAGER'S OWN TASKS ARE THE OTHER WORD, only while it has live
- // ones: `Traffic · Tasks 2`, the word a press or ctrl+g takes to lay them
- // in this column (teamrail.go).
- if key.tasks > 0 {
- word := trafficTasksWord + " " + itoa(key.tasks)
- if headW+ansi.StringWidth(trafficTabSep)+ansi.StringWidth(word)+2+ansi.StringWidth(hideWord) <= width {
- painted := a.pal.dim(word)
- if key.tabHot {
- painted = a.pal.cursor(a.pal.ink(word), 0)
- }
- from := headW + ansi.StringWidth(trafficTabSep)
- head += a.pal.dim(trafficTabSep) + painted
- tab = hudSpan{from: from, to: from + ansi.StringWidth(word)}
- headW = tab.to
- hints[0] = "Show the manager's tasks here" + hintSegment + railStowKey
- }
- }
- if gap := width - headW - ansi.StringWidth(hideWord); gap >= 2 {
- word := a.pal.dim(hideWord)
- if key.hideHot {
- word = a.pal.cursor(a.pal.ink(hideWord), 0)
- }
- head += strings.Repeat(" ", gap) + word
- hide = hudSpan{from: width - ansi.StringWidth(hideWord), to: width}
- if key.hideHot || hints[0] == "" {
- hints[0] = "Hide the traffic" + hintSegment + trafficKey
- }
- }
- out[0] = fit(head, width)
- // THE NEWEST THREAD IS AT THE TOP, straight under the header, and the
- // older ones run down from it; what does not fit falls off the bottom.
- // Nothing is pushed down to leave room above it.
- laid := a.trafficSheetOf(t, height-1, width, 1)
- if len(laid) == 0 {
- if height > 2 {
- quiet := fit(a.pal.dim("nothing yet"), width)
- out[1] = quiet + strings.Repeat(" ", max(width-ansi.StringWidth(quiet), 0))
- }
- }
- for i, r := range laid {
- out[1+i], doors[1+i] = r.text, r.doors
- }
- a.traffic.cache = trafficCache{key: key, out: out, doors: doors, hints: hints, hide: hide, tab: tab}
- return out, doors, hints, hide
-}
-
-// trafficRows is the rail's column beside the body, height rows exactly
-// [app.trafficWidth] wide: the seam and the rows, or the edge. nil when the
-// manager is not in front. It records what it drew for the pointer.
-func (a *app) trafficRows(height int) []string {
- t, _ := a.teamFrontManaged()
- cols := a.trafficWidth()
- if cols == 0 || height <= 0 {
- if !a.trafficOverShowing() {
- a.traffic.drawn = trafficDrawn{}
- }
- return nil
- }
- width, _ := a.size()
- if cols == trafficGripCols {
- out := a.trafficEdgeRows(t, height)
- if !a.trafficOverShowing() {
- a.traffic.drawn = trafficDrawn{mode: trafficEdge, x0: width - cols, x1: width, y0: 0, y1: height}
- }
- return out
- }
- seamW := ansi.StringWidth(railSeam)
- body, doors, hints, hide := a.trafficBody(t, height, cols-seamW)
- a.trafficMarkSeen(t)
- left := width - cols + seamW
- a.traffic.drawn = trafficDrawn{
- mode: trafficColumn, x0: width - cols, x1: width, y0: 0, y1: height,
- doors: trafficDoorsAt(doors, left), hints: hints, hide: hudSpan{from: hide.from + left, to: hide.to + left},
- }
- if tab := a.traffic.cache.tab; tab.pressable() {
- a.traffic.drawn.tab = hudSpan{from: tab.from + left, to: tab.to + left}
- }
- if !hide.pressable() {
- a.traffic.drawn.hide = hudSpan{}
- }
- seam := a.pal.dim(railSeam)
- out := make([]string, len(body))
- for i := range body {
- out[i] = seam + body[i]
- }
- return out
-}
-
-// trafficEdgeRows is the rail put away, or a frame too narrow for it: the word
-// `Traffic` down the edge at its middle, and under it how many entries came in
-// since the person last had them in front of them. Every row answers a press,
-// so the whole edge lights under the pointer.
-func (a *app) trafficEdgeRows(t team, height int) []string {
- blank := strings.Repeat(" ", trafficGripCols)
- out := make([]string, height)
- for i := range out {
- out[i] = blank
- }
- hot := a.hot.kind == hoverTrafficGrip || a.trafficOverShowing()
- paint := func(s string) string {
- if hot {
- return a.pal.cursor(" "+a.pal.ink(s), trafficGripCols)
- }
- return " " + a.pal.ink(s)
- }
- word := []rune(trafficWord)
- if height < len(word)+2 {
- out[height/2] = paint(string(word[0]))
- return out
- }
- top := (height - len(word) - 2) / 2
- for i, r := range word {
- out[top+i] = paint(string(r))
- }
- if n := a.trafficUnseen(t); n > 0 {
- count := itoa(min(n, 99))
- if len(count) == 1 {
- count = " " + count
- }
- out[top+len(word)+1] = a.pal.dim(count)
- }
- if hot {
- for i := range out {
- if out[i] == blank {
- out[i] = a.pal.cursor(blank, trafficGripCols)
- }
- }
- }
- return out
-}
-
-// trafficBeside joins the rail's column onto the task column's rows for the
-// body region, so [app.railJoin] lays both beside the conversation: the task
-// column's row padded to its own width, then the traffic's. With no traffic
-// column it hands the task column back as it was.
-func (a *app) trafficBeside(rail []string, height int) []string {
- traffic := a.trafficRows(height)
- if traffic == nil {
- return rail
- }
- cols := a.railWidth()
- out := make([]string, height)
- for i := range out {
- task := ""
- if i < len(rail) {
- task = rail[i]
- }
- if cols > 0 {
- if w := ansi.StringWidth(task); w < cols {
- task += strings.Repeat(" ", cols-w)
- }
- }
- out[i] = task + traffic[i]
- }
- return out
-}
-
-// trafficOverBody lays the card over the lower part of the body on a narrow
-// frame: a bordered, grounded card [trafficCardShare] of the region tall, set
-// in from the sides, titled `Traffic` with `Close esc` in its foot. The rows
-// above it are the conversation's, untouched, so the manager's last words are
-// still on screen. It hands back the body at the region's height and no slack.
-func (a *app) trafficOverBody(body []row, pad, view int) ([]row, int) {
- t, ok := a.teamFrontManaged()
- width := a.bodyWidth()
- h := max(view*trafficCardShare/100, min(8, view))
- inset := 1
- if width >= 60 {
- inset = 2
- }
- w := width - 2*inset
- if !ok || h < 4 || w < 20 {
- return body, pad
- }
- top := view - h
- pal, ground := a.pal.hopSurfacePalette()
- surface := func(s string, n int) string { return pal.background(s, n, ground) }
- inner := frameInner(w) - 2
- rows, doors := a.trafficCardRows(t, h-2, inner, top+1)
- a.trafficMarkSeen(t)
- painted := make([]string, len(rows))
- for i, r := range rows {
- painted[i] = surface(" "+r+" ", inner+2)
- }
- closeWord := pal.dim("Close") + " " + pal.ink("esc")
- if a.hot.kind == hoverTrafficClose {
- closeWord = pal.cursor(pal.ink("Close esc"), 0)
- }
- card, span := framed{title: pal.ink(trafficWord), keysAside: closeWord, ground: surface}.draw(pal, w, painted)
- out := make([]row, view)
- for i := range out {
- switch {
- case i >= top && i-top < len(card):
- out[i] = row{text: strings.Repeat(" ", inset) + card[i-top], entry: -1}
- case i < len(body):
- out[i] = body[i]
- default:
- out[i] = row{entry: -1}
- }
- }
- d := trafficDrawn{mode: trafficCard, x0: inset, x1: inset + w, y0: top, y1: top + h,
- doors: make([][]trafficDoor, view), hints: make([]string, view), closeY: top + h - 1}
- // The card's rows start a border and a space in from its edge.
- left := inset + (w-inner)/2
- for i := range doors {
- if top+1+i < view {
- d.doors[top+1+i] = trafficDoorsShift(doors[i], left)
- }
- }
- if span.pressable() {
- d.close = hudSpan{from: inset + span.from, to: inset + span.to}
- d.hints[d.closeY] = "Close the traffic" + hintSegment + "esc"
- }
- a.traffic.drawn = d
- return out, 0
-}
-
-// trafficCardRows is the card's rows: the threads, the newest at the top,
-// with no header of its own, because the card's edge is the header. first is
-// the body row the first of them lands on, which is what the pointer holds.
-func (a *app) trafficCardRows(t team, height, width, first int) ([]string, [][]trafficDoor) {
- out := make([]string, height)
- doors := make([][]trafficDoor, height)
- blank := strings.Repeat(" ", width)
- for i := range out {
- out[i] = blank
- }
- laid := a.trafficSheetOf(t, height, width, first)
- if len(laid) == 0 {
- out[0] = fit(a.pal.dim("nothing yet"), width)
- return out, doors
- }
- for i, r := range laid {
- out[i], doors[i] = r.text, r.doors
- }
- return out, doors
-}
-
-// trafficDoorsAt is every row's doors moved from the rail's own columns into
-// the frame's.
-func trafficDoorsAt(rows [][]trafficDoor, left int) [][]trafficDoor {
- out := make([][]trafficDoor, len(rows))
- for i, r := range rows {
- out[i] = trafficDoorsShift(r, left)
- }
- return out
-}
-
-// trafficDoorsShift is one row's doors moved left columns right.
-func trafficDoorsShift(r []trafficDoor, left int) []trafficDoor {
- if len(r) == 0 {
- return nil
- }
- out := make([]trafficDoor, len(r))
- for i, d := range r {
- d.span = hudSpan{from: d.span.from + left, to: d.span.to + left}
- out[i] = d
- }
- return out
-}
diff --git a/internal/tui3/teamrailpointer.go b/internal/tui3/teamrailpointer.go
index 1574d5f48c..3a2160be34 100644
--- a/internal/tui3/teamrailpointer.go
+++ b/internal/tui3/teamrailpointer.go
@@ -4,127 +4,10 @@ import (
tea "charm.land/bubbletea/v2"
)
-// ── THE RAIL UNDER THE HAND AND THE KEYS (teamrail.go says what it is) ─────
+// ── THE TRAFFIC'S KEYS AND DOORS (teamrail.go says what it is) ─────────────
-// trafficEdgeAt reports whether the pointer is on the rail's edge: put away on
-// a wide frame, or the whole of it on a narrow one.
-func (a *app) trafficEdgeAt(x, y int) bool {
- if a.trafficWidth() != trafficGripCols {
- return false
- }
- width, _ := a.size()
- top := a.bodyTop()
- return top >= 0 && y >= top && y < top+a.viewHeight() && x >= width-trafficGripCols && x < width
-}
-
-// trafficRowAt is the rail's body row under the pointer, measured from the
-// body's top, and whether the pointer is over the column or the card at all.
-func (a *app) trafficRowAt(x, y int) (int, bool) {
- d := a.traffic.drawn
- switch d.mode {
- case trafficColumn:
- if !a.trafficHoldsRail() {
- return -1, false
- }
- case trafficCard:
- if !a.trafficOverShowing() {
- return -1, false
- }
- default:
- return -1, false
- }
- top := a.bodyTop()
- rel := y - top
- if top < 0 || x < d.x0 || x >= d.x1 || rel < d.y0 || rel >= d.y1 {
- return -1, false
- }
- return rel, true
-}
-
-// trafficDoorAt is the door under column x on body row rel of the last frame's
-// rail, and its index on the row, -1 for none.
-func (d trafficDrawn) trafficDoorAt(rel, x int) (trafficDoor, int) {
- if rel < 0 || rel >= len(d.doors) {
- return trafficDoor{}, -1
- }
- for i, door := range d.doors[rel] {
- if door.span.holds(x) {
- return door, i
- }
- }
- return trafficDoor{}, -1
-}
-
-// trafficHoverAt is the hover the rail answers with: a handle or a message's
-// words under the pointer. Anywhere else on the rail answers with nothing, so
-// it does not light: it is not a door.
-func (a *app) trafficHoverAt(x, y int) (hoverAt, bool) {
- if rel, ok := a.trafficRowAt(x, y); ok {
- d := a.traffic.drawn
- switch {
- case d.mode == trafficCard && rel == d.closeY && d.close.holds(x):
- return hoverAt{kind: hoverTrafficClose}, true
- case d.mode == trafficColumn && rel == d.hideY && d.hide.holds(x):
- return hoverAt{kind: hoverTrafficHide}, true
- case d.mode == trafficColumn && rel == d.hideY && d.tab.holds(x):
- return hoverAt{kind: hoverTrafficTab}, true
- }
- if _, i := d.trafficDoorAt(rel, x); i >= 0 {
- return hoverAt{kind: hoverTraffic, index: rel, entry: i}, true
- }
- return hoverAt{}, true
- }
- if a.trafficEdgeAt(x, y) {
- return hoverAt{kind: hoverTrafficGrip}, true
- }
- return hoverAt{}, false
-}
-
-// trafficPress answers a press on the rail and reports whether it took it. A
-// handle goes to its member, and puts the card away; a message's words are
-// laid out in full or folded again; `hide` puts the column away; `Close esc`
-// puts the card away; the edge brings the column back, or on a narrow frame
-// lays the card over the body or takes it off. The rail's other cells are
-// furniture and take the press to do nothing.
-func (a *app) trafficPress(x, y int) (tea.Cmd, bool) {
- if rel, ok := a.trafficRowAt(x, y); ok {
- d := a.traffic.drawn
- switch {
- case d.mode == trafficCard && rel == d.closeY && d.close.holds(x):
- a.trafficShow(false)
- return nil, true
- case d.mode == trafficColumn && rel == d.hideY && d.hide.holds(x):
- a.trafficShow(false)
- return nil, true
- case d.mode == trafficColumn && rel == d.hideY && d.tab.holds(x):
- a.trafficTasksShow(true)
- return nil, true
- }
- door, i := d.trafficDoorAt(rel, x)
- switch {
- case i < 0:
- case door.member != "":
- a.traffic.over = false
- return a.trafficJump(door.member, door.land), true
- case door.expand != "":
- a.trafficToggle(door.expand)
- // AND THE MESSAGE'S CARD IN THE MANAGER'S OWN CONVERSATION COMES
- // INTO VIEW, lifted (teamjump.go).
- if door.here != "" {
- return a.trafficJump("", door.here), true
- }
- }
- return nil, true
- }
- if a.trafficEdgeAt(x, y) {
- a.trafficShow(!a.trafficShowing())
- return nil, true
- }
- return nil, false
-}
-
-// trafficToggle lays one message out in full, or folds it again. It moves no
-// focus and changes nothing but what the rail and the thread cards draw.
+// trafficToggle lays one message out in full under a thread card, or folds it
+// again. It moves no focus and changes nothing but what the cards draw.
func (a *app) trafficToggle(key string) {
if a.traffic.open == nil {
a.traffic.open = map[string]bool{}
@@ -138,28 +21,6 @@ func (a *app) trafficToggle(key string) {
a.touch()
}
-// trafficShowing reports whether the Traffic is in front of the person: the
-// column on a wide frame, the card on a narrow one.
-func (a *app) trafficShowing() bool {
- if a.trafficFits() {
- return !a.traffic.hidden
- }
- return a.traffic.over
-}
-
-// trafficShow shows the Traffic or puts it away, in whichever shape this frame
-// has for it. The column's answer is kept for the window; the card is only
-// ever laid over for as long as the person is reading it.
-func (a *app) trafficShow(on bool) {
- if a.trafficFits() {
- a.traffic.hidden = !on
- } else {
- a.traffic.over = on
- }
- a.dropHover()
- a.touch()
-}
-
// trafficGo switches to member key: its tab when the strip has one, and
// otherwise what the team kept of it, which is enough to open it again.
func (a *app) trafficGo(key string) tea.Cmd {
@@ -183,32 +44,21 @@ func (a *app) trafficGo(key string) tea.Cmd {
return nil
}
-// trafficKeyPress takes the rail's keys on the conversation: `esc` while the
-// card is over the body, [trafficKey] to show or hide the Traffic, and
-// [teamManagerKey] to go to the team's manager. Every overlay and page that
-// owns the keyboard is read before it and keeps these keys.
+// trafficKeyPress takes two keys on the conversation: [trafficKey] shows or
+// hides the side column (sidecol.go's [app.sideToggle]), and [teamManagerKey]
+// goes to the team's manager. Every overlay and page that owns the keyboard is
+// read before it and keeps these keys.
func (a *app) trafficKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) {
key := msg.String()
- if key != "esc" && key != trafficKey && key != teamManagerKey {
+ if key != trafficKey && key != teamManagerKey {
return nil, false
}
if a.asking() || a.at(pageSettings) || a.at(pageTasks) || a.at(pageHome) || a.pick.open ||
a.copy.on || a.welcome.open || a.menu.open || a.comp.open || a.effPick.open || a.wall.on {
return nil, false
}
- switch key {
- case "esc":
- if !a.trafficOverShowing() {
- return nil, false
- }
- a.trafficShow(false)
- return nil, true
- case trafficKey:
- if !a.trafficOn() {
- return nil, false
- }
- a.trafficShow(!a.trafficShowing())
- return nil, true
+ if key == trafficKey {
+ return nil, a.sideToggle()
}
t, ok := a.teamOfFront()
if !ok || t.Manager == "" {
@@ -241,39 +91,6 @@ func (a *app) teamOfFront() (team, bool) {
return shown, showing
}
-// trafficHoverWords is what the hint line says with the pointer on the rail,
-// "" anywhere else.
-func (a *app) trafficHoverWords() string {
- d := a.traffic.drawn
- switch a.hot.kind {
- case hoverTraffic:
- if rel := a.hot.index; rel >= 0 && rel < len(d.doors) && a.hot.entry >= 0 && a.hot.entry < len(d.doors[rel]) {
- return d.doors[rel][a.hot.entry].hint
- }
- case hoverTrafficHide:
- return "Hide the traffic" + hintSegment + trafficKey
- case hoverTrafficTab:
- if i := d.hideY; i >= 0 && i < len(d.hints) {
- return d.hints[i]
- }
- case hoverTrafficClose:
- if d.closeY >= 0 && d.closeY < len(d.hints) {
- return d.hints[d.closeY]
- }
- case hoverTrafficGrip:
- words := "Show the team's traffic" + hintSegment + trafficKey
- if a.trafficOverShowing() {
- words = "Close the traffic" + hintSegment + "esc"
- } else if t, ok := a.teamFrontManaged(); ok {
- if n := a.trafficUnseen(t); n > 0 {
- words += hintSegment + itoa(n) + " new"
- }
- }
- return words
- }
- return ""
-}
-
// trafficHint is the composer's placeholder in a managed team: the person's
// words go to the manager when it is in front and to the member when one is,
// and the box says which. "" elsewhere. Frame-safe, allocation free for every
@@ -282,6 +99,10 @@ func (a *app) trafficHint() string {
if a.teamsOff() || !a.wall.loaded || len(a.wall.teams) == 0 {
return ""
}
+ // On the teams page the box says which team as well as who.
+ if words := a.teamsComposerWord(); words != "" {
+ return words
+ }
if _, ok := a.teamFrontManaged(); ok {
return "to " + a.teamManagerMark() + " manager"
}
diff --git a/internal/tui3/teamrenamelabel_test.go b/internal/tui3/teamrenamelabel_test.go
new file mode 100644
index 0000000000..1837ece39b
--- /dev/null
+++ b/internal/tui3/teamrenamelabel_test.go
@@ -0,0 +1,72 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// A RENAME REACHES THE LABELS THAT SAY THE NAME. The side column caches the
+// rows it drew, and the composer's `to ◆ name manager` is read when the box is drawn.
+// Both follow the name the teams hold now, with no read of the disk.
+func TestARenamedTeamUpdatesTheRunLabelAndTheComposer(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ if _, ok := a.teamByID(harbor); !ok {
+ t.Fatal("no harbor")
+ }
+ start := teamstore.Entry{
+ ID: "000000000008", Kind: teamstore.KindStart, From: teamstore.FromManager,
+ To: "api", Team: orbit, Text: "run it",
+ }
+ if a.traffic.rows == nil {
+ a.traffic.rows = map[string][]teamstore.Entry{}
+ }
+ a.traffic.rows[harbor] = []teamstore.Entry{start}
+ // The start is chatter, so it is a line of the General thread, laid open.
+ a.sideToggleThread(sideThreadKey(harbor, sideGeneral))
+ draw := func() string {
+ t, _ := a.teamByID(harbor)
+ s := &sideSheet{a: a, t: t, width: 70}
+ s.threads(sideKindManager, "")
+ var rows []string
+ for _, l := range s.lines {
+ rows = append(rows, l.text)
+ }
+ return ansi.Strip(strings.Join(rows, "\n"))
+ }
+ if got := draw(); !strings.Contains(got, "to run orbit") {
+ t.Fatalf("the start does not name the team it made:\n%s", got)
+ }
+ a.side.traffic = sideTrafficCache{lines: []railLine{{text: "to run orbit"}}}
+ if err := a.teamRename(orbit, "backend"); err != nil {
+ t.Fatal(err)
+ }
+ if a.side.traffic.lines != nil {
+ t.Fatal("the rename left the column's cached rows, which still carry the old name")
+ }
+ got := draw()
+ if strings.Contains(got, "to run orbit") || !strings.Contains(got, "to run backend") {
+ t.Fatalf("after the rename the column still says the old name:\n%s", got)
+ }
+
+ a.tp.sel = harbor
+ a.tp.host = a.frontTabKey()
+ before := a.teamsComposerWord()
+ if !strings.Contains(before, "harbor") {
+ t.Fatalf("the composer does not name the team: %q", before)
+ }
+ if err := a.teamRename(harbor, "dock"); err != nil {
+ t.Fatal(err)
+ }
+ after := a.teamsComposerWord()
+ if strings.Contains(after, "harbor") || !strings.Contains(after, "dock") {
+ t.Fatalf("after the rename the composer says %q", after)
+ }
+ a.input.insert("hi")
+ if pieces := a.seamPieces(120); !strings.Contains(pieces.name, "dock") {
+ t.Fatalf("the seam kept the old team: %q", pieces.name)
+ }
+}
diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go
index 4a5cf85286..14ba7f8f72 100644
--- a/internal/tui3/teams.go
+++ b/internal/tui3/teams.go
@@ -339,6 +339,11 @@ func (a *app) teamEdit(change func(f *teamstore.File) error) error {
}
a.teamRefreshWords(mine.Teams)
a.wall.teams = mine.Teams
+ // THE SIDE COLUMN REMEMBERS THE ROWS IT DREW, keyed by everything but a
+ // team's name (sidetraffic.go's [sideTrafficCacheKey]). A rename would
+ // otherwise leave `to run ` on screen until something else moved
+ // the key. The next frame reads the name from the teams it now holds.
+ a.side.traffic = sideTrafficCache{}
// A window still waiting on its first read over a connection holds only
// this edit; the write brings the whole list back ([app.teamsTake]).
a.wall.loaded = true
@@ -406,6 +411,15 @@ func (a *app) teamHues(skip string) []teamHueSpec {
// offers several and the person may take any. A team remade under a name it
// already has keeps its id, its place in the tree and its colour.
func (a *app) teamMakeHued(name string, tabs []chatTab, hue teamHueSpec) (string, error) {
+ return a.teamMakeIn(name, tabs, hue, "")
+}
+
+// teamMakeIn is [app.teamMakeHued] with the team made inside parent ("" the
+// top level). A new team under a parent with a cap is handed the parent's
+// sub-team share of it as its own cap ([teamstore.File.SubTeamCap]), as the
+// session's own sub-team start does, and a team remade under a name it
+// already has stays where it is.
+func (a *app) teamMakeIn(name string, tabs []chatTab, hue teamHueSpec, parent string) (string, error) {
a.teamsEnsure()
name = strings.TrimSpace(name)
if name == "" {
@@ -420,12 +434,21 @@ func (a *app) teamMakeHued(name string, tabs []chatTab, hue teamHueSpec) (string
// times.
fresh := newTeamID()
id := fresh
+ defaults := a.tp.defaults
err := a.teamEdit(func(f *teamstore.File) error {
at := teamNamed(f.Teams, name)
if at < 0 {
made := t.Clone()
made.ID = fresh
made.SetHue(hue)
+ if parent != "" {
+ if p, ok := f.Team(parent); ok && !p.Closed() {
+ made.Parent = parent
+ if c := f.SubTeamCap(parent, defaults); c > 0 {
+ made.Settings.CapUSDDay = &c
+ }
+ }
+ }
f.Teams = append(f.Teams, made)
id = fresh
return nil
diff --git a/internal/tui3/teams_test.go b/internal/tui3/teams_test.go
index 427c247aad..81e74b7fc2 100644
--- a/internal/tui3/teams_test.go
+++ b/internal/tui3/teams_test.go
@@ -396,10 +396,10 @@ func TestTeamDeleteOrReorderNeverRetargetsAnother(t *testing.T) {
t.Fatalf("the hover followed the place, not the team:\n%s", frame)
}
wallKeyPress(a, "D")
- if _, ok := a.teamByID(second); ok || a.wall.activeID != "" {
- t.Fatalf("D deleted the wrong team: %v active %q", a.teamNames(), a.wall.activeID)
+ if got, ok := a.teamByID(second); !ok || !got.Closed() || a.wall.activeID != "" {
+ t.Fatalf("D closed the wrong team: %v active %q", a.teamNames(), a.wall.activeID)
}
- if _, ok := a.teamByID(third); !ok {
+ if got, ok := a.teamByID(third); !ok || got.Closed() {
t.Fatal("D took a neighbour with it")
}
}
diff --git a/internal/tui3/teamsacts.go b/internal/tui3/teamsacts.go
new file mode 100644
index 0000000000..bd2c176872
--- /dev/null
+++ b/internal/tui3/teamsacts.go
@@ -0,0 +1,398 @@
+package tui3
+
+import (
+ "path/filepath"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── WHAT THE TEAMS PAGE'S TARGETS DO ────────────────────────────────────────
+//
+// One switch for the pointer and the keyboard both ([app.teamsDo]), so a press
+// and an `enter` on the same target cannot come to mean two things. Every act
+// that reaches the store is an edit through [app.teamEdit] (written off the
+// loop) or a seam door asked off the loop; nothing here waits on a disk or a
+// wire.
+
+// teamsDo is one target, pressed.
+func (a *app) teamsDo(t teamsTarget) tea.Cmd {
+ a.tp.msg = ""
+ a.tp.cur = t.ref()
+ switch t.act {
+ case teamsActSelect:
+ return a.teamsSelect(t.id)
+ case teamsActClosedFold:
+ a.tp.closedOpen = !a.tp.closedOpen
+ if !a.tp.closedOpen {
+ if sel, ok := a.teamsSelected(); ok && sel.Closed() {
+ a.tp.sel = ""
+ a.teamsSettle()
+ }
+ }
+ a.touch()
+ return nil
+ case teamsActNewTeam:
+ // On the rail with a team chosen, the new team is made inside it.
+ return a.teamMenuNewTeamIn(t.id)
+ case teamsActOrganize:
+ open := a.openWall()
+ a.wallSetTeam("")
+ return tea.Batch(open, a.wallOrganizeOpen())
+ case teamsActWall:
+ open := a.openWall()
+ a.wallSetTeam(t.id)
+ return open
+ case teamsActManager:
+ return a.teamsManagerStart(t.id)
+ case teamsActRootManager:
+ return a.teamsRootManagerStart()
+ case teamsActSettings:
+ return a.teamSheetOpen(t.id, teamSheetSettings)
+ case teamsActClose:
+ return a.teamsCloseAsk(t.id)
+ case teamsActReopen:
+ return a.teamsReopen(t.id, false)
+ case teamsActReopenParent:
+ return a.teamsReopen(t.id, true)
+ case teamsActDelete:
+ return a.teamSheetOpen(t.id, teamSheetDelete)
+ case teamsActMember:
+ return a.teamsMemberGo(t.id, t.arg)
+ case teamsActOption:
+ if t.opt == "" {
+ a.tp.expand = t.arg
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil
+ }
+ return a.teamsDecide(t.arg, t.opt, "")
+ case teamsActOwnAnswer:
+ a.tp.answering = t.arg
+ a.tp.answer.reset()
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil
+ case teamsActPrompt:
+ return a.teamsPrompt(t.arg, t.opt)
+ case teamsActUndo:
+ return a.teamsUndoAny()
+ case teamsActRetryManager:
+ return a.teamsRetryManager()
+ case teamsActOpenInChats:
+ return a.teamsOpenInChats()
+ case teamsActMoveYes:
+ return a.teamMoveConfirm()
+ case teamsActMoveNo:
+ a.teamMoveCancel()
+ return nil
+ case teamsActManagerGo:
+ return a.teamsManagerGo(t.id)
+ case teamsActCrew:
+ return a.teamCrewOpen(t.id)
+ }
+ return nil
+}
+
+// teamDraggable reports whether a press on target t may become a drag
+// (teamdrag.go): an open team's row on the rail, or a member's chip.
+func (a *app) teamDraggable(t teamsTarget) bool {
+ switch t.act {
+ case teamsActMember:
+ return t.arg != ""
+ case teamsActSelect:
+ u, ok := a.teamByID(t.id)
+ return ok && !t.pane && !u.Root && !u.Closed()
+ }
+ return false
+}
+
+// teamsSelect puts the pane on team id and, when it has a manager, brings that
+// conversation in front, where the pane draws it. The keyboard goes back to the
+// composer: choosing a team is choosing whom to talk to.
+func (a *app) teamsSelect(id string) tea.Cmd {
+ a.tp.sel = id
+ a.tp.expand, a.tp.answering = "", ""
+ a.tp.top = teamsTopCache{}
+ if t, ok := a.teamByID(id); ok && t.Manager != "" && !t.Closed() {
+ a.tp.focus = false
+ }
+ a.touch()
+ return tea.Batch(a.teamsBringManager(), a.teamsRead(false))
+}
+
+// teamsMemberGo is a press on a member: one this window holds is opened, and
+// one it does not is resumed BEHIND, as its own tab, without moving the
+// person's focus (ruling c-b).
+func (a *app) teamsMemberGo(id, key string) tea.Cmd {
+ if a.trafficHeld(key) {
+ if key == a.frontTabKey() {
+ a.leavePlace()
+ return nil
+ }
+ cmd := a.trafficGo(key)
+ a.leavePlace()
+ return cmd
+ }
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil
+ }
+ m, ok := t.Member(key)
+ if !ok || strings.TrimSpace(m.File) == "" {
+ return nil
+ }
+ return a.teamsResumeBehind(m)
+}
+
+// teamsResumeBehind opens one member's conversation behind the one in front,
+// off the loop, and holds it as a tab of its own.
+func (a *app) teamsResumeBehind(m teamMember) tea.Cmd {
+ name := m.Word
+ if m.Handle != "" {
+ name = "@" + m.Handle
+ }
+ switch {
+ case a.shared:
+ a.tp.msg = name + " could not open beside this one: " + oneConversationWord
+ a.touch()
+ return nil
+ case !a.canOpen():
+ a.tp.msg = name + ": " + resumeUnavailableWord
+ a.touch()
+ return nil
+ }
+ open, resume, file, where := a.open, a.resume, m.File, m.Where
+ a.tp.msg = "opening " + name + " behind" + a.linearMark("…", "...")
+ a.touch()
+ return a.besideLine(func() func(bool) tea.Cmd {
+ var conv Conversation
+ var err error
+ if open != nil {
+ conv, err = open(where, file)
+ } else {
+ var agent Agent
+ agent, err = resume(file)
+ conv = Conversation{Agent: agent, Workspace: where, SessionFile: file}
+ }
+ return func(bool) tea.Cmd {
+ if err != nil || conv.Agent == nil {
+ why := "the conversation did not open"
+ if err != nil {
+ why = err.Error()
+ }
+ a.tp.msg = name + ": " + why
+ a.touch()
+ return nil
+ }
+ key := a.convKey(conv.SessionFile)
+ cmd := a.stow(conv, nil)
+ a.trafficBehindTop(key)
+ a.chatTabBar = tabBar{}
+ a.tp.msg = name + " is open behind, in its own tab"
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return cmd
+ }
+ })
+}
+
+// teamsManagerStart is `+ Manager` on team id: a new conversation in the
+// team's folder, made its manager, and in front for the person's first words
+// to it, which is where the pane draws it. A team whose manager's transcript is
+// gone ([app.teamsManagerMissing]) is offered it too, and the new conversation
+// replaces the manager the team named.
+func (a *app) teamsManagerStart(id string) tea.Cmd {
+ t, ok := a.teamByID(id)
+ if !ok || (t.Manager != "" && !a.teamsManagerMissing(t)) || t.Closed() {
+ return nil
+ }
+ if a.teamsOff() {
+ a.tp.msg = teamHostedWord
+ a.touch()
+ return nil
+ }
+ return a.teamsStartManager(a.teamWhere(t), func(tab chatTab) {
+ if err := a.teamMakeManager(id, tab); err != nil {
+ a.note("the manager is set for this window, but " + err.Error())
+ }
+ })
+}
+
+// teamsRootManagerStart is `+ Manager` on the `All teams` row: the optional
+// global manager. It makes the root team (every top-level team moves under it,
+// internal/teams' root.go) and its manager in one edit.
+func (a *app) teamsRootManagerStart() tea.Cmd {
+ if a.teamsOff() {
+ a.tp.msg = teamHostedWord
+ a.touch()
+ return nil
+ }
+ if root, ok := a.teamsRoot(); ok {
+ if root.Manager != "" {
+ return a.teamsSelect(root.ID)
+ }
+ return a.teamsManagerStart(root.ID)
+ }
+ // The id is minted here, once: the edit is made twice (teams.go's
+ // [app.teamEdit]) and must name the same root both times.
+ rootID, now := newTeamID(), a.now()
+ return a.teamsStartManager(a.workspace, func(tab chatTab) {
+ m := teamFromTabs("", []chatTab{tab}, now).Members
+ err := a.teamEdit(func(f *teamstore.File) error {
+ id := rootID
+ if r, ok := f.Root(); ok {
+ id = r.ID
+ } else {
+ f.Teams = append([]teamstore.Team{{ID: rootID, Name: teamstore.RootName, Made: now, Root: true}}, f.Teams...)
+ for i := range f.Teams {
+ if f.Teams[i].ID != rootID && f.Teams[i].Parent == "" {
+ f.Teams[i].Parent = rootID
+ }
+ }
+ }
+ if len(m) > 0 {
+ if err := f.AddMember(id, m[0]); err != nil {
+ return err
+ }
+ }
+ return f.SetManager(id, tab.key)
+ })
+ if err != nil {
+ a.note("the manager is set for this window, but " + err.Error())
+ }
+ a.tp.sel = rootID
+ if r, ok := a.teamsRoot(); ok {
+ a.tp.sel = r.ID
+ }
+ })
+}
+
+// teamsStartManager opens a fresh conversation in where, in front, and hands
+// it to made once it is. The conversation is opened on the door line, off the
+// loop, because opening one is a call to the engine.
+func (a *app) teamsStartManager(where string, made func(chatTab)) tea.Cmd {
+ take := func() {
+ made(chatTab{key: a.convKey(a.file), file: a.file, where: a.workspace})
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+ if a.start == nil || a.shared {
+ cmd, refusal := a.teamsStartIn(where)
+ if refusal != "" {
+ a.tp.msg = refusal
+ a.touch()
+ return nil
+ }
+ take()
+ return cmd
+ }
+ if !a.canStart() {
+ a.tp.msg = newUnavailableWord
+ a.touch()
+ return nil
+ }
+ start := a.start
+ return a.besideLine(func() func(bool) tea.Cmd {
+ conv, err := start(where)
+ return func(bool) tea.Cmd {
+ if err != nil || conv.Agent == nil {
+ why := newUnavailableWord
+ if err != nil {
+ why = err.Error()
+ }
+ a.tp.msg = why
+ a.touch()
+ return nil
+ }
+ cmd := a.takeBeside(conv)
+ take()
+ return cmd
+ }
+ })
+}
+
+// teamsStartIn is [app.teamStartIn] for a folder rather than a team.
+func (a *app) teamsStartIn(where string) (tea.Cmd, string) {
+ if a.start != nil && strings.TrimSpace(where) != "" && where != a.workspace {
+ return a.startBeside(where)
+ }
+ if a.start != nil {
+ return a.startBeside(a.workspace)
+ }
+ cmd, ok := a.renew()
+ if !ok {
+ return nil, newUnavailableWord
+ }
+ return cmd, ""
+}
+
+// ── DECIDING ────────────────────────────────────────────────────────────────
+
+// teamsDecide decides packet id with option opt, or with the person's own
+// words, through the seam, off the loop. A closing report's `Close` (or
+// `Close now`, on an incomplete one) closes the team on its report
+// ([app.teamsAcceptReport]). A cap's `Raise to $10` is only the decision: the
+// session lifts the ceiling for the day from the packet's own figures
+// (DESIGN.md 8.8), and a manager can never decide one.
+func (a *app) teamsDecide(id, opt, words string) tea.Cmd {
+ seam := a.teamsSeam()
+ if !seam.delegation() {
+ a.tp.msg = teamsHostedWord
+ a.touch()
+ return nil
+ }
+ var packet teamstore.Packet
+ found := false
+ for _, p := range a.tp.packets {
+ if p.ID == id {
+ packet, found = p, true
+ }
+ }
+ if !found {
+ return nil
+ }
+ decision := opt
+ if decision == "" {
+ decision = words
+ }
+ label := decision
+ if o, ok := packet.Option(opt); ok {
+ label = o.Label
+ }
+ a.tp.msg = "decided " + a.teamsDot() + " " + label
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ // A CLOSING REPORT'S `Close` closes the team (DESIGN.md 8.8). Every other
+ // decision, a cap's `Raise to $X` included, is the decision and nothing
+ // more: the session reads a raise back from the packet's own figures.
+ if packet.Kind == teamstore.PacketClosing && (opt == teamstore.OptionClose || opt == teamstore.OptionCloseNow) {
+ return a.teamsAcceptReport(packet, decision)
+ }
+ return a.teamsDecideOnly(seam, id, decision)
+}
+
+// teamsPrompt answers a member's permission prompt from the page, through
+// home's own door ([app.sendAnswer]): the answer is left on that
+// conversation's doorstep, or given in this window's own hands when it is the
+// one in front.
+func (a *app) teamsPrompt(file, key string) tea.Cmd {
+ row, ok := a.tp.world[filepath.Clean(file)]
+ if !ok {
+ return nil
+ }
+ question, ok := answerable(row, time.Now())
+ if !ok {
+ return nil
+ }
+ cmd, took := a.sendAnswer(row, question, key)
+ if took {
+ a.tp.msg = answerSentWord + question.Label(key)
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+ return cmd
+}
diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go
index 18111ee409..3d6f51e6fb 100644
--- a/internal/tui3/teamseam.go
+++ b/internal/tui3/teamseam.go
@@ -5,6 +5,8 @@ import (
tea "charm.land/bubbletea/v2"
+ "github.com/Agent-Field/codeaf/internal/config"
+ "github.com/Agent-Field/codeaf/internal/session"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
)
@@ -58,11 +60,82 @@ type TeamsSeam struct {
// ask from the same cursor is answered with nothing from a stat. It may
// block.
Traffic func(team, after string, limit int) ([]teamstore.Entry, error)
+
+ // ── DELEGATION (DESIGN.md section 8) ──
+ //
+ // The doors below are the delegation store of the same machine: its
+ // `teams.` defaults, its decision packets, a team's spend and the delete
+ // that removes a closed team's files. Every one of them may block and is
+ // asked off the loop. Over --host against an engine without
+ // [remote.Welcome.Delegation] they are nil, and [TeamsSeam.delegation]
+ // says so: the window then says the inbox and the spend are not available
+ // over that connection, and never reads this machine's packet files.
+ // Closing and reopening a team are not doors of their own: they are
+ // [teamstore.File.Close] and [teamstore.File.Reopen] made through Update.
+
+ // Defaults is the five `teams.` defaults, for a card's `· from Settings`.
+ Defaults func() (teamstore.Defaults, error)
+ // ApplyDefault writes one of those rows the way the settings tab writes it
+ // locally (config's ApplyTeamDefault) and answers the five as they stand
+ // after. Nil over --host against an engine without [remote.Welcome.TeamSettings]:
+ // the Teams tab stays read-only and says so, and nothing is written here.
+ ApplyDefault func(key, raw string) (teamstore.Defaults, error)
+ // Packets is the packets waiting on scope (a team id, teamstore.Person,
+ // or teamstore.ScopeAll), or same when the packet files are still at
+ // since ("" is never same).
+ Packets func(scope, since string) (packets []teamstore.Packet, stamp string, same bool, err error)
+ // Raise, Decide and Escalate are teamstore's, on that machine.
+ Raise func(p teamstore.Packet) (teamstore.Packet, error)
+ Decide func(id, by, decision, reason string) (teamstore.Packet, error)
+ Escalate func(id, by, to, reason string) (teamstore.Packet, error)
+ // Spend is team's spend on day ("" that machine's today) with its stamp,
+ // or same when neither the teams file nor the ledger moved since since.
+ Spend func(team, day, since string) (spend teamstore.Spend, stamp string, same bool, err error)
+ // Delete forgets a closed team, the teams under it and their files, and
+ // answers the ids forgotten. The window reads the list again after it
+ // (ReadSince), because the file moved.
+ Delete func(team string) ([]string, error)
+
+ // ── THE TEAMS PAGE (place_teams.go) ──
+ //
+ // 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.
+
+ // History is team's packets, decided ones included, oldest first: the
+ // closed view reads its closing report from it (teamstore.Packets).
+ History func(team string) ([]teamstore.Packet, error)
+ // Append writes one entry to team's Traffic (teamstore.AppendTraffic). The
+ // page uses it for the two things the person says to a team outside a
+ // conversation that the session does not write itself: a close and a
+ // reopen.
+ Append func(team string, e teamstore.Entry) error
+
+ // ── THE WRAP-UP (DESIGN.md 8.8) ──
+ //
+ // Two narrow doors, which cross --host when the engine says so
+ // ([remote.Welcome.WrapUp]); an engine without them offers `Close now`
+ // only, and the close card says so.
+
+ // WrapUp asks team's manager to wrap up: it appends exactly
+ // teamstore.WrapUpRequest(text) to the team's Traffic, which the manager's
+ // session reads, and nothing else. text "" is the standard words.
+ WrapUp func(team, text string) error
+ // AcceptClosing closes the team a decided closing packet reports on, with
+ // the packet as its report (teamstore.AcceptClosing), and says whether
+ // this call closed it. The page calls it after the person's Decide.
+ AcceptClosing func(id string) (bool, error)
}
// present reports whether the seam was handed at all.
func (s TeamsSeam) present() bool { return s.Load != nil && s.Update != nil }
+// delegation reports whether the seam carries the delegation doors.
+func (s TeamsSeam) delegation() bool {
+ return s.Defaults != nil && s.Packets != nil && s.Raise != nil && s.Decide != nil &&
+ s.Escalate != nil && s.Spend != nil && s.Delete != nil
+}
+
// localTeams is the seam onto internal/teams in dir, the profile of this
// machine. watch is the window's stat-before-read memory of the logs.
func localTeams(dir string, watch *teamstore.Watch) TeamsSeam {
@@ -103,7 +176,59 @@ func localTeams(dir string, watch *teamstore.Watch) TeamsSeam {
entries, _, err := watch.Traffic(dir, team, after, limit)
return entries, err
},
+ Defaults: func() (teamstore.Defaults, error) { return teamstore.DefaultsAt(dir), nil },
+ ApplyDefault: func(key, raw string) (teamstore.Defaults, error) {
+ if err := config.ApplyTeamDefault(dir, key, raw); err != nil {
+ return teamstore.Defaults{}, err
+ }
+ return teamstore.DefaultsAt(dir), nil
+ },
+ Packets: func(scope, since string) ([]teamstore.Packet, string, bool, error) {
+ stamp := teamstore.PacketsStamp(dir)
+ if since != "" && since == stamp {
+ return nil, stamp, true, nil
+ }
+ packets, _, err := teamstore.OpenPackets(dir, scope)
+ return packets, stamp, false, err
+ },
+ Raise: func(p teamstore.Packet) (teamstore.Packet, error) { return teamstore.Raise(dir, p) },
+ Decide: func(id, by, decision, reason string) (teamstore.Packet, error) {
+ return teamstore.Decide(dir, id, by, decision, reason)
+ },
+ Escalate: func(id, by, to, reason string) (teamstore.Packet, error) {
+ return teamstore.Escalate(dir, id, by, to, reason)
+ },
+ Spend: func(team, day, since string) (teamstore.Spend, string, bool, error) {
+ if day == "" {
+ day = teamstore.Today()
+ }
+ stamp := teamstore.TeamSpendStamp(dir, team, day)
+ if since != "" && since == stamp {
+ return teamstore.Spend{}, stamp, true, nil
+ }
+ spend, err := teamstore.TeamSpend(dir, team, day)
+ return spend, stamp, false, err
+ },
+ Delete: func(team string) ([]string, error) { return teamstore.Delete(dir, team) },
+ History: func(team string) ([]teamstore.Packet, error) {
+ return teamstore.Packets(dir, team)
+ },
+ Append: func(team string, e teamstore.Entry) error { return teamstore.AppendTraffic(dir, team, e) },
+ WrapUp: func(team, text string) error {
+ return teamstore.AppendTraffic(dir, team, teamstore.WrapUpRequest(text))
+ },
+ AcceptClosing: func(id string) (bool, error) { return teamsAcceptClosingLocal(dir, id) },
+ }
+}
+
+// teamsAcceptClosingLocal closes the team decided closing packet id in this
+// profile reports on.
+func teamsAcceptClosingLocal(dir, id string) (bool, error) {
+ p, err := teamstore.PacketByID(dir, id)
+ if err != nil {
+ return false, err
}
+ return teamstore.AcceptClosing(dir, p)
}
// teamsDisk is the window's side of the seam: the door, the queue of edits
@@ -119,6 +244,10 @@ type teamsDisk struct {
// 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
+ // rows reads named conversations' rows off this machine's disk for the
+ // teams page ([app.teamsRead]); nil is [session.ReadRows]. It is a field
+ // so a test can see exactly which conversations a read asked about.
+ rows func(transcripts []string) map[string]session.SessionRow
}
// teamsSeam is the seam this window reads and writes teams through, bound now,
@@ -130,6 +259,27 @@ func (a *app) teamsSeam() TeamsSeam {
return localTeams(a.profileDir, &a.teamsDisk.watch)
}
+// THE FRAME ASKS WHICH DOORS THERE ARE, NEVER FOR THE SEAM. Binding the local
+// seam builds closures that read the disk when called, and the paint walks
+// every closure it builds (framedisk_law_test.go), so a draw that only wants to
+// know whether a door exists asks here: the engine's seam when there is one,
+// and otherwise the local one, which has every door.
+
+// teamsCanDelegate reports whether the seam carries the delegation doors.
+func (a *app) teamsCanDelegate() bool {
+ return !a.teamsDisk.door.present() || a.teamsDisk.door.delegation()
+}
+
+// teamsCanWrapUp reports whether the seam has the wrap-up door.
+func (a *app) teamsCanWrapUp() bool {
+ return !a.teamsDisk.door.present() || a.teamsDisk.door.WrapUp != nil
+}
+
+// teamsCanReadHistory reports whether the seam can read a team's packets.
+func (a *app) teamsCanReadHistory() bool {
+ return !a.teamsDisk.door.present() || a.teamsDisk.door.History != nil
+}
+
// teamsOff reports whether this window has no teams it can keep: over --host,
// facing an engine that does not answer the teams doors. The window's own
// profile is never the answer there, so the writes and the manager say
diff --git a/internal/tui3/teamseam_test.go b/internal/tui3/teamseam_test.go
index a29ba717ab..c1b3f43856 100644
--- a/internal/tui3/teamseam_test.go
+++ b/internal/tui3/teamseam_test.go
@@ -181,8 +181,8 @@ func TestTeamsOverAHostedSeamRefuseNothingAndLeaveTheLaptopFileAlone(t *testing.
if _, err := os.Stat(teamsPath(laptop)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("the laptop's teams file was touched: %v", err)
}
- if !a.trafficWanted() || !a.trafficOn() {
- t.Fatal("the manager in front over --host has no clock or no rail")
+ if !a.trafficWanted() || a.sideKind() != sideKindManager {
+ t.Fatal("the manager in front over --host has no clock or no Traffic")
}
handle := ""
for _, m := range on[0].Members {
@@ -198,7 +198,15 @@ func TestTeamsOverAHostedSeamRefuseNothingAndLeaveTheLaptopFileAlone(t *testing.
trafficReadNow(t, a)
rows := a.traffic.rows[id]
if len(rows) != 1 || rows[0].Text != "take the lexer" {
- t.Fatalf("the rail over --host holds %+v", rows)
+ t.Fatalf("the cache over --host holds %+v", rows)
+ }
+ // AND THE SIDE COLUMN DRAWS IT FROM THAT CACHE: the manager's Traffic in
+ // front, the directive a row of work, and the frames that draw it write
+ // nothing on the laptop.
+ a.width, a.height = 160, 40
+ a.welcome.open = false
+ if col := strings.Join(railLines(t, a), "\n"); !strings.Contains(col, "take the lexer") || a.sideView() != sideTraffic {
+ t.Fatalf("the column over --host does not draw the engine's Traffic:\n%s", col)
}
if entries, _ := os.ReadDir(laptop); len(entries) != 0 {
t.Fatalf("the laptop's profile holds %v", entries)
@@ -261,3 +269,46 @@ func TestAHostedSeamThatHoldsNothingYetIsReadOffTheLoop(t *testing.T) {
t.Fatalf("the read off the loop: loaded %v, %+v, %d reads", a.wall.loaded, a.wall.teams, far.reads)
}
}
+
+// THE LOCAL SEAM CARRIES THE DELEGATION DOORS ONTO THIS PROFILE, and a seam
+// handed without them says so. A packet raised through the local seam is read
+// back through it, a second read at the stamp is same, and the spend of a team
+// nobody has spent in is zero with a stamp that answers same.
+func TestTheLocalTeamsSeamCarriesTheDelegationDoors(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CODEAF_HOME", t.TempDir())
+ var watch teamstore.Watch
+ seam := localTeams(dir, &watch)
+ if !seam.delegation() {
+ t.Fatal("the local seam has no delegation doors")
+ }
+ if (TeamsSeam{Load: seam.Load, Update: seam.Update}).delegation() {
+ t.Fatal("a seam without the doors says it has them")
+ }
+ if err := teamstore.Save(dir, []teamstore.Team{{ID: "0a0a0a0a0a0a", Name: "harbor", Manager: "hm",
+ Members: []teamstore.Member{{Key: "hm", Handle: "boss"}}}}); err != nil {
+ t.Fatal(err)
+ }
+ if d, err := seam.Defaults(); err != nil || d.DepthLimit != 3 {
+ t.Fatalf("defaults %+v %v", d, err)
+ }
+ p, err := seam.Raise(teamstore.Packet{Team: teamstore.Person, Origin: "0a0a0a0a0a0a",
+ Kind: teamstore.PacketQuestion, RaisedBy: "boss", Question: "Friday or Monday?"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ mine, stamp, same, err := seam.Packets(teamstore.Person, "")
+ if err != nil || same || len(mine) != 1 || mine[0].ID != p.ID {
+ t.Fatalf("the person's packets %+v %v %v", mine, same, err)
+ }
+ if _, _, same, _ := seam.Packets(teamstore.Person, stamp); !same {
+ t.Fatal("a quiet read was not same")
+ }
+ spend, at, same, err := seam.Spend("0a0a0a0a0a0a", "", "")
+ if err != nil || same || spend.USD != 0 {
+ t.Fatalf("spend %+v %v %v", spend, same, err)
+ }
+ if _, _, same, _ := seam.Spend("0a0a0a0a0a0a", "", at); !same {
+ t.Fatal("a quiet spend was not same")
+ }
+}
diff --git a/internal/tui3/teamsheet.go b/internal/tui3/teamsheet.go
new file mode 100644
index 0000000000..ba49266473
--- /dev/null
+++ b/internal/tui3/teamsheet.go
@@ -0,0 +1,867 @@
+package tui3
+
+import (
+ "strconv"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE TEAM SETTINGS CARD, AND THE CLOSE AND DELETE CARDS ─────────────────
+//
+// One card over whatever the frame is showing, opened from three doors that
+// mean the same thing: `Team settings…` in the strip's switcher, `e` on the
+// wall, and `Settings` in the teams page's header. It was a popover on the wall
+// alone; it is a card of its own now because a team's settings are a fact
+// about the team and not about the wall.
+//
+// ╭─ Team settings ─────────────────────────────────────────╮
+// │ │
+// │ Name harbor▏ │
+// │ Colour ◉ ● ● ● ● │
+// │ ────────────────────────────────────────────────────── │
+// │ questions go to the manager on · from Settings │
+// │ daily cap $5 a day reset │
+// │ team depth 3 levels · from harbor │
+// │ sub-team share 50% · from Settings │
+// │ ────────────────────────────────────────────────────── │
+// │ Close team… Done ⏎ │
+// │ │
+// ╰──────────────────────────────────────────────────────────╯
+//
+// OVERRIDES ONLY. A value the team inherits is drawn dim with where it comes
+// from ([teamstore.Origin.Words]: `from Settings`, `from harbor`); a value the
+// team sets is drawn in ink with `reset` beside it. Changing a dim value makes
+// it an override; `reset` makes it inherit again. The writes are
+// [teamstore.File.SetSettings] through [app.teamEdit], so they reach the
+// session's own file, over --host included.
+//
+// THERE IS NO `wake` ROW. The ruling names one, and the store on this build has
+// no such field (internal/teams' teamsettings.go keeps four); a row that wrote
+// nothing would be a control that lies, so it waits for the store.
+//
+// THE SAME CARD CLOSES AND DELETES (teamclose.go says what each does), in two
+// more modes, because each is a question about one team asked where the team
+// is: the close card from `Close…`, the delete card only on a closed team.
+
+// The card's modes.
+type teamSheetMode int
+
+const (
+ teamSheetSettings teamSheetMode = iota + 1
+ teamSheetClose
+ teamSheetDelete
+)
+
+// The card's rows and buttons, as the keyboard and the pointer name them.
+const (
+ tsName = iota
+ tsColour
+ tsQuestions
+ tsWake
+ tsCap
+ tsDepth
+ tsShare
+ tsCloseTeam
+ tsDone
+ tsWrapUp
+ tsCloseNow
+ tsCancel
+ tsDelete
+ tsKeep
+ // `Inside: harbor ▾` and the move it asks for (teammove.go): its
+ // consequence line's `Move` and `Cancel`, and the Undo after it.
+ tsInside
+ tsMoveYes
+ tsMoveNo
+ tsMoveUndo
+ // The reset words sit on their rows; a reset is its row's code plus this.
+ tsReset = 100
+ // A colour swatch is its choice plus this.
+ tsSwatch = 200
+)
+
+// teamSheet is the card's whole state.
+type teamSheet struct {
+ on bool
+ mode teamSheetMode
+ team string
+ // cursor is the row or button the keyboard is on, hot the one under the
+ // pointer (-1 none).
+ cursor, hot int
+ // name is the name being edited, choices and choice the colours offered.
+ name editor
+ choices []teamHueSpec
+ choice int
+ // editing is the value row whose box is open, and box the box.
+ editing int
+ box editor
+ err string
+ // rect and hits are where the last frame drew it, in frame cells.
+ rect wallRect
+ hits []wallHit
+}
+
+// teamSheetOpen puts the card up on team id in mode.
+func (a *app) teamSheetOpen(id string, mode teamSheetMode) tea.Cmd {
+ a.teamsEnsure()
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil
+ }
+ s := teamSheet{on: true, mode: mode, team: id, hot: -1, editing: -1}
+ switch mode {
+ case teamSheetSettings:
+ s.cursor = tsName
+ s.name.setText(t.Name)
+ s.choices = append([]teamHueSpec{t.HueSpec()}, teamHueChoices(a.teamHues(id), teamReservedHues(a.pal), wallSwatchCount-1)...)
+ case teamSheetClose:
+ s.cursor = tsCloseNow
+ if _, managed := a.teamsRunning(id); managed && a.teamsCanWrapUp() {
+ s.cursor = tsWrapUp
+ }
+ case teamSheetDelete:
+ s.cursor = tsKeep
+ }
+ a.tsheet = s
+ a.touch()
+ if !a.tp.defaultsOK {
+ return a.teamsRead(false)
+ }
+ return nil
+}
+
+// teamSheetShut puts the card away, keeping a name typed into it.
+func (a *app) teamSheetShut() {
+ a.teamSheetRename()
+ a.tsheet = teamSheet{}
+ a.touch()
+}
+
+// teamSheetRename keeps the name typed into the card, when it changed.
+func (a *app) teamSheetRename() {
+ s := &a.tsheet
+ if s.mode != teamSheetSettings {
+ return
+ }
+ if t, ok := a.teamByID(s.team); ok {
+ if name := strings.TrimSpace(s.name.String()); name != "" && name != t.Name {
+ if err := a.teamRename(s.team, name); err != nil {
+ a.note(err.Error())
+ }
+ }
+ }
+}
+
+// ── WHAT EACH VALUE ROW SAYS ────────────────────────────────────────────────
+
+// teamSheetRow is one value row as drawn: its label, the value, where it came
+// from, and whether the team sets it.
+type teamSheetRow struct {
+ code int
+ label, value string
+ from string
+ own bool
+}
+
+// teamSheetRows is the four overrides of team t, in the ruling's order.
+func (a *app) teamSheetRows(t team) []teamSheetRow {
+ e := a.teamTree().Effective(t.ID, a.tp.defaults)
+ known := a.tp.defaultsOK
+ row := func(code int, label, value string, o teamstore.Origin) teamSheetRow {
+ r := teamSheetRow{code: code, label: label, value: value, own: !o.Inherited(), from: o.Words()}
+ if !known && o.Kind == teamstore.OriginSettings {
+ r.value = ""
+ r.from = "from Settings"
+ }
+ return r
+ }
+ onOff := func(on bool) string {
+ if on {
+ return "on"
+ }
+ return "off"
+ }
+ cap := "no cap"
+ if e.CapUSDDay > 0 {
+ cap = dollars(e.CapUSDDay) + " a day"
+ }
+ depth := itoa(e.DepthLimit) + " levels"
+ if e.DepthLimit == 1 {
+ depth = "1 level"
+ }
+ share := strconv.Itoa(int(e.SubShare*100+0.5)) + "%"
+ return []teamSheetRow{
+ row(tsQuestions, "questions go to the manager", onOff(e.QuestionsUp), e.QuestionsUpFrom),
+ row(tsWake, "team messages wake", onOff(e.Wake), e.WakeFrom),
+ row(tsCap, "daily cap", cap, e.CapFrom),
+ row(tsDepth, "team depth", depth, e.DepthFrom),
+ row(tsShare, "sub-team share", share, e.SubShareFrom),
+ }
+}
+
+// ── DRAWING IT ──────────────────────────────────────────────────────────────
+
+// teamSheetOver lays the card over a finished frame and writes down where its
+// targets landed. With the card down the frame comes back as it was given.
+func (a *app) teamSheetOver(frame string) string {
+ if !a.tsheet.on {
+ return frame
+ }
+ width, height := a.width, a.height
+ card := a.teamSheetCard(width, height)
+ a.tsheet.hits = card.hits
+ if len(card.rows) == 0 {
+ a.tsheet.rect = wallRect{}
+ return frame
+ }
+ a.tsheet.rect = wallRect{card.x, card.y, card.x + card.w, card.y + len(card.rows)}
+ rows := strings.Split(frame, "\n")
+ for len(rows) < height {
+ rows = append(rows, "")
+ }
+ for dy, cr := range card.rows {
+ if y := card.y + dy; y >= 0 && y < len(rows) {
+ rows[y] = wallSplice(rows[y], cr, card.x, width)
+ }
+ }
+ return strings.Join(rows[:height], "\n")
+}
+
+// teamSheetLit reports whether code wears a ground: the keyboard's or the
+// pointer's.
+func (a *app) teamSheetLit(code int) bool {
+ return a.tsheet.cursor == code || a.tsheet.hot == code
+}
+
+// teamSheetButton paints one button of a card line at x: its word with a cell
+// either side, its key dim after it, on the cursor's ground when lit. The
+// default button takes the accent, the one on the card.
+func (a *app) teamSheetButton(label, key string, code, x int, accent bool) (string, wallHit, int) {
+ pal := a.pal
+ ink := pal.ink
+ if accent {
+ ink = pal.accent
+ }
+ s := " " + ink(label)
+ w := 2 + ansi.StringWidth(label)
+ if key != "" {
+ s += " " + pal.dim(key)
+ w += 1 + ansi.StringWidth(key)
+ }
+ s += " "
+ if a.teamSheetLit(code) {
+ s = pal.cursor(s, 0)
+ }
+ return s, wallHit{x0: x, x1: x + w, y1: 1, kind: wallHitPopRow, arg: code}, w
+}
+
+// teamSheetCard is the card in its mode, centred, a third of the way down.
+func (a *app) teamSheetCard(width, height int) wallCard {
+ s := &a.tsheet
+ t, ok := a.teamByID(s.team)
+ if !ok {
+ return wallCard{}
+ }
+ var title string
+ var lines []wallCardLine
+ inner := min(max(width-12, 40), 64)
+ switch s.mode {
+ case teamSheetSettings:
+ title, lines = "Team settings", a.teamSheetSettingsLines(t, inner)
+ case teamSheetClose:
+ title, lines = "Close "+t.Name, a.teamSheetCloseLines(t, inner)
+ case teamSheetDelete:
+ title, lines = "Delete "+t.Name, a.teamSheetDeleteLines(t, inner)
+ }
+ w := inner + 2 + 2*wallCardPadX
+ h := len(lines) + 2 + 2*wallCardPadY
+ if w > width-2 || h > height-1 {
+ return wallCard{}
+ }
+ x := a.teamsCardX(width, w)
+ y := max((height-h)/3, 1)
+ return wallCardBuild(a.pal, title, lines, x, y, w, wallCardPadX, wallCardPadY)
+}
+
+// teamsCardX is where a card w wide stands on a frame width wide: centred over
+// the teams page's pane while that page stands and the pane can hold it, so
+// the rail beside it stays readable (the card is about the team the rail has
+// selected, and covering the rail hid which one); centred on the frame
+// everywhere else.
+func (a *app) teamsCardX(width, w int) int {
+ if a.at(pageTeams) {
+ rail := teamsRailCols(width)
+ if pane := width - rail; rail > 0 && w <= pane-2 {
+ return rail + (pane-w)/2
+ }
+ }
+ return (width - w) / 2
+}
+
+// teamSheetSettingsLines is the settings card's lines.
+func (a *app) teamSheetSettingsLines(t team, inner int) []wallCardLine {
+ pal := a.pal
+ s := &a.tsheet
+ var lines []wallCardLine
+ const labelW = 10
+ name := s.name.String()
+ field := pal.ink(name)
+ if s.cursor == tsName {
+ field += pal.ink(a.linearMark("▏", "|"))
+ }
+ nameLine := pal.dim(teamsPad("Name", labelW)) + field
+ if s.cursor == tsName || s.hot == tsName {
+ nameLine = pal.cursor(teamsPad(nameLine, inner), inner)
+ }
+ lines = append(lines, wallCardLine{s: nameLine, hits: []wallHit{{x0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: tsName}}})
+ sws, _, swh := wallSwatches(pal, wallView{}, s.choices, s.choice, labelW)
+ for i := range swh {
+ swh[i].kind, swh[i].arg = wallHitPopRow, tsSwatch+swh[i].arg
+ }
+ colour := pal.dim(teamsPad("Colour", labelW)) + sws
+ if s.cursor == tsColour {
+ colour += " " + pal.dim("←→")
+ }
+ lines = append(lines, wallCardLine{s: colour, hits: swh})
+ lines = append(lines, a.teamSheetInsideLines(t, inner, labelW)...)
+ lines = append(lines, wallCardLine{rule: true})
+ const valueX = 31
+ for _, r := range a.teamSheetRows(t) {
+ value := r.value
+ var text string
+ switch {
+ case s.editing == r.code:
+ box, _, _ := draftBlock(&s.box, pal, inner-valueX-2, 1, teamSheetBoxHint(r.code), "")
+ text = pal.ink(teamsPad(r.label, valueX)) + strings.Join(box, "")
+ case r.own:
+ text = pal.ink(teamsPad(r.label, valueX)) + pal.ink(value)
+ default:
+ words := value
+ if r.from != "" {
+ if words != "" {
+ words += " " + a.teamsDot() + " "
+ }
+ words += r.from
+ }
+ text = pal.dim(teamsPad(r.label, valueX)) + pal.dim(words)
+ }
+ ln := wallCardLine{hits: []wallHit{{x0: 0, x1: inner - 8, y1: 1, kind: wallHitPopRow, arg: r.code}}}
+ if r.own && s.editing != r.code {
+ reset := "reset"
+ rs := pal.muted(reset)
+ if s.hot == r.code+tsReset {
+ rs = pal.cursor(pal.ink(reset), 0)
+ }
+ text = teamsPad(text, inner-ansi.StringWidth(reset)) + rs
+ ln.hits = append(ln.hits, wallHit{x0: inner - ansi.StringWidth(reset), x1: inner, y1: 1, kind: wallHitPopRow, arg: r.code + tsReset})
+ }
+ if s.cursor == r.code && s.editing != r.code {
+ text = pal.cursor(teamsPad(text, inner), inner)
+ }
+ ln.s = text
+ lines = append(lines, ln)
+ }
+ if s.err != "" {
+ lines = append(lines, wallCardLine{s: pal.bad(fit(s.err, inner))})
+ }
+ lines = append(lines, wallCardLine{rule: true})
+ closeWord := "Close team" + a.linearMark("…", "...")
+ var hits []wallHit
+ row := ""
+ if !t.Root {
+ b, hit, _ := a.teamSheetButton(closeWord, "", tsCloseTeam, 0, false)
+ row, hits = b, append(hits, hit)
+ }
+ enter := a.linearMark("⏎", "enter")
+ doneX := inner + 2 - a.teamSheetButtonW("Done", enter)
+ done, doneHit, _ := a.teamSheetButton("Done", enter, tsDone, doneX, false)
+ row = teamsPad(row, doneX) + done
+ lines = append(lines, wallCardLine{s: row, hits: append(hits, doneHit), bleed: true})
+ return lines
+}
+
+// teamSheetInsideLines is `Inside: harbor ▾`, the team's place in the tree,
+// which opens the move picker; under it, while one is asked from here, the
+// move's consequence line with `Move` and `Cancel`, or the move just made with
+// `Undo`. The root has no place to move to and draws none.
+func (a *app) teamSheetInsideLines(t team, inner, labelW int) []wallCardLine {
+ if t.Root {
+ return nil
+ }
+ pal := a.pal
+ s := &a.tsheet
+ where := "Top level"
+ if p, ok := a.teamByID(t.Parent); ok && !p.Root {
+ where = p.Name
+ }
+ value := pal.ink(where + " " + a.linearMark("▾", "v"))
+ line := pal.dim(teamsPad("Inside", labelW)) + value
+ if s.cursor == tsInside || s.hot == tsInside {
+ line = pal.cursor(teamsPad(line, inner), inner)
+ }
+ out := []wallCardLine{{s: line, hits: []wallHit{{x0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: tsInside}}}}
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromCard {
+ for _, l := range wrap(p.words, max(inner-labelW, 12)) {
+ out = append(out, wallCardLine{s: strings.Repeat(" ", labelW) + pal.ink(l)})
+ }
+ // The line bleeds a cell to the left (wallCardLine), so a button's cell
+ // of air stands where the value's first letter's left neighbour is, and
+ // its word lines up with the value above it.
+ yes, hy, wy := a.teamSheetButton("Move", "", tsMoveYes, labelW, true)
+ 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) + " "
+ 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})
+ }
+ return out
+}
+
+// teamSheetButtonW is a button's width as [app.teamSheetButton] draws it.
+func (a *app) teamSheetButtonW(label, key string) int {
+ w := 2 + ansi.StringWidth(label)
+ if key != "" {
+ w += 1 + ansi.StringWidth(key)
+ }
+ return w
+}
+
+// teamSheetBoxHint is the dim sentence in a value row's box.
+func teamSheetBoxHint(code int) string {
+ switch code {
+ case tsCap:
+ return "dollars a day, 0 no cap"
+ case tsDepth:
+ return "levels, 1 to 10"
+ case tsShare:
+ return "percent, 1 to 100"
+ }
+ return ""
+}
+
+// teamSheetCloseLines is the close card: what is running, and the three ways
+// on, the default in the accent.
+func (a *app) teamSheetCloseLines(t team, inner int) []wallCardLine {
+ pal := a.pal
+ names, managed := a.teamsRunning(t.ID)
+ var lines []wallCardLine
+ for _, l := range wrap(teamsCloseWords(names)+".", inner) {
+ lines = append(lines, wallCardLine{s: pal.ink(l)})
+ }
+ lines = append(lines, wallCardLine{})
+ type choice struct {
+ code int
+ label, key string
+ consequence string
+ }
+ var cs []choice
+ switch {
+ case t.Manager != "" && a.teamsCanWrapUp():
+ cs = append(cs, choice{tsWrapUp, "Wrap up first", "", "the manager asks everyone to finish and commit, then brings you a closing report"})
+ case t.Manager != "":
+ // The engine behind this window has no wrap-up door: said, not hidden.
+ for _, l := range wrap(teamsNoWrapUpWord, inner) {
+ lines = append(lines, wallCardLine{s: pal.dim(l)})
+ }
+ lines = append(lines, wallCardLine{})
+ }
+ cs = append(cs,
+ choice{tsCloseNow, "Close now", "", "stops every turn and closes the tabs; Undo for a few seconds"},
+ choice{tsCancel, "Cancel", "esc", ""})
+ labelW := 0
+ for _, c := range cs {
+ labelW = max(labelW, a.teamSheetButtonW(c.label, c.key))
+ }
+ for _, c := range cs {
+ accent := c.code == tsWrapUp && managed
+ b, hit, w := a.teamSheetButton(c.label, c.key, c.code, 0, accent)
+ text := b + strings.Repeat(" ", max(labelW-w, 0))
+ // The consequence wraps under itself rather than being cut: it is the
+ // one thing that says what the button does.
+ said := wrap(c.consequence, max(inner-labelW-2, 8))
+ if len(said) > 0 {
+ text += " " + pal.dim(said[0])
+ }
+ lines = append(lines, wallCardLine{s: text, hits: []wallHit{hit}, bleed: true})
+ for _, l := range said[min(1, len(said)):] {
+ lines = append(lines, wallCardLine{s: strings.Repeat(" ", labelW+2) + pal.dim(l)})
+ }
+ }
+ return lines
+}
+
+// teamSheetDeleteLines is the delete card: what goes, what stays, and the two
+// answers, the one that cannot be undone in the failure red.
+func (a *app) teamSheetDeleteLines(t team, inner int) []wallCardLine {
+ pal := a.pal
+ var lines []wallCardLine
+ say := "Forget " + t.Name + "? Its grouping, its Traffic and its packets go. Its conversations stay in your history."
+ for _, l := range wrap(say, inner) {
+ lines = append(lines, wallCardLine{s: pal.ink(l)})
+ }
+ lines = append(lines, wallCardLine{})
+ keepW := a.teamSheetButtonW("Keep", "esc")
+ delW := a.teamSheetButtonW("Delete", "")
+ x := inner + 2 - keepW - 1 - delW
+ keep, kh, _ := a.teamSheetButton("Keep", "esc", tsKeep, x, false)
+ del := " " + pal.bad("Delete") + " "
+ if a.teamSheetLit(tsDelete) {
+ del = pal.cursor(del, 0)
+ }
+ dh := wallHit{x0: x + keepW + 1, x1: x + keepW + 1 + delW, y1: 1, kind: wallHitPopRow, arg: tsDelete}
+ lines = append(lines, wallCardLine{s: strings.Repeat(" ", max(x, 0)) + keep + " " + del, hits: []wallHit{kh, dh}, bleed: true})
+ return lines
+}
+
+// ── THE KEYS AND THE POINTER ────────────────────────────────────────────────
+
+// teamSheetStops is the rows and buttons the keyboard walks, in order.
+func (a *app) teamSheetStops() []int {
+ s := &a.tsheet
+ switch s.mode {
+ case teamSheetSettings:
+ stops := []int{tsName, tsColour}
+ t, ok := a.teamByID(s.team)
+ if ok && !t.Root {
+ stops = append(stops, tsInside)
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromCard {
+ stops = append(stops, tsMoveYes, tsMoveNo)
+ } else if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromCard {
+ stops = append(stops, tsMoveUndo)
+ }
+ }
+ stops = append(stops, tsQuestions, tsWake, tsCap, tsDepth, tsShare)
+ if ok && !t.Root {
+ stops = append(stops, tsCloseTeam)
+ }
+ return append(stops, tsDone)
+ case teamSheetClose:
+ var stops []int
+ if t, ok := a.teamByID(s.team); ok && t.Manager != "" && a.teamsCanWrapUp() {
+ stops = append(stops, tsWrapUp)
+ }
+ return append(stops, tsCloseNow, tsCancel)
+ case teamSheetDelete:
+ return []int{tsKeep, tsDelete}
+ }
+ return nil
+}
+
+// teamSheetKey is a key while the card is up: it has the keyboard, as every
+// card does.
+func (a *app) teamSheetKey(msg tea.KeyPressMsg) tea.Cmd {
+ s := &a.tsheet
+ key := msg.String()
+ a.touch()
+ if s.editing >= 0 {
+ switch key {
+ case "esc":
+ s.editing, s.err = -1, ""
+ case "enter":
+ a.teamSheetSave(s.editing, s.box.String())
+ case "backspace":
+ s.box.deleteBackward()
+ default:
+ if text := msg.Key().Text; text != "" {
+ s.box.insert(text)
+ }
+ }
+ return nil
+ }
+ stops := a.teamSheetStops()
+ at := 0
+ for i, c := range stops {
+ if c == s.cursor {
+ at = i
+ }
+ }
+ switch key {
+ case "esc":
+ // A move waiting on its line is answered first.
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromCard {
+ a.teamMoveCancel()
+ return nil
+ }
+ if s.mode == teamSheetSettings {
+ a.teamSheetShut()
+ } else {
+ a.tsheet = teamSheet{}
+ }
+ return nil
+ case "up", "shift+tab":
+ s.cursor = stops[max(at-1, 0)]
+ return nil
+ case "down", "tab":
+ s.cursor = stops[min(at+1, len(stops)-1)]
+ return nil
+ case "left", "right":
+ if s.cursor == tsColour && len(s.choices) > 0 {
+ step := 1
+ if key == "left" {
+ step = len(s.choices) - 1
+ }
+ return a.teamSheetRecolor((s.choice + step) % len(s.choices))
+ }
+ if s.cursor == tsName {
+ if key == "left" {
+ s.name.left()
+ } else {
+ s.name.right()
+ }
+ }
+ return nil
+ case "enter":
+ return a.teamSheetDo(s.cursor)
+ }
+ if s.mode == teamSheetSettings && s.cursor == tsName {
+ switch key {
+ case "backspace":
+ s.name.deleteBackward()
+ default:
+ if text := msg.Key().Text; text != "" {
+ s.name.insert(text)
+ }
+ }
+ return nil
+ }
+ if s.mode == teamSheetSettings && key == "u" && a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromCard {
+ return a.teamSheetDo(tsMoveUndo)
+ }
+ if s.mode == teamSheetSettings && (key == "r" || key == "delete") && s.cursor >= tsQuestions && s.cursor <= tsShare {
+ return a.teamSheetDo(s.cursor + tsReset)
+ }
+ if key == "space" {
+ return a.teamSheetDo(s.cursor)
+ }
+ return nil
+}
+
+// teamSheetDo is one row or button of the card, pressed.
+func (a *app) teamSheetDo(code int) tea.Cmd {
+ s := &a.tsheet
+ id := s.team
+ s.err = ""
+ switch {
+ case code >= tsSwatch:
+ return a.teamSheetRecolor(code - tsSwatch)
+ case code >= tsReset:
+ a.teamSheetReset(code - tsReset)
+ return nil
+ }
+ switch code {
+ case tsName:
+ // enter on the name keeps it and moves on, as a form's field does.
+ a.teamSheetRename()
+ s.cursor = tsColour
+ case tsColour:
+ s.cursor = code
+ case tsQuestions:
+ s.cursor = code
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil
+ }
+ next := !a.teamTree().Effective(t.ID, a.tp.defaults).QuestionsUp
+ a.teamSheetWrite(func(set *teamstore.Settings) { set.QuestionsUp = &next })
+ case tsWake:
+ s.cursor = code
+ t, ok := a.teamByID(id)
+ if !ok {
+ return nil
+ }
+ next := !a.teamTree().Effective(t.ID, a.tp.defaults).Wake
+ a.teamSheetWrite(func(set *teamstore.Settings) { set.Wake = &next })
+ case tsCap, tsDepth, tsShare:
+ s.cursor, s.editing = code, code
+ s.box.reset()
+ case tsInside:
+ s.cursor = code
+ return a.teamMoveOpen([]string{id}, teamMoveFromCard)
+ case tsMoveYes:
+ s.cursor = tsInside
+ return a.teamMoveConfirm()
+ case tsMoveNo:
+ a.teamMoveCancel()
+ case tsMoveUndo:
+ s.cursor = tsInside
+ return a.teamMoveUndo()
+ case tsCloseTeam:
+ a.teamSheetShut()
+ return a.teamsCloseAsk(id)
+ case tsDone:
+ a.teamSheetShut()
+ case tsWrapUp:
+ a.tsheet = teamSheet{}
+ return a.teamsWrapUp(id)
+ case tsCloseNow:
+ a.tsheet = teamSheet{}
+ return a.teamsCloseNow(id, "")
+ case tsCancel, tsKeep:
+ a.tsheet = teamSheet{}
+ a.touch()
+ case tsDelete:
+ a.tsheet = teamSheet{}
+ return a.teamsDelete(id)
+ }
+ return nil
+}
+
+// teamSheetRecolor takes colour j at once.
+func (a *app) teamSheetRecolor(j int) tea.Cmd {
+ s := &a.tsheet
+ if j < 0 || j >= len(s.choices) {
+ return nil
+ }
+ s.choice, s.cursor = j, tsColour
+ if err := a.teamRecolor(s.team, s.choices[j]); err != nil {
+ a.note("the colour is kept for this window, but " + err.Error())
+ }
+ return nil
+}
+
+// teamSheetSave reads one value row's box and writes it as the team's own
+// override. A value outside its band is said on the card and nothing changes.
+func (a *app) teamSheetSave(code int, raw string) {
+ s := &a.tsheet
+ raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(raw), "$"), "%"))
+ if raw == "" {
+ s.editing = -1
+ return
+ }
+ switch code {
+ case tsCap:
+ if strings.EqualFold(raw, "no cap") {
+ raw = "0"
+ }
+ v, err := strconv.ParseFloat(raw, 64)
+ if err != nil || v < 0 {
+ s.err = "a cap is dollars a day, 0 for no cap"
+ return
+ }
+ a.teamSheetWrite(func(set *teamstore.Settings) { set.CapUSDDay = &v })
+ case tsDepth:
+ v, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSuffix(raw, " levels"), " level"))
+ if err != nil || v < 1 || v > 10 {
+ s.err = "a depth is 1 to 10 levels"
+ return
+ }
+ a.teamSheetWrite(func(set *teamstore.Settings) { set.DepthLimit = &v })
+ case tsShare:
+ v, err := strconv.Atoi(raw)
+ if err != nil || v < 1 || v > 100 {
+ s.err = "a share is 1 to 100 percent"
+ return
+ }
+ f := float64(v) / 100
+ a.teamSheetWrite(func(set *teamstore.Settings) { set.SubShare = &f })
+ }
+ if s.err == "" {
+ s.editing = -1
+ }
+}
+
+// teamSheetReset makes one row inherit again.
+func (a *app) teamSheetReset(code int) {
+ a.teamSheetWrite(func(set *teamstore.Settings) {
+ switch code {
+ case tsQuestions:
+ set.QuestionsUp = nil
+ case tsWake:
+ set.Wake = nil
+ case tsCap:
+ set.CapUSDDay = nil
+ case tsDepth:
+ set.DepthLimit = nil
+ case tsShare:
+ set.SubShare = nil
+ }
+ })
+}
+
+// teamSheetWrite makes one change to the card's team's overrides, as an
+// ordinary edit ([app.teamEdit]).
+func (a *app) teamSheetWrite(change func(*teamstore.Settings)) {
+ id := a.tsheet.team
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.SetSettings(id, change) }); err != nil {
+ a.tsheet.err = err.Error()
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+}
+
+// teamSheetHitAt is the card's target under the pointer on the last frame.
+func (a *app) teamSheetHitAt(x, y int) (wallHit, bool) {
+ for _, hit := range a.tsheet.hits {
+ if x >= hit.x0 && x < hit.x1 && y >= hit.y0 && y < hit.y1 {
+ return hit, true
+ }
+ }
+ return wallHit{}, false
+}
+
+// teamSheetPress is a left press while the card is up. A press on a target
+// takes it; one on the card between targets does nothing; one off the card
+// puts it away, keeping a name typed into it.
+func (a *app) teamSheetPress(x, y int) tea.Cmd {
+ if hit, ok := a.teamSheetHitAt(x, y); ok {
+ return a.teamSheetDo(hit.arg)
+ }
+ if !a.tsheet.rect.holds(x, y) {
+ if a.tsheet.mode == teamSheetSettings {
+ a.teamSheetShut()
+ } else {
+ a.tsheet = teamSheet{}
+ a.touch()
+ }
+ }
+ return nil
+}
+
+// teamSheetMotion lights the target under the pointer.
+func (a *app) teamSheetMotion(x, y int) {
+ hot := -1
+ if hit, ok := a.teamSheetHitAt(x, y); ok {
+ hot = hit.arg
+ }
+ if hot != a.tsheet.hot {
+ a.tsheet.hot = hot
+ a.touch()
+ }
+}
+
+// teamSheetHint is what the hint line says while the card is up.
+func (a *app) teamSheetHint() string {
+ s := &a.tsheet
+ switch s.mode {
+ case teamSheetClose:
+ return "↑↓ choose · enter · esc cancel"
+ case teamSheetDelete:
+ return "enter · esc keep it"
+ }
+ if s.editing >= 0 {
+ return "enter keep it as this team's own · esc cancel"
+ }
+ switch s.cursor {
+ case tsName:
+ return "type to rename · ↓ next · esc done"
+ case tsColour:
+ return "←→ colour · esc done"
+ case tsQuestions, tsCap, tsDepth, tsShare:
+ return "enter change it for this team · r reset to inherit · esc done"
+ case tsInside:
+ return "enter Move into… another team, or the top level · esc done"
+ case tsMoveYes:
+ return a.teamMoveDoing(a.tmove.pend.ids, a.tmove.pend.parent) + " · enter · esc cancel"
+ case tsMoveNo:
+ return "Leave it where it is · enter"
+ case tsMoveUndo:
+ return "Put it back where it was · u"
+ }
+ return "↑↓ move · enter · esc done"
+}
diff --git a/internal/tui3/teamsopen.go b/internal/tui3/teamsopen.go
new file mode 100644
index 0000000000..84351f8327
--- /dev/null
+++ b/internal/tui3/teamsopen.go
@@ -0,0 +1,437 @@
+package tui3
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "strings"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+)
+
+// ── BRINGING THE MANAGER INTO THE PANE ──────────────────────────────────────
+//
+// The pane hosts the selected team's manager only while that conversation is
+// the one in front ([app.teamsHosting]); everything here is how it gets there,
+// and what the pane says while it does not.
+//
+// THE PANE USED TO SAY `opening ◆ prism's manager…` WHENEVER THE MANAGER WAS NOT
+// IN FRONT, whether or not anything was opening it. The one attempt was made by
+// [app.teamsBringManager] on the page's own doors (opening the page, choosing a
+// team, a close and a reopen), and every other road to the same state made
+// none: the teams arriving from the disk after the page opened, a manager set
+// on the file by a session, a message handed to the hosted conversation that
+// moved the front (a press on a Traffic row), a refusal that went to a line the
+// pane does not draw. Each of them left the word on the pane with nothing
+// behind it, and the owner watched it for as long as he looked (2026-09-24).
+// And the attempt that did run went through the switcher's own door, which
+// ends by stepping off whatever place is standing ([app.hopLand]): a manager
+// this window was not holding was opened and the person was taken off the page
+// to it.
+//
+// SO AN ATTEMPT IS NOW A THING THE PAGE HOLDS ([teamsOpen]), and the word on the
+// pane is read off it rather than off the front. [app.teamsSync], which runs
+// after every message, starts one whenever the selected team's manager is not
+// in front and no attempt has been made for it, so no road can leave the pane
+// waiting on nothing. The open is asked off the loop and lands the manager
+// behind, then brings it forward only if the page still wants it, so the
+// person never leaves the page for it. And the pane never waits silently: a
+// refusal is said with its reason, an attempt the engine has not answered
+// within [teamsOpenBound] is said too, and both offer `Retry` and
+// `Open in chats` (the chat surface's own door, [app.trafficGo]). A manager
+// whose transcript is gone offers `+ Manager`, which makes a new one.
+//
+// ONE OPEN PER MANAGER IS OUT AT A TIME. A team chosen twice (a double press, or
+// away and back while the first open was on the wire) used to ask the door
+// twice for the same transcript. The first answer came back as a stale attempt
+// and was put behind, the second met the first one's lock, and the pane said
+// the conversation was open in another window, about a conversation this window
+// was holding, until the person pressed Retry. Now a choice while an open is
+// out takes that open up as its own ([teamsOpenOut]), an answer for the manager
+// the page is asking about is the page's answer whichever attempt it was, and a
+// refusal about a conversation this window already holds is no refusal. `Retry`
+// alone asks again while an open is out, because it is pressed when that open
+// has not answered.
+//
+// AND THE DOOR IS NEVER ASKED FROM UPDATE. Over a connection that holds one
+// conversation at a time the open is the engine's swap; it was made on the
+// loop, and the window froze for the round trip. It goes through the ordered
+// door line now ([app.offLoop]), because a swap is a gesture whose order
+// matters, and the swap is taken when it answers.
+
+// teamsOpenBound is how long the pane says `opening` before it says the engine
+// has not answered. Long enough for an ordinary open over the socket, short
+// enough that a person is not left looking at a word with nothing behind it.
+const teamsOpenBound = 4 * time.Second
+
+// teamsManagerGoneWord is the reason for a manager whose transcript is not on
+// the disk any more.
+const teamsManagerGoneWord = "its conversation is gone"
+
+// teamsOpenInChatsWord is the button that opens the manager where every
+// conversation is opened, off the page.
+const teamsOpenInChatsWord = "Open in chats"
+
+// teamsOpen is the page's one attempt to bring a manager in front: which
+// manager, when it was asked, and how it ended.
+type teamsOpen struct {
+ // key is the manager this attempt is for; gen tells a stale answer from
+ // the current one after a Retry.
+ key string
+ gen int
+ // at is when the open was asked, and out says it has not answered yet.
+ at time.Time
+ out bool
+ // why is the refusal, said on the pane; missing says the transcript is
+ // gone, which offers `+ Manager` rather than a door that cannot open it.
+ why string
+ missing bool
+ // done says the manager came in front on this attempt.
+ done bool
+}
+
+// teamsOpenOut is one open a door has not answered yet: its attempt and when
+// it was asked.
+type teamsOpenOut struct {
+ gen int
+ at time.Time
+}
+
+// teamsBringManager brings the selected team's manager in front when it is not
+// there yet. It is the one move of the front this page makes, and only ever
+// for the team the person chose: the conversation that was in front stays
+// open behind, one `tab` away. A new attempt every time it is called, so the
+// page's own doors (open, select, reopen) always try again.
+func (a *app) teamsBringManager() tea.Cmd {
+ t, ok := a.teamsSelected()
+ if !ok || t.Closed() || t.Manager == "" || a.teamsOff() {
+ a.tp.open = teamsOpen{gen: a.tp.open.gen}
+ return nil
+ }
+ if t.Manager == a.frontTabKey() {
+ a.tp.open = teamsOpen{key: t.Manager, gen: a.tp.open.gen, done: true}
+ return nil
+ }
+ return a.teamsOpenManager(t, false)
+}
+
+// teamsKeepManager is [app.teamsSync]'s half: an attempt for the selected
+// team's manager when none has been made for it, whatever road left it out of
+// front. It makes one attempt per manager: a refusal stays said until the
+// person presses Retry or chooses again, rather than being asked every beat.
+func (a *app) teamsKeepManager(t team) tea.Cmd {
+ if a.tp.open.key == t.Manager {
+ return nil
+ }
+ return a.teamsOpenManager(t, false)
+}
+
+// teamsOpenManager asks for team t's manager: brought forward at once when this
+// window holds it behind, and otherwise opened off the loop, landed behind, and
+// brought forward when the answer comes back if the page still wants it. An
+// open for the same manager that is still out is taken up rather than asked
+// again, unless again says the person pressed Retry.
+func (a *app) teamsOpenManager(t team, again bool) tea.Cmd {
+ key := t.Manager
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ if out, ok := a.tp.opens[key]; ok && !again {
+ a.tp.open = teamsOpen{key: key, gen: out.gen, at: out.at, out: true}
+ return nil
+ }
+ a.tp.openSeq++
+ gen := a.tp.openSeq
+ a.tp.open = teamsOpen{key: key, gen: gen, at: a.now(), out: true}
+ if held := a.behind[key]; held != nil {
+ cmd, _ := a.bringForward(held.conv.SessionFile)
+ a.tp.open.out, a.tp.open.done = false, true
+ return cmd
+ }
+ m, ok := t.Member(key)
+ if !ok || strings.TrimSpace(m.File) == "" {
+ a.teamsOpenFailed(gen, teamsManagerGoneWord, true)
+ return nil
+ }
+ file, where := m.File, m.Where
+ switch {
+ case a.shared:
+ // ONE CONVERSATION PER CONNECTION: the engine swaps in place and there
+ // is nothing to hold behind ([Options.SharedAgent]), so the swap is the
+ // open. The identity is asked here, from memory ([app.openBeside]'s
+ // rule); the swap itself is a door, asked on the line.
+ if cmd, ours := a.bringForward(file); ours {
+ a.tp.open.out, a.tp.open.done = false, true
+ return cmd
+ }
+ if !a.canOpen() {
+ a.teamsOpenFailed(gen, resumeUnavailableWord, false)
+ return nil
+ }
+ case !a.canOpen():
+ a.teamsOpenFailed(gen, resumeUnavailableWord, false)
+ return nil
+ }
+ if a.tp.opens == nil {
+ a.tp.opens = map[string]teamsOpenOut{}
+ }
+ a.tp.opens[key] = teamsOpenOut{gen: gen, at: a.tp.open.at}
+ open, resume, local, shared := a.open, a.resume, !a.hosted(), a.shared
+ ask := func() func(bool) tea.Cmd {
+ var conv Conversation
+ var err error
+ missing := false
+ switch {
+ case local && !shared && transcriptGone(file):
+ // A local path is this machine's, so its absence is a fact; over
+ // --host the path is the far machine's and only its engine can say.
+ missing, err = true, errors.New(teamsManagerGoneWord)
+ case local && !shared && !homeFolderThere(where):
+ err = errors.New(WorkspaceGoneWord + " · " + where)
+ case open != nil:
+ conv, err = open(where, file)
+ default:
+ var agent Agent
+ agent, err = resume(file)
+ conv = Conversation{Agent: agent, Workspace: where, SessionFile: file}
+ }
+ if shared {
+ return func(bool) tea.Cmd { return a.teamsSwapped(gen, key, conv, err) }
+ }
+ return func(bool) tea.Cmd { return a.teamsOpened(gen, key, file, conv, err, missing) }
+ }
+ if shared {
+ return a.offLoop(ask)
+ }
+ return a.besideLine(ask)
+}
+
+// transcriptGone reports whether a local transcript is not on the disk. Only a
+// not-exist answer counts: a permission error is a door's to say.
+func transcriptGone(file string) bool {
+ _, err := os.Stat(file)
+ return errors.Is(err, fs.ErrNotExist)
+}
+
+// teamsOpened folds one open's answer in, on the loop. A conversation that
+// opened is held behind, and brought forward only when the page still wants
+// that manager: an answer that arrives after the person chose another team, or
+// left the page, stays a tab of its own rather than moving them. An answer
+// about the manager the page is asking for is the page's answer whichever
+// attempt carried it, and a refusal is said only when it is the page's current
+// attempt and the conversation is not in this window's hands after all.
+func (a *app) teamsOpened(gen int, key, file string, conv Conversation, err error, missing bool) tea.Cmd {
+ if out, ok := a.tp.opens[key]; ok && out.gen == gen {
+ delete(a.tp.opens, key)
+ }
+ mine := a.tp.open.key == key
+ current := mine && a.tp.open.gen == gen
+ if err != nil || conv.Agent == nil {
+ switch {
+ case a.teamsOpenHeld(key, file):
+ return a.teamsLanded(key, file)
+ case current:
+ why := "the conversation did not open"
+ switch {
+ case errors.Is(err, session.ErrSessionLocked):
+ why = sessionBusyWord
+ case err != nil:
+ why = err.Error()
+ }
+ a.teamsOpenFailed(gen, why, missing)
+ }
+ return nil
+ }
+ var cmd tea.Cmd
+ if a.holding(conv.SessionFile) {
+ // Another road opened it while this ask was out (a Retry, or the
+ // person's own door); the window holds one handle per conversation.
+ leaveOffFrame(conv.Agent)
+ } else {
+ cmd = a.stow(conv, nil)
+ a.chatTabBar = tabBar{}
+ }
+ if mine {
+ cmd = tea.Batch(cmd, a.teamsLanded(key, conv.SessionFile))
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return cmd
+}
+
+// teamsLanded is the page's attempt for key answered with the conversation
+// held: the pane stops waiting, and the manager comes in front if the page
+// still wants it.
+func (a *app) teamsLanded(key, file string) tea.Cmd {
+ a.tp.open.out, a.tp.open.why, a.tp.open.missing = false, "", false
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ if !a.teamsWantsManager(key) {
+ return nil
+ }
+ cmd, _ := a.bringForward(file)
+ a.tp.open.done = true
+ return cmd
+}
+
+// teamsOpenHeld reports whether a refusal for key is about a conversation this
+// window already holds. Another answer landed it while this one was out, and
+// this one met its lock. A refusal about a conversation in hand is no refusal,
+// on the local open and on the swap alike.
+func (a *app) teamsOpenHeld(key, file string) bool {
+ return a.tp.open.key == key && a.holding(file)
+}
+
+// teamsSwapped folds a swap over a connection that holds one conversation at a
+// time. The engine has already moved when it answers, so a conversation that
+// opened is taken in front whatever the page wants now ([app.takeBeside]): the
+// window must show the conversation its one handle names. A refusal about a
+// conversation this window already holds is no refusal ([app.teamsOpenHeld]),
+// the same rule as the local open. Any other refusal is said when it is the
+// page's current attempt.
+func (a *app) teamsSwapped(gen int, key string, conv Conversation, err error) tea.Cmd {
+ if out, ok := a.tp.opens[key]; ok && out.gen == gen {
+ delete(a.tp.opens, key)
+ }
+ current := a.tp.open.key == key && a.tp.open.gen == gen
+ if err != nil || conv.Agent == nil {
+ if a.teamsOpenHeld(key, conv.SessionFile) {
+ return a.teamsLanded(key, conv.SessionFile)
+ }
+ if current {
+ why := "the conversation did not open"
+ switch {
+ case errors.Is(err, session.ErrSessionLocked):
+ why = sessionBusyWord
+ case err != nil:
+ why = err.Error()
+ }
+ a.teamsOpenFailed(gen, why, false)
+ }
+ return nil
+ }
+ cmd := a.takeBeside(conv)
+ if a.tp.open.key == key {
+ a.tp.open.out, a.tp.open.why, a.tp.open.done = false, "", true
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return cmd
+}
+
+// teamsOpenFailed says attempt gen's refusal on the pane.
+func (a *app) teamsOpenFailed(gen int, why string, missing bool) {
+ if a.tp.open.gen != gen {
+ return
+ }
+ a.tp.open.out, a.tp.open.done = false, false
+ a.tp.open.why, a.tp.open.missing = why, missing
+ a.tp.top = teamsTopCache{}
+ a.touch()
+}
+
+// teamsWantsManager reports whether the page still wants key in front: it is
+// standing, and key is the selected open team's manager.
+func (a *app) teamsWantsManager(key string) bool {
+ if !a.at(pageTeams) {
+ return false
+ }
+ t, ok := a.teamsSelected()
+ return ok && !t.Closed() && t.Manager == key
+}
+
+// teamsRetryManager is `Retry`: a new attempt for the selected team's manager.
+func (a *app) teamsRetryManager() tea.Cmd {
+ t, ok := a.teamsSelected()
+ if !ok || t.Closed() || t.Manager == "" {
+ return nil
+ }
+ return a.teamsOpenManager(t, true)
+}
+
+// teamsOpenInChats is `Open in chats`: the manager opened through the chat
+// surface's own door ([app.trafficGo]), off the page, where a refusal is said
+// on the conversation's own line.
+func (a *app) teamsOpenInChats() tea.Cmd {
+ t, ok := a.teamsSelected()
+ if !ok || t.Manager == "" {
+ return nil
+ }
+ a.leavePlace()
+ return a.trafficGo(t.Manager)
+}
+
+// teamsManagerMissing reports whether team t's manager is known to be gone, so
+// `+ Manager` may replace it.
+func (a *app) teamsManagerMissing(t team) bool {
+ return t.Manager != "" && a.tp.open.key == t.Manager && a.tp.open.missing
+}
+
+// teamsOpeningRows is the pane under the header while the selected team's
+// manager is not in front: `opening` for the beat it takes, and once it has
+// taken longer than [teamsOpenBound] or been refused, the reason and a way
+// forward.
+func (a *app) teamsOpeningRows(d *teamsDraw, t team, width, y int) []string {
+ pal := a.pal
+ name := t.Name
+ if t.Root {
+ name = "all teams"
+ }
+ manager := a.teamManagerMark() + " " + name + "'s manager"
+ o := a.tp.open
+ mine := o.key == t.Manager
+ why := ""
+ switch {
+ case t.Manager == a.frontTabKey():
+ // In front, with a surface over it that keeps the pane from hosting it
+ // for now; nothing is being opened.
+ return nil
+ case mine && o.why != "":
+ why = o.why
+ case mine && o.out && a.now().Sub(o.at) >= teamsOpenBound:
+ why = "no answer in " + itoa(int(teamsOpenBound/time.Second)) + "s"
+ case mine && o.done:
+ // It came in front on this attempt and something has since moved the
+ // front off it.
+ out := []string{"", " " + pal.dim(fit(manager+" is not in front", width-2))}
+ return append(out, a.teamsOpenButtons(d, t, y+len(out), "Bring it here")...)
+ default:
+ word := "opening " + manager + a.linearMark("…", "...")
+ return []string{"", " " + pal.dim(fit(word, width-2))}
+ }
+ out := []string{""}
+ for _, l := range wrap("couldn't open "+manager+": "+why, max(width-2, 8)) {
+ out = append(out, " "+pal.muted(l))
+ }
+ out = append(out, "")
+ return append(out, a.teamsOpenButtons(d, t, y+len(out), "Retry")...)
+}
+
+// teamsOpenButtons is the pane's way forward: `retry` (the word says what it
+// does here) and `Open in chats`, or `+ Manager` in place of the second when
+// the manager's transcript is gone.
+func (a *app) teamsOpenButtons(d *teamsDraw, t team, y int, retry string) []string {
+ pal := a.pal
+ row := " "
+ x := 1
+ if a.teamsManagerMissing(t) {
+ s, w := d.button(teamManagerSlotWord, teamsTarget{act: teamsActManager, id: t.ID, x0: x, y: y,
+ hint: "Start a new manager for " + t.Name + hintSegment + "m"}, pal.ink)
+ row += s + " "
+ x += w + 2
+ }
+ s, w := d.button(retry, teamsTarget{act: teamsActRetryManager, id: t.ID, x0: x, y: y,
+ hint: "Open " + t.Name + "'s manager here again"}, pal.ink)
+ row += s
+ x += w
+ if !a.teamsManagerMissing(t) {
+ row += " "
+ x += 2
+ s, _ := d.button(teamsOpenInChatsWord, teamsTarget{act: teamsActOpenInChats, id: t.ID, x0: x, y: y,
+ hint: "Open " + t.Name + "'s manager as a conversation, off this page"}, pal.ink)
+ row += s
+ }
+ return []string{row}
+}
diff --git a/internal/tui3/teamsopen_test.go b/internal/tui3/teamsopen_test.go
new file mode 100644
index 0000000000..64dbd40058
--- /dev/null
+++ b/internal/tui3/teamsopen_test.go
@@ -0,0 +1,388 @@
+package tui3
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE MANAGER BROUGHT INTO THE PANE (teamsopen.go) ────────────────────────
+
+// teamsOpenLab is the teams page lab with orbit given a manager this window is
+// NOT holding: a transcript in a folder of its own, which is not the window's
+// (`/tmp/lab`). made says whether the transcript is on the disk. The open door
+// is a fake that records what it was asked and answers with err when set.
+type teamsOpenLab struct {
+ a *app
+ harbor, orbit string
+ file, where string
+ key string
+ asked []string
+ err error
+}
+
+func newTeamsOpenLab(t *testing.T, made bool) *teamsOpenLab {
+ t.Helper()
+ l := &teamsOpenLab{}
+ l.a, l.harbor, l.orbit = teamsPlaceLabIDs(t)
+ l.where = t.TempDir()
+ l.file = filepath.Join(l.where, "manager.jsonl")
+ if made {
+ if err := os.WriteFile(l.file, []byte("{}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ l.key = l.a.convKey(l.file)
+ l.a.open = func(where, file string) (Conversation, error) {
+ l.asked = append(l.asked, where+" "+file)
+ if l.err != nil {
+ return Conversation{}, l.err
+ }
+ return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: where, SessionFile: file}, nil
+ }
+ if err := l.a.teamEdit(func(f *teamstore.File) error {
+ if err := f.AddMember(l.orbit, teamstore.Member{Key: l.key, File: l.file, Where: l.where, Word: "run orbit", Handle: "boss"}); err != nil {
+ return err
+ }
+ return f.SetManager(l.orbit, l.key)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return l
+}
+
+// selectOrbit chooses orbit on the rail, as a press does, and runs what that
+// asked for.
+func (l *teamsOpenLab) selectOrbit(t *testing.T) {
+ t.Helper()
+ drive(t, l.a, runCmd(l.a.teamsSelect(l.orbit))...)
+}
+
+// A MANAGER THIS WINDOW IS NOT HOLDING OPENS IN THE PANE, AND THE PERSON STAYS
+// ON THE PAGE. It is opened in its own folder, not the window's, off the loop,
+// and the pane hosts it; the conversation that was in front stays open behind.
+// Before the fix the open went through the switcher's door, which steps off
+// the place standing, and the person was taken off the page to it.
+func TestTeamsManagerNotHeldOpensInThePane(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ was := l.a.frontTabKey()
+ l.selectOrbit(t)
+ if len(l.asked) != 1 || l.asked[0] != l.where+" "+l.file {
+ t.Fatalf("the door was asked %q, want the manager in its own folder %q", l.asked, l.where)
+ }
+ if !l.a.at(pageTeams) {
+ t.Fatalf("opening the manager took the person off the page, to %q", l.a.page.word())
+ }
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() {
+ t.Fatalf("the pane does not host the manager (front %q):\n%s", l.a.frontTabKey(), teamsFrameText(l.a))
+ }
+ if !l.a.trafficHeld(was) {
+ t.Fatal("the conversation that was in front is not held behind")
+ }
+ if strings.Contains(teamsFrameText(l.a), "opening") {
+ t.Fatalf("the hosted pane still says opening:\n%s", teamsFrameText(l.a))
+ }
+}
+
+// A MANAGER HELD BEHIND COMES FORWARD at once, with no door asked.
+func TestTeamsManagerHeldBehindComesForward(t *testing.T) {
+ a, _, orbit := teamsPlaceLabIDs(t)
+ m := mustTeam(t, a, orbit).Members[0]
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.SetManager(orbit, m.Key) }); err != nil {
+ t.Fatal(err)
+ }
+ asked := 0
+ a.open = func(where, file string) (Conversation, error) {
+ asked++
+ return Conversation{}, errors.New("not asked")
+ }
+ drive(t, a, runCmd(a.teamsSelect(orbit))...)
+ if asked != 0 || a.frontTabKey() != m.Key || !a.teamsHosting() {
+ t.Fatalf("the held manager did not come forward (asked %d, front %q)", asked, a.frontTabKey())
+ }
+}
+
+// A MANAGER SET WHILE THE PAGE STANDS IS BROUGHT IN without anybody choosing
+// the team again: a session naming one on the file, or the teams arriving
+// after the page opened. The pane used to say `opening` with nothing behind it.
+func TestTeamsManagerSetWhileThePageStandsIsBroughtIn(t *testing.T) {
+ a, _, orbit := teamsPlaceLabIDs(t)
+ drive(t, a, runCmd(a.teamsSelect(orbit))...)
+ where := t.TempDir()
+ file := filepath.Join(where, "later.jsonl")
+ if err := os.WriteFile(file, []byte("{}\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ a.open = func(w, f string) (Conversation, error) {
+ return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: w, SessionFile: f}, nil
+ }
+ key := a.convKey(file)
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ if err := f.AddMember(orbit, teamstore.Member{Key: key, File: file, Where: where, Word: "later", Handle: "later"}); err != nil {
+ return err
+ }
+ return f.SetManager(orbit, key)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ // Any message: the page settles after every one.
+ drive(t, a, tea.WindowSizeMsg{Width: a.width, Height: a.height})
+ if a.frontTabKey() != key || !a.teamsHosting() || !a.at(pageTeams) {
+ t.Fatalf("the manager set on the file was not brought in (front %q):\n%s", a.frontTabKey(), teamsFrameText(a))
+ }
+}
+
+// A REFUSAL IS SAID ON THE PANE WITH ITS REASON, and offers Retry and
+// Open in chats. Retry asks again and hosts it; the refusal is not asked
+// again on every beat.
+func TestTeamsManagerRefusedSaysWhyAndRetries(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ l.err = errors.New("the engine said no")
+ l.selectOrbit(t)
+ drive(t, l.a, tea.WindowSizeMsg{Width: l.a.width, Height: l.a.height})
+ text := teamsFrameText(l.a)
+ for _, want := range []string{"couldn't open " + teamManagerGlyph + " orbit's manager: the engine said no", "Retry", teamsOpenInChatsWord} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the pane does not say %q:\n%s", want, text)
+ }
+ }
+ if len(l.asked) != 1 {
+ t.Fatalf("the refused open was asked %d times without a Retry", len(l.asked))
+ }
+ if !l.a.at(pageTeams) {
+ t.Fatal("a refusal took the person off the page")
+ }
+ l.err = nil
+ drive(t, l.a, runCmd(l.a.teamsDo(teamsTargetOf(t, l.a, teamsActRetryManager, l.orbit)))...)
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() {
+ t.Fatalf("Retry did not host the manager:\n%s", teamsFrameText(l.a))
+ }
+}
+
+// AN OPEN THE ENGINE HAS NOT ANSWERED IS SAID AFTER THE BOUND: `opening` for
+// the beat it takes, then the reason and the way forward. Open in chats goes
+// through the chat surface's own door, off the page.
+func TestTeamsManagerSlowOpenIsSaidAfterTheBound(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ now := time.Date(2026, 9, 24, 20, 0, 0, 0, time.UTC)
+ l.a.clock = func() time.Time { return now }
+ // The ask is left out: the engine has not answered.
+ _ = l.a.teamsSelect(l.orbit)
+ text := teamsFrameText(l.a)
+ if !strings.Contains(text, "opening "+teamManagerGlyph+" orbit's manager") || strings.Contains(text, "Retry") {
+ t.Fatalf("the pane does not say opening for the beat:\n%s", text)
+ }
+ now = now.Add(teamsOpenBound + time.Second)
+ l.a.tp.top = teamsTopCache{}
+ text = teamsFrameText(l.a)
+ for _, want := range []string{"couldn't open " + teamManagerGlyph + " orbit's manager: no answer in 4s", "Retry", teamsOpenInChatsWord} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("after the bound the pane does not say %q:\n%s", want, text)
+ }
+ }
+ drive(t, l.a, runCmd(l.a.teamsDo(teamsTargetOf(t, l.a, teamsActOpenInChats, l.orbit)))...)
+ if l.a.at(pageTeams) || l.a.frontTabKey() != l.key {
+ t.Fatalf("Open in chats did not open the manager off the page (page %q, front %q)", l.a.page.word(), l.a.frontTabKey())
+ }
+}
+
+// A MANAGER WHOSE TRANSCRIPT IS GONE SAYS SO AND OFFERS `+ Manager`, which
+// makes a new conversation the team's manager and hosts it.
+func TestTeamsManagerGoneOffersANewManager(t *testing.T) {
+ l := newTeamsOpenLab(t, false)
+ l.selectOrbit(t)
+ text := teamsFrameText(l.a)
+ for _, want := range []string{"couldn't open " + teamManagerGlyph + " orbit's manager: " + teamsManagerGoneWord, teamManagerSlotWord, "Retry"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the pane does not say %q:\n%s", want, text)
+ }
+ }
+ if len(l.asked) != 0 {
+ t.Fatalf("the door was asked for a transcript that is not there: %q", l.asked)
+ }
+ fresh := filepath.Join(t.TempDir(), "fresh.jsonl")
+ l.a.start = func(where string) (Conversation, error) {
+ return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: where, SessionFile: fresh}, nil
+ }
+ drive(t, l.a, runCmd(l.a.teamsDo(teamsTargetOf(t, l.a, teamsActManager, l.orbit)))...)
+ teamsFlush(t, l.a)
+ if got := mustTeam(t, l.a, l.orbit).Manager; got != l.a.convKey(fresh) {
+ t.Fatalf("+ Manager did not replace the gone manager: %q", got)
+ }
+ if !l.a.teamsHosting() || !l.a.at(pageTeams) {
+ t.Fatalf("the new manager is not in the pane:\n%s", teamsFrameText(l.a))
+ }
+}
+
+// A PRESS ON A TRAFFIC ROW IN THE HOSTED MANAGER GOES THROUGH THE CHAT'S OWN
+// DOOR and takes the person to that member, as a press on a member row does,
+// rather than leaving the page over a conversation it does not host with the
+// pane saying `opening`.
+func TestTeamsHostedTrafficRowGoesToTheMember(t *testing.T) {
+ a, harbor, _, _ := trafficApp(t)
+ a.width, a.height = 180, 40
+ price, priceKey := trafficHandle(t, a, harbor, "openrouter")
+ trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: "prices are in"})
+ trafficReadNow(t, a)
+ if cmd := a.showPage(pageTeams); cmd != nil {
+ drive(t, a, runCmd(cmd)...)
+ }
+ drive(t, a, runCmd(a.teamsSelect(harbor))...)
+ a.tp.traffic = true
+ a.teamsSync()
+ if !a.teamsHosting() {
+ t.Fatalf("the pane does not host harbor's manager:\n%s", teamsFrameText(a))
+ }
+ // The note answers nothing, so it is a line under General, laid open.
+ a.sideToggleThread(sideThreadKey(harbor, sideGeneral))
+ frame, _, _ := a.frame()
+ rows := strings.Split(ansi.Strip(frame), "\n")
+ cols := a.railWidth()
+ // THE RAIL IS THREADS: the member's handle heads the thread and opens it,
+ // and the words under it are a door of their own.
+ at, x := -1, -1
+ for y, r := range rows {
+ cells := plainCells(r, a.width-cols, a.width)
+ if i := strings.Index(cells, "@"+price); i >= 0 {
+ at, x = y, a.width-cols+len([]rune(cells[:i]))+1
+ break
+ }
+ }
+ if at < 0 {
+ t.Fatalf("no Traffic row in the hosted pane:\n%s", strings.Join(rows, "\n"))
+ }
+ drive(t, a, tea.MouseClickMsg{X: x, Y: at, Button: tea.MouseLeft})
+ if a.frontTabKey() != priceKey {
+ t.Fatalf("the row went to %q, want %q", a.frontTabKey(), priceKey)
+ }
+ if a.at(pageTeams) {
+ t.Fatalf("the page stayed over a conversation it does not host:\n%s", teamsFrameText(a))
+ }
+}
+
+// lockingOpen makes the lab's door behave like the real one about the
+// transcript's lock: the first open of a file takes it, and every later open of
+// the same file while it is held is refused as another window's.
+func (l *teamsOpenLab) lockingOpen() {
+ held := map[string]bool{}
+ l.a.open = func(where, file string) (Conversation, error) {
+ l.asked = append(l.asked, where+" "+file)
+ if held[file] {
+ return Conversation{}, session.ErrSessionLocked
+ }
+ held[file] = true
+ return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: where, SessionFile: file}, nil
+ }
+}
+
+// A TEAM CHOSEN TWICE WHILE ITS MANAGER IS OPENING ASKS THE DOOR ONCE, AND THE
+// MANAGER COMES IN. The second choice used to ask again: the first answer came
+// back as a stale attempt and was put behind, the second met its lock, and the
+// pane said the manager was open in another window until Retry was pressed.
+// The two answers are run one after the other, first ask first, which is the
+// order that stuck.
+func TestTeamsManagerChosenTwiceWhileOpeningAsksOnceAndComesIn(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ l.lockingOpen()
+ first := l.a.teamsSelect(l.orbit)
+ second := l.a.teamsSelect(l.orbit)
+ drive(t, l.a, runCmd(first)...)
+ drive(t, l.a, runCmd(second)...)
+ if len(l.asked) != 1 {
+ t.Fatalf("the door was asked %d times for one manager: %q", len(l.asked), l.asked)
+ }
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() || l.a.tp.open.why != "" {
+ t.Fatalf("the manager is not in the pane (front %q, pane says %q):\n%s", l.a.frontTabKey(), l.a.tp.open.why, teamsFrameText(l.a))
+ }
+}
+
+// A RETRY PRESSED WHILE THE FIRST OPEN IS STILL OUT ASKS AGAIN, and whichever
+// answer lands the manager is the page's: the first one, which the Retry made
+// stale, brings the manager in, and the Retry's refusal about a conversation
+// this window now holds is no refusal.
+func TestTeamsManagerRetryRacingTheFirstOpenStillComesIn(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ l.lockingOpen()
+ first := l.a.teamsSelect(l.orbit)
+ again := l.a.teamsRetryManager()
+ drive(t, l.a, runCmd(first)...)
+ drive(t, l.a, runCmd(again)...)
+ if len(l.asked) != 2 {
+ t.Fatalf("Retry did not ask the door again: %q", l.asked)
+ }
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() || l.a.tp.open.why != "" {
+ t.Fatalf("the manager is not in the pane (front %q, pane says %q):\n%s", l.a.frontTabKey(), l.a.tp.open.why, teamsFrameText(l.a))
+ }
+ if strings.Contains(teamsFrameText(l.a), sessionBusyWord) {
+ t.Fatalf("the pane says the manager is busy:\n%s", teamsFrameText(l.a))
+ }
+}
+
+// OVER A CONNECTION THAT HOLDS ONE CONVERSATION AT A TIME THE SWAP IS NOT MADE
+// FROM UPDATE. Choosing the team asks nothing on the keystroke; the swap is a
+// command, and when it answers the manager is the conversation in front.
+func TestTeamsManagerSwapOverASharedConnectionIsAskedOffTheLoop(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ l.a.shared = true
+ l.a.open = nil
+ swapped := 0
+ l.a.resume = func(file string) (Agent, error) {
+ swapped++
+ return &fakeAgent{model: "m"}, nil
+ }
+ cmd := l.a.teamsSelect(l.orbit)
+ if swapped != 0 {
+ t.Fatal("the swap was asked on the keystroke, from Update")
+ }
+ drive(t, l.a, runCmd(cmd)...)
+ if swapped != 1 {
+ t.Fatalf("the swap was asked %d times", swapped)
+ }
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() {
+ t.Fatalf("the swapped manager is not in the pane (front %q):\n%s", l.a.frontTabKey(), teamsFrameText(l.a))
+ }
+}
+
+// A SWAP THAT MEETS THIS WINDOW'S OWN LOCK, WHILE THE MANAGER IS HELD BEHIND,
+// IS NO REFUSAL. The local open forgives that case. The swap used to say the
+// conversation was open in another window about one this window already holds.
+// (A manager already in front is a different road: the page settles to done
+// and the refusal does not stay on the pane.)
+func TestTeamsSwappedLockWhileThisWindowHoldsItIsNoRefusal(t *testing.T) {
+ l := newTeamsOpenLab(t, true)
+ l.a.shared = true
+ if l.a.behind == nil {
+ l.a.behind = map[string]*kept{}
+ }
+ l.a.tp.sel = l.orbit
+ l.a.behind[l.key] = &kept{conv: Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: l.file}}
+ if !l.a.holding(l.file) || l.a.frontTabKey() == l.key {
+ t.Fatal("the manager is not held behind")
+ }
+ l.a.tp.openSeq++
+ gen := l.a.tp.openSeq
+ l.a.tp.open = teamsOpen{key: l.key, gen: gen, at: l.a.now(), out: true}
+ if selected, ok := l.a.teamsSelected(); !ok || selected.Manager != l.key || !l.a.at(pageTeams) {
+ t.Fatalf("the page does not want this manager (ok %v, manager %q, key %q, on page %v)", ok, selected.Manager, l.key, l.a.at(pageTeams))
+ }
+ cmd := l.a.teamsSwapped(gen, l.key, Conversation{SessionFile: l.file}, session.ErrSessionLocked)
+ drive(t, l.a, tea.WindowSizeMsg{Width: l.a.width, Height: l.a.height})
+ if cmd != nil {
+ drive(t, l.a, runCmd(cmd)...)
+ }
+ if l.a.tp.open.why != "" || strings.Contains(teamsFrameText(l.a), sessionBusyWord) {
+ t.Fatalf("a lock on a conversation this window holds was said (why %q):\n%s", l.a.tp.open.why, teamsFrameText(l.a))
+ }
+ if l.a.frontTabKey() != l.key || !l.a.teamsHosting() {
+ t.Fatalf("the held manager was not brought forward (front %q):\n%s", l.a.frontTabKey(), teamsFrameText(l.a))
+ }
+}
diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go
new file mode 100644
index 0000000000..b91aab2a58
--- /dev/null
+++ b/internal/tui3/teamspage.go
@@ -0,0 +1,861 @@
+package tui3
+
+import (
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE TEAMS PAGE: THE TEAM-LEVEL VIEW ─────────────────────────────────────
+//
+// The third of the three surfaces (DESIGN.md section 8.4, ruling c-b): the
+// strip is what is open in this window, the wall is the same open set drawn
+// big, and this page is the team as a team. Every member is on it whether this
+// window has it open or not, with what it is doing and when it last moved; the
+// packets waiting on the person are cards on it; and the selected team's
+// manager is not summarised here, it IS here, as the real conversation
+// (teamspagehost.go), so a person can run most of their work from this one
+// page and step away.
+//
+// home teams chats sessions spend settings
+// ────────────────────────────────────────────────────────────────────────
+// All teams + Manager │ ◆ harbor $1.20 of $5 today Settings Close… Open ▦
+// ● harbor ⠿ │ @web running now · @api idle 2h · @docs not open 3d
+// ● parser ? 1 │
+// ● orbit │ ◆ conflict · raised by @web waiting on you
+// │ which shape does the signup form send?
+// + New team │ JSON @api changes the handler recommended
+// ✦ Organize │
+// │ (the manager's own conversation, composer and all)
+// ▸ Closed · 2 │
+//
+// THE LEFT RAIL IS THE TREE (teamspagedraw.go). One row per open team,
+// indented by level, each with its colour dot and at most one mark, and a mark
+// only when something is happening: `⠿` dim while a member works, and the
+// needs-you amber `? N` while N things wait on the person. Idle draws nothing.
+// Above the teams is `All teams`: with no root team it carries `+ Manager` (the
+// optional global manager, which makes the root); with one it is the root and
+// selects like any team. Below them `+ New team` and `✦ Organize`, and at the
+// foot, folded, `Closed · N`.
+//
+// EVERYTHING HERE IS READ OFF THE LOOP, THROUGH THE SEAM, WITH A STAMP
+// (teamseam.go). The page's clock turns only while the page is up AND some
+// team has a manager, because nothing else writes packets or spends a team's
+// money; each turn asks the seam for the packets and the spend with the stamps
+// it holds, and a quiet turn is answered `same` and draws nothing new. The
+// frame reads memory and nothing else (framedisk_law_test.go).
+
+// teamsAllRow is the rail's `All teams` row while there is no root team behind
+// it: a row the page draws over the top level with nothing stored.
+const teamsAllRow = "\x00all"
+
+// The rail's width: a fifth of the frame between its floor and ceiling, and
+// none at all under teamsRailFloor, where the page is the pane alone (the rail
+// is still walked with its keys).
+const (
+ teamsRailMin = 22
+ teamsRailMax = 30
+ teamsRailFloor = 72
+)
+
+// teamsInboxWhole is how many cards the inbox draws whole; past it each card is
+// one line, still pressable.
+const teamsInboxWhole = 3
+
+// The page's words, quoted in the manual exactly as spelled here.
+const (
+ teamsExplainWord = "A team is a set of conversations you run together; give it a manager and you talk to the manager, which hands out the work and asks you only what it cannot decide."
+ teamsNoManagerWord = "a manager takes your messages to the team and asks you only what it cannot decide"
+ teamsOrganizeWord = "Organize my conversations"
+ teamsNewTeamWord = "New team"
+ teamsHostedWord = "the inbox and the spend are not available over this connection"
+ teamsFocusKeys = "alt+↑↓"
+)
+
+// teamsPage is the page's whole state, on the app (place_teams.go's handle
+// holds none).
+type teamsPage struct {
+ // sel is the team the pane is about: a team id, [teamsAllRow], or "" for
+ // none (no teams at all).
+ sel string
+ // closedOpen says the `Closed · N` fold is open.
+ closedOpen bool
+ // focus says the keyboard is on the page's buttons rather than on the
+ // manager's composer. With no manager in the pane there is no composer and
+ // the page always has the keyboard ([app.teamsHasKeys]).
+ focus bool
+ // cur is the target the keyboard is on and hot the one under the pointer,
+ // each named by what it does rather than by where it was drawn, so a
+ // frame that moved it keeps it lit.
+ cur, hot teamsRef
+ // targets is every pressable thing the last frame drew, in frame cells.
+ targets []teamsTarget
+ // expand is an inbox card beyond the first three that a press unfolded.
+ expand string
+ // railW is the rail's columns on the last sync, its separator included.
+ railW int
+ // host is the manager key the pane hosts, "" when it hosts none; forwarding
+ // says a key or a pointer event is being handed to that conversation
+ // (teamspagehost.go).
+ host string
+ forwarding bool
+ // traffic says the hosted manager's Traffic rail is out. It is folded by
+ // default here, because this page has a rail of its own on the left.
+ traffic bool
+ // reading says a read is out, so a beat that comes round before it is
+ // answered does not start a second; again says a read was asked for while
+ // it was out, and againWorld that the ask wanted the members' rows too.
+ // Neither is ever dropped: the fold asks again ([app.teamsFold]).
+ reading, again, againWorld bool
+ // The readings the frame draws from, each with the stamp the next read
+ // asks the seam to answer `same` to.
+ packets []teamstore.Packet
+ packetsStamp string
+ packetsKnown bool
+ spend map[string]teamstore.Spend
+ spendStamp map[string]string
+ defaults teamstore.Defaults
+ defaultsOK bool
+ world map[string]session.SessionRow
+ worldKnown bool
+ history map[string][]teamstore.Packet
+ // answering is the packet whose `Your own answer…` box is open, and answer
+ // the box.
+ answering string
+ answer editor
+ // msg is the page's one line of news, said on its note.
+ msg string
+ // open is the attempt to bring the selected team's manager in front, so
+ // the pane says what it is doing and, when it cannot, why (teamsopen.go).
+ // opens is every open a door has not answered yet, by manager, and
+ // openSeq the last attempt's number.
+ open teamsOpen
+ opens map[string]teamsOpenOut
+ openSeq int
+ // top is the hosted pane's header rows, kept between frames.
+ top teamsTopCache
+ // undo is the last close, while Undo is offered (teamclose.go).
+ undo teamsUndo
+ // picked is the teams picked on the rail with `space`, which one `Move
+ // into…` moves together (teammove.go).
+ picked map[string]bool
+}
+
+// teamsTarget is one pressable thing on the page, in frame cells.
+type teamsTarget struct {
+ x0, x1, y int
+ act teamsAct
+ // id is the team it acts on; arg and opt are the member key, the packet
+ // id and option, or the session row's transcript and answer key.
+ id, arg, opt string
+ // hint is what the hint line says with the pointer or the cursor on it,
+ // its key included.
+ hint string
+ // pane says the target is in the pane rather than on the rail. ↑ and ↓
+ // walk within one of the two, and ← and → cross between them.
+ pane bool
+ // line is the body line the target is on with the pane unscrolled: the
+ // router's name for a row ([place.stops]), which a scroll does not move.
+ line int
+}
+
+// teamsRef names a target by what it does.
+type teamsRef struct {
+ act teamsAct
+ id, arg, opt string
+}
+
+// ref is the target's name.
+func (t teamsTarget) ref() teamsRef { return teamsRef{act: t.act, id: t.id, arg: t.arg, opt: t.opt} }
+
+// teamsAct is what a target does.
+type teamsAct int
+
+const (
+ teamsActNone teamsAct = iota
+ teamsActSelect
+ teamsActRootManager
+ teamsActNewTeam
+ teamsActOrganize
+ teamsActClosedFold
+ teamsActManager
+ teamsActSettings
+ teamsActClose
+ teamsActWall
+ teamsActMember
+ teamsActOption
+ teamsActOwnAnswer
+ teamsActPrompt
+ teamsActReopen
+ teamsActReopenParent
+ teamsActDelete
+ teamsActTraffic
+ teamsActUndo
+ teamsActRetryManager
+ teamsActOpenInChats
+ // The nesting acts (teammove.go, teamcrew.go): `Move` and `Cancel` on a
+ // move's consequence line, the header's `◆ Manager` and its members word.
+ teamsActMoveYes
+ teamsActMoveNo
+ teamsActManagerGo
+ teamsActCrew
+)
+
+// ── THE TREE ────────────────────────────────────────────────────────────────
+
+// teamsRailRow is one row of the rail's model.
+type teamsRailRow struct {
+ kind int
+ id string
+ depth int
+}
+
+const (
+ railRowAll = iota
+ railRowTeam
+ railRowBlank
+ railRowNew
+ railRowOrganize
+ railRowClosed
+ railRowClosedTeam
+ // railRowNewIn is the second row of `+ New team in harbor` on a rail too
+ // narrow to say it on one: `in harbor`, under `+ New team`.
+ railRowNewIn
+)
+
+// teamsRoot is the root team, false when there is none. Memory only.
+func (a *app) teamsRoot() (team, bool) {
+ for _, t := range a.wall.teams {
+ if t.Root {
+ return t, true
+ }
+ }
+ return team{}, false
+}
+
+// teamsOpenTree is every open team that is not the root, in tree order: each
+// top-level team (or each team directly under the root) and then its open
+// sub-teams, depth first, in stored order, with its depth from 0.
+func (a *app) teamsOpenTree() []teamsRailRow {
+ root, hasRoot := a.teamsRoot()
+ var out []teamsRailRow
+ var walk func(parent string, depth int)
+ walk = func(parent string, depth int) {
+ for _, t := range a.wall.teams {
+ if t.Root || t.Closed() || t.Parent != parent {
+ continue
+ }
+ out = append(out, teamsRailRow{kind: railRowTeam, id: t.ID, depth: depth})
+ if depth < 10 {
+ walk(t.ID, depth+1)
+ }
+ }
+ }
+ top := ""
+ if hasRoot {
+ top = root.ID
+ }
+ walk(top, 0)
+ return out
+}
+
+// teamsClosed is every closed team, newest close first.
+func (a *app) teamsClosed() []team {
+ return a.teamTree().ClosedTeams()
+}
+
+// teamsRailRows is the rail, top to bottom. Memory only.
+func (a *app) teamsRailRows() []teamsRailRow {
+ rows := []teamsRailRow{{kind: railRowAll}}
+ rows = append(rows, a.teamsOpenTree()...)
+ rows = append(rows, teamsRailRow{kind: railRowBlank}, teamsRailRow{kind: railRowNew})
+ if _, split := a.teamsRailNewWords(); split {
+ rows = append(rows, teamsRailRow{kind: railRowNewIn})
+ }
+ rows = append(rows, teamsRailRow{kind: railRowOrganize})
+ if closed := a.teamsClosed(); len(closed) > 0 {
+ rows = append(rows, teamsRailRow{kind: railRowBlank}, teamsRailRow{kind: railRowClosed})
+ if a.tp.closedOpen {
+ for _, t := range closed {
+ rows = append(rows, teamsRailRow{kind: railRowClosedTeam, id: t.ID})
+ }
+ }
+ }
+ return rows
+}
+
+// teamsAny reports whether there is any team at all, closed ones included.
+func (a *app) teamsAny() bool {
+ for _, t := range a.wall.teams {
+ if !t.Root {
+ return true
+ }
+ }
+ _, root := a.teamsRoot()
+ return root
+}
+
+// teamsSelected is the team the pane is about, false for the `All teams` row
+// with no root and for nothing selected.
+func (a *app) teamsSelected() (team, bool) {
+ if a.tp.sel == "" || a.tp.sel == teamsAllRow {
+ return team{}, false
+ }
+ return a.teamByID(a.tp.sel)
+}
+
+// teamsSettle keeps the selection on something that exists: a team that went
+// is replaced by the team of the conversation in front, else the first open
+// team, else the root, else the `All teams` row, else nothing.
+func (a *app) teamsSettle() {
+ if a.tp.sel == teamsAllRow {
+ if _, root := a.teamsRoot(); !root && a.teamsAny() {
+ return
+ }
+ a.tp.sel = ""
+ }
+ if t, ok := a.teamByID(a.tp.sel); ok {
+ if !t.Closed() || a.tp.closedOpen {
+ return
+ }
+ }
+ a.tp.sel = ""
+ if t, ok := a.teamOfFront(); ok && !t.Closed() && !t.Root {
+ a.tp.sel = t.ID
+ return
+ }
+ if tree := a.teamsOpenTree(); len(tree) > 0 {
+ a.tp.sel = tree[0].id
+ return
+ }
+ if root, ok := a.teamsRoot(); ok {
+ a.tp.sel = root.ID
+ return
+ }
+ if a.teamsAny() {
+ a.tp.sel = teamsAllRow
+ }
+}
+
+// teamsManaged reports whether any open team has a manager, which is when the
+// page's clock turns: nothing else writes packets or spends a team's money.
+func (a *app) teamsManaged() bool {
+ for _, t := range a.wall.teams {
+ if t.Manager != "" && !t.Closed() {
+ return true
+ }
+ }
+ return false
+}
+
+// teamsSubtree reports whether id is sel or under it.
+func (a *app) teamsSubtree(sel, id string) bool {
+ if id == sel {
+ return true
+ }
+ for _, t := range a.teamAncestors(id) {
+ if t.ID == sel {
+ return true
+ }
+ }
+ return false
+}
+
+// teamsPickedIDs is the teams picked with `space`, in rail order, the ones
+// that went (closed, gone) left out.
+func (a *app) teamsPickedIDs() []string {
+ var out []string
+ for _, r := range a.teamsOpenTree() {
+ if a.tp.picked[r.id] {
+ out = append(out, r.id)
+ }
+ }
+ return out
+}
+
+// teamsPick picks team id on the rail with `space`, or unpicks it.
+func (a *app) teamsPick(id string) {
+ t, ok := a.teamByID(id)
+ if !ok || t.Root || t.Closed() {
+ return
+ }
+ if a.tp.picked == nil {
+ a.tp.picked = map[string]bool{}
+ }
+ if a.tp.picked[id] {
+ delete(a.tp.picked, id)
+ } else {
+ a.tp.picked[id] = true
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+}
+
+// teamsMoveIDs is what `Move into…` moves: the picked teams, else the selected
+// one, else nothing.
+func (a *app) teamsMoveIDs() []string {
+ if ids := a.teamsPickedIDs(); len(ids) > 0 {
+ return ids
+ }
+ if t, ok := a.teamsSelected(); ok && !t.Root && !t.Closed() {
+ return []string{t.ID}
+ }
+ return nil
+}
+
+// ── WHAT A TEAM IS DOING ────────────────────────────────────────────────────
+
+// teamsMemberState is one member as the page draws it: a word, whether it is
+// the needs-you amber, and when it last moved ("" unknown).
+type teamsMemberState struct {
+ word string
+ asking bool
+ open bool
+ at time.Time
+}
+
+// teamsMember reads one member's state from memory: this window's own signal
+// for a conversation it holds, and the world reading for one it does not.
+func (a *app) teamsMember(m teamMember) teamsMemberState {
+ row, known := a.tp.world[filepath.Clean(m.File)]
+ st := teamsMemberState{}
+ if known {
+ st.at = row.At
+ }
+ if a.trafficHeld(m.Key) {
+ st.open = true
+ switch a.tabSignalFor(m.Key, m.Key == a.frontTabKey()) {
+ case tabNeedsPerson:
+ st.word, st.asking = "asking", true
+ case tabWorking:
+ st.word = "running"
+ default:
+ st.word = "idle"
+ }
+ if st.word == "running" {
+ st.at = a.now()
+ }
+ return st
+ }
+ switch {
+ case known && row.NeedsPerson():
+ st.word, st.asking, st.open = "asking", true, true
+ case known && row.Live && row.Presence.State != "" && string(row.Presence.State) != "idle":
+ st.word, st.open = "running", true
+ case known && row.Open:
+ // Open in another window is a fact about that window, not the team:
+ // on this page it is idle like any other member at rest.
+ st.word, st.open = "idle", true
+ default:
+ st.word = "idle"
+ }
+ return st
+}
+
+// teamsNeeds is how many things in team id wait on the person: packets raised
+// from it (or under it) that wait on the person, and members asking.
+func (a *app) teamsNeeds(t team) int {
+ n := 0
+ for _, p := range a.tp.packets {
+ if p.Team == teamstore.Person && p.Waiting() && a.teamsSubtree(t.ID, p.Origin) {
+ n++
+ }
+ }
+ for _, m := range t.Members {
+ if a.teamsMember(m).asking {
+ n++
+ }
+ }
+ return n
+}
+
+// teamsWorking reports whether a member of t is running.
+func (a *app) teamsWorking(t team) bool {
+ for _, m := range t.Members {
+ if a.trafficHeld(m.Key) && a.tabSignalFor(m.Key, m.Key == a.frontTabKey()) == tabWorking {
+ return true
+ }
+ }
+ return false
+}
+
+// teamsInbox is the packets the pane's inbox shows for the selection, oldest
+// first so the newest is last: those waiting on the person that were raised in
+// it or under it (every one of them on the root and the `All teams` row), and
+// those waiting on its own manager.
+func (a *app) teamsInbox() []teamstore.Packet {
+ sel := a.tp.sel
+ var out []teamstore.Packet
+ root, hasRoot := a.teamsRoot()
+ all := sel == teamsAllRow || hasRoot && sel == root.ID
+ for _, p := range a.tp.packets {
+ if !p.Waiting() {
+ continue
+ }
+ switch {
+ case p.Team == teamstore.Person && (all || a.teamsSubtree(sel, p.Origin)):
+ out = append(out, p)
+ case p.Team == sel && sel != "":
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// teamsPrompts is every member of the selection whose conversation is stopped
+// on a question the person can answer from here: the session's own offer, read
+// off the world, never this window's own conversation in front (its card is in
+// the pane already).
+func (a *app) teamsPrompts(t team) []session.SessionRow {
+ var out []session.SessionRow
+ now := time.Now()
+ for _, m := range t.Members {
+ row, ok := a.tp.world[filepath.Clean(m.File)]
+ if !ok || a.answeringHere(row) {
+ continue
+ }
+ if _, ok := answerable(row, now); ok {
+ out = append(out, row)
+ }
+ }
+ return out
+}
+
+// ── THE SPEND AND THE CAP ───────────────────────────────────────────────────
+
+// teamsPool is whose spend sits beside team t's cap, and the effective
+// settings: the team itself with no cap or its own, the ancestor whose cap it
+// inherits, and the top of the chain when the cap is the profile's default. A
+// cap is a pool (DESIGN.md section 8.2), so the figure beside it is always the
+// pool owner's.
+func (a *app) teamsPool(t team) (string, teamstore.Effective) {
+ e := a.teamTree().Effective(t.ID, a.tp.defaults)
+ if e.CapUSDDay <= 0 {
+ return t.ID, e
+ }
+ switch e.CapFrom.Kind {
+ case teamstore.OriginTeam, teamstore.OriginAncestor:
+ return e.CapFrom.Team, e
+ }
+ top := t.ID
+ for _, up := range a.teamAncestors(t.ID) {
+ if !up.Closed() {
+ top = up.ID
+ }
+ }
+ return top, e
+}
+
+// ── THE READ ────────────────────────────────────────────────────────────────
+
+// teamsGot is what one read found, folded on the loop.
+type teamsGot struct {
+ packets []teamstore.Packet
+ packetsStamp string
+ packetsSame bool
+ packetsErr error
+ defaults teamstore.Defaults
+ defaultsOK bool
+ spend map[string]teamstore.Spend
+ spendStamp map[string]string
+ world map[string]session.SessionRow
+ worldKnown bool
+ worldAsked bool
+ history map[string][]teamstore.Packet
+}
+
+// THE PAGE HAS NO CLOCK OF ITS OWN. It answers the router's beat, which every
+// place but home answers ([placeTeams.tick]), and on that beat it reads the
+// store only while an open team has a manager: nothing else raises a packet
+// or spends a team's money, so a page of plain teams reads nothing at all.
+// Each read is stamped, so a quiet store answers `same` and the frame before
+// it stands.
+//
+// ONE READ IS OUT AT A TIME, AND NONE IS EVER DROPPED. A read asked for while
+// one is out (a team chosen while the beat's read is on the wire) is kept, and
+// made the moment the one out is folded, with what the page wants THEN: the
+// selection's own pool, not the pool of the team that was selected when the
+// first read left. It used to be dropped, and the spend of a team chosen in that
+// window waited for the next beat, or for ever on a page whose clock was not
+// turning.
+//
+// AND IT READS WHAT THE PAGE DRAWS, NOT THE MACHINE. The rows are the members'
+// of the open teams, twenty-five on a big machine: on this machine's disk they
+// are read by name ([session.ReadRows]), where the page used to walk every
+// session under the root on its opening and on every beat to find them, and
+// over a connection they are taken out of the world the window already holds
+// (tui3.go's [Options.World]), which costs nothing here. The store's doors are
+// asked side by side rather than one after another, because over --host each
+// of them is a round trip.
+
+// teamsRead asks the seam, beside the door line, for what the page draws: the
+// packets and the spend of the selection's pool (each with its stamp, so a
+// quiet file is answered `same`), the defaults, and the rows that say what a
+// member this window does not hold is doing. withWorld is false for a read a
+// gesture asked for, which needs only the store.
+func (a *app) teamsRead(withWorld bool) tea.Cmd {
+ if a.teamsOff() {
+ return nil
+ }
+ if a.tp.reading {
+ a.tp.again = true
+ a.tp.againWorld = a.tp.againWorld || withWorld
+ return nil
+ }
+ a.tp.reading = true
+ seam := a.teamsSeam()
+ packetsSince := a.tp.packetsStamp
+ var pools []string
+ var closedReports []string
+ if t, ok := a.teamsSelected(); ok {
+ if t.Closed() {
+ closedReports = append(closedReports, t.ID)
+ } else {
+ owner, _ := a.teamsPool(t)
+ pools = append(pools, owner)
+ if owner != t.ID {
+ pools = append(pools, t.ID)
+ }
+ }
+ }
+ spendSince := make([]string, len(pools))
+ for i, id := range pools {
+ spendSince[i] = a.tp.spendStamp[id]
+ }
+ var files []string
+ if withWorld {
+ files = a.teamsMemberFiles()
+ }
+ worldDoor, hosted, rowsDoor := a.world, a.hosted(), a.teamsDisk.rows
+ if rowsDoor == nil {
+ rowsDoor = session.ReadRows
+ }
+ return a.besideLine(func() func(bool) tea.Cmd {
+ got := teamsGot{spend: map[string]teamstore.Spend{}, spendStamp: map[string]string{}}
+ var wg sync.WaitGroup
+ side := func(read func()) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ read()
+ }()
+ }
+ type spendGot struct {
+ spend teamstore.Spend
+ stamp string
+ ok bool
+ }
+ spends := make([]spendGot, len(pools))
+ if seam.delegation() {
+ side(func() {
+ got.packets, got.packetsStamp, got.packetsSame, got.packetsErr = seam.Packets(teamstore.ScopeAll, packetsSince)
+ })
+ side(func() {
+ if d, err := seam.Defaults(); err == nil {
+ got.defaults, got.defaultsOK = d, true
+ }
+ })
+ for i, id := range pools {
+ side(func() {
+ s, stamp, same, err := seam.Spend(id, "", spendSince[i])
+ if err == nil && !same {
+ spends[i] = spendGot{spend: s, stamp: stamp, ok: true}
+ }
+ })
+ }
+ }
+ var histories [][]teamstore.Packet
+ if seam.History != nil && len(closedReports) > 0 {
+ histories = make([][]teamstore.Packet, len(closedReports))
+ for i, id := range closedReports {
+ side(func() {
+ if list, err := seam.History(id); err == nil {
+ histories[i] = list
+ }
+ })
+ }
+ }
+ if withWorld {
+ got.worldAsked = true
+ side(func() { got.world, got.worldKnown = teamsMemberRows(worldDoor, hosted, rowsDoor, files) })
+ }
+ wg.Wait()
+ for i, id := range pools {
+ if spends[i].ok {
+ got.spend[id], got.spendStamp[id] = spends[i].spend, spends[i].stamp
+ }
+ }
+ // ONLY THE HISTORIES THAT WERE ASKED FOR. A seam with no History door
+ // (an older engine over --host) asks for none, and a closed team is
+ // then shown without its record rather than indexing an empty list.
+ for i, list := range histories {
+ if list != nil {
+ if got.history == nil {
+ got.history = map[string][]teamstore.Packet{}
+ }
+ got.history[closedReports[i]] = list
+ }
+ }
+ return func(bool) tea.Cmd { return a.teamsFold(got) }
+ })
+}
+
+// teamsMemberFiles is every member transcript of the open teams, cleaned and
+// once each: what the page's rows are read for. Memory only.
+func (a *app) teamsMemberFiles() []string {
+ seen := map[string]bool{}
+ var files []string
+ for _, t := range a.wall.teams {
+ if t.Closed() {
+ continue
+ }
+ for _, m := range t.Members {
+ file := strings.TrimSpace(m.File)
+ if file == "" {
+ continue
+ }
+ file = filepath.Clean(file)
+ if !seen[file] {
+ seen[file] = true
+ files = append(files, file)
+ }
+ }
+ }
+ return files
+}
+
+// teamsMemberRows is the rows of files, keyed by cleaned transcript, and
+// whether that is an answer: out of the world a connection's door holds, none
+// at all over a connection with no such door (the rule [worldSeam] states),
+// and otherwise read by name off this machine's disk. It runs off the loop.
+func teamsMemberRows(door func() (session.World, bool), hosted bool,
+ read func([]string) map[string]session.SessionRow, files []string) (map[string]session.SessionRow, bool) {
+ switch {
+ case door != nil:
+ world, known := door()
+ if !known {
+ return nil, false
+ }
+ want := make(map[string]bool, len(files))
+ for _, f := range files {
+ want[f] = true
+ }
+ rows := map[string]session.SessionRow{}
+ for _, p := range world.Projects {
+ for _, row := range p.Sessions {
+ if key := strings.TrimSpace(row.Transcript); key != "" && want[filepath.Clean(key)] {
+ rows[filepath.Clean(key)] = row
+ }
+ }
+ }
+ return rows, true
+ case hosted:
+ return nil, false
+ }
+ rows := read(files)
+ if rows == nil {
+ rows = map[string]session.SessionRow{}
+ }
+ return rows, true
+}
+
+// teamsFold folds one read in. A read that found nothing new leaves the frame
+// before it standing: the clock costs a stat and draws nothing.
+func (a *app) teamsFold(got teamsGot) tea.Cmd {
+ a.tp.reading = false
+ changed := false
+ if got.packetsErr == nil && !got.packetsSame && got.packetsStamp != "" {
+ a.tp.packets, a.tp.packetsStamp, a.tp.packetsKnown = got.packets, got.packetsStamp, true
+ changed = true
+ }
+ if got.defaultsOK && (!a.tp.defaultsOK || got.defaults != a.tp.defaults) {
+ a.tp.defaults, a.tp.defaultsOK = got.defaults, true
+ changed = true
+ }
+ if len(got.spend) > 0 {
+ if a.tp.spend == nil {
+ a.tp.spend, a.tp.spendStamp = map[string]teamstore.Spend{}, map[string]string{}
+ }
+ for id, s := range got.spend {
+ a.tp.spend[id], a.tp.spendStamp[id] = s, got.spendStamp[id]
+ }
+ changed = true
+ }
+ if got.worldAsked && got.worldKnown && !teamsSameWorld(a.tp.world, got.world) {
+ a.tp.world, a.tp.worldKnown = got.world, true
+ changed = true
+ }
+ if len(got.history) > 0 {
+ if a.tp.history == nil {
+ a.tp.history = map[string][]teamstore.Packet{}
+ }
+ for id, list := range got.history {
+ a.tp.history[id] = list
+ }
+ changed = true
+ }
+ if changed {
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ } else {
+ a.ptr.still = a.drawn
+ }
+ // A read asked for while this one was out is made now, for the page as it
+ // stands now ([app.teamsRead]).
+ if a.tp.again {
+ world := a.tp.againWorld
+ a.tp.again, a.tp.againWorld = false, false
+ if a.at(pageTeams) {
+ return a.teamsRead(world)
+ }
+ }
+ return nil
+}
+
+// teamsSameWorld reports whether two readings say the same about every
+// conversation the page draws from them: its state, its question and when it
+// last moved.
+func teamsSameWorld(was, now map[string]session.SessionRow) bool {
+ if len(was) != len(now) {
+ return false
+ }
+ for k, r := range now {
+ o, ok := was[k]
+ if !ok || !o.At.Equal(r.At) || o.Open != r.Open || o.Live != r.Live ||
+ o.Presence.State != r.Presence.State || o.Presence.Question.ID != r.Presence.Question.ID ||
+ o.Presence.Question.Kind != r.Presence.Question.Kind {
+ return false
+ }
+ }
+ return true
+}
+
+// wallTeams is the teams the wall, its popovers and the strip's switcher list:
+// every open team but the `All teams` root, which is the switcher's own `All`
+// row and not a team a conversation is put in. A closed team is on the teams
+// page's `Closed` fold and nowhere else. It is the loaded list itself, with
+// nothing allocated, while no team is closed and there is no root.
+func (a *app) wallTeams() []team {
+ hidden := 0
+ for _, t := range a.wall.teams {
+ if t.Root || t.Closed() {
+ hidden++
+ }
+ }
+ if hidden == 0 {
+ return a.wall.teams
+ }
+ out := make([]team, 0, len(a.wall.teams)-hidden)
+ for _, t := range a.wall.teams {
+ if !t.Root && !t.Closed() {
+ out = append(out, t)
+ }
+ }
+ return out
+}
diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go
new file mode 100644
index 0000000000..9b8a741204
--- /dev/null
+++ b/internal/tui3/teamspage_test.go
@@ -0,0 +1,794 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── THE TEAMS PAGE'S LAB ────────────────────────────────────────────────────
+
+// teamsPlaceLab is the teams page over three conversations in two teams, orbit
+// nested under harbor, with harbor (the conversation in front) selected and no
+// manager yet, so the page draws the shared frame.
+func teamsPlaceLab(t *testing.T) *app {
+ t.Helper()
+ a, _, _ := teamsPlaceLabIDs(t)
+ return a
+}
+
+// teamsPlaceLabIDs is [teamsPlaceLab] with the two teams' ids.
+func teamsPlaceLabIDs(t *testing.T) (a *app, harbor, orbit string) {
+ t.Helper()
+ a, harbor, orbit = menuApp(t)
+ a.profileDir = t.TempDir()
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.SetParent(orbit, harbor) }); err != nil {
+ t.Fatal(err)
+ }
+ a.width, a.height = 120, 24
+ if cmd := a.showPage(pageTeams); cmd != nil {
+ drive(t, a, runCmd(cmd)...)
+ }
+ if !a.at(pageTeams) {
+ t.Fatal("the teams place did not open")
+ }
+ a.teamsSync()
+ a.frame()
+ return a, harbor, orbit
+}
+
+// teamsHits is, for each row of the last frame, the index of the target the
+// keyboard walks on it (the cursor's own where it is on that row), and -1 for
+// a row with none.
+func teamsHits(a *app) []int {
+ a.frame()
+ hits := make([]int, a.height)
+ for i := range hits {
+ hits[i] = -1
+ }
+ cur := a.teamsCursorIndex()
+ for i, tg := range a.tp.targets {
+ // The rail's rows, which the arrows walk as one list, and of those the
+ // ones a press at the left edge (where the shared tests press) lands on.
+ if tg.y < 0 || tg.y >= len(hits) || tg.pane || tg.x0 > 4 || tg.x1 <= 4 {
+ continue
+ }
+ if hits[tg.y] < 0 || i == cur {
+ hits[tg.y] = tg.line
+ }
+ }
+ return hits
+}
+
+// teamsCursorLine is the body line the keyboard is on, -1 for none.
+func teamsCursorLine(a *app) int {
+ if t, ok := a.teamsCursorTarget(); ok {
+ return t.line
+ }
+ return -1
+}
+
+// teamsFrameText is the whole frame, plain.
+func teamsFrameText(a *app) string {
+ f, _, _ := a.frame()
+ return plain(f)
+}
+
+// teamsTargetOf is the first drawn target of act (and id, when given).
+func teamsTargetOf(t *testing.T, a *app, act teamsAct, id string) teamsTarget {
+ t.Helper()
+ a.frame()
+ for _, tg := range a.tp.targets {
+ if tg.act == act && (id == "" || tg.id == id) {
+ return tg
+ }
+ }
+ t.Fatalf("no target %d %q on the frame:\n%s", act, id, teamsFrameText(a))
+ return teamsTarget{}
+}
+
+// ── the place ───────────────────────────────────────────────────────────────
+
+// TEAMS IS THE SECOND PLACE, right after home, on the bar and on the digits
+// (ruling c-2), and /teams opens it too.
+func TestTeamsIsTheSecondPlaceOnTheBarTheDigitsAndTheCommand(t *testing.T) {
+ if placeOrder[1] != pageTeams {
+ t.Fatalf("the second place is %q", placeOrder[1].word())
+ }
+ a := placeApp(t)
+ bar := navPlaces(a, 120, false)
+ if !placeWordsInOrder(bar, "home", "teams", "chats", "sessions") {
+ t.Fatalf("the bar does not put teams after home: %q", bar)
+ }
+ drive(t, a, key("alt+2"))
+ if !a.at(pageTeams) {
+ t.Fatalf("alt+2 landed on %q", a.page.word())
+ }
+ a.leavePlace()
+ typeLine(t, a, "/teams")
+ if !a.at(pageTeams) {
+ t.Fatalf("/teams landed on %q", a.page.word())
+ }
+}
+
+// NO TEAMS IS ONE SENTENCE AND TWO BUTTONS, and the organize button opens the
+// wall's proposal.
+func TestTeamsWithNoTeamsSaysWhatTheyAreAndOffersTwoWays(t *testing.T) {
+ a := placeApp(t)
+ a.width, a.height = 110, 24
+ drive(t, a, key("alt+2"))
+ text := teamsFrameText(a)
+ for _, want := range []string{"A team is a set of conversations", teamsOrganizeWord, teamsNewTeamWord} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the empty page lost %q:\n%s", want, text)
+ }
+ }
+}
+
+// THE RAIL IS THE TREE: orbit under harbor, indented, and the needs-you mark
+// only when a packet waits on the person.
+func TestTeamsRailIsTheTreeWithMarksOnlyWhenSomethingHappens(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ rail := func() []string {
+ lines := strings.Split(teamsFrameText(a), "\n")
+ for i, l := range lines {
+ if at := strings.Index(l, "│"); at >= 0 {
+ lines[i] = l[:at]
+ }
+ }
+ return lines
+ }
+ lines := rail()
+ hy, oy := -1, -1
+ for y, l := range lines {
+ // The head is skipped: the strip's team chip reads `● harbor ▾` on
+ // every page.
+ if y < placeHeadRows {
+ continue
+ }
+ if hy < 0 && strings.Contains(l, "harbor") {
+ hy = y
+ }
+ if oy < 0 && strings.Contains(l, "orbit") {
+ oy = y
+ }
+ }
+ if hy < 0 || oy <= hy {
+ t.Fatalf("harbor at %d, orbit at %d:\n%s", hy, oy, strings.Join(lines, "\n"))
+ }
+ if strings.Index(lines[oy], "orbit") <= strings.Index(lines[hy], "harbor") {
+ t.Fatalf("orbit is not indented under harbor:\n%s\n%s", lines[hy], lines[oy])
+ }
+ if strings.Contains(lines[hy], "?") || strings.Contains(lines[oy], "?") {
+ t.Fatalf("a quiet team carries a mark:\n%s", strings.Join(lines, "\n"))
+ }
+ a.tp.packets = []teamstore.Packet{{ID: "p1", Team: teamstore.Person, Origin: orbit,
+ Kind: teamstore.PacketQuestion, RaisedBy: "boss", Question: "Friday or Monday?",
+ State: teamstore.PacketOpen}}
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ lines = rail()
+ if !strings.Contains(lines[oy], "? 1") {
+ t.Fatalf("orbit does not say a packet waits on you:\n%s", lines[oy])
+ }
+ _ = harbor
+}
+
+// THE INBOX CARD DECIDES A PACKET with one press on an option's word.
+func TestTeamsInboxCardDecidesAPacket(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ flushTeams(t, a)
+ seam := a.teamsSeam()
+ p, err := seam.Raise(teamstore.Packet{Team: teamstore.Person, Origin: harbor,
+ Kind: teamstore.PacketConflict, RaisedBy: "boss", Question: "Which parser wins?",
+ Options: []teamstore.Option{{ID: "a", Label: "Keep the old one", Consequence: "no churn"},
+ {ID: "b", Label: "Take the new one", Consequence: "two files move"}},
+ Recommendation: &teamstore.Recommendation{Option: "b", Reason: "it is faster"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ drive(t, a, runCmd(a.teamsRead(false))...)
+ text := teamsFrameText(a)
+ for _, want := range []string{"Which parser wins?", "Keep the old one", "Take the new one", "recommended"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the card lost %q:\n%s", want, text)
+ }
+ }
+ var opt teamsTarget
+ for _, tg := range a.tp.targets {
+ if tg.act == teamsActOption && tg.arg == p.ID && tg.opt == "b" {
+ opt = tg
+ }
+ }
+ if opt.opt == "" {
+ t.Fatalf("no button for the recommended option:\n%s", text)
+ }
+ drive(t, a, runCmd(a.teamsDo(opt))...)
+ list, _, _, err := seam.Packets(teamstore.ScopeAll, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, q := range list {
+ if q.ID == p.ID && q.Waiting() {
+ t.Fatalf("the press did not decide the packet: %+v", q)
+ }
+ }
+}
+
+// flushTeams writes the lab's in-memory edits to its profile.
+func flushTeams(t *testing.T, a *app) {
+ t.Helper()
+ if cmd := a.teamsWrite(); cmd != nil {
+ drive(t, a, runCmd(cmd)...)
+ }
+}
+
+// teamsHostedLab is the lab with harbor's manager made of the conversation in
+// front, so the pane hosts it.
+func teamsHostedLab(t *testing.T) (a *app, harbor, orbit string) {
+ t.Helper()
+ a, harbor, orbit = teamsPlaceLabIDs(t)
+ for _, tab := range a.tabList() {
+ if tab.key == a.frontTabKey() {
+ if err := a.teamMakeManager(harbor, tab); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ drive(t, a, key("alt+1"))
+ drive(t, a, key("alt+2"))
+ if !a.teamsHosting() {
+ t.Fatalf("the pane does not host harbor's manager:\n%s", teamsFrameText(a))
+ }
+ return a, harbor, orbit
+}
+
+// THE PANE IS THE MANAGER'S REAL CONVERSATION: the bar still says teams, the
+// rail stands on the left, the composer says whom it talks to, and a letter
+// typed lands in the manager's own box.
+func TestTeamsHostsTheManagersRealConversation(t *testing.T) {
+ a, _, _ := teamsHostedLab(t)
+ lines := strings.Split(teamsFrameText(a), "\n")
+ if len(lines) != a.height {
+ t.Fatalf("the hosted frame has %d rows, want %d", len(lines), a.height)
+ }
+ if !strings.Contains(lines[navRow], "teams") {
+ t.Fatalf("the nav does not say teams:\n%s", strings.Join(lines, "\n"))
+ }
+ w, _ := a.size()
+ if w != a.width-a.tp.railW {
+ t.Fatalf("the hosted conversation is %d wide, want %d", w, a.width-a.tp.railW)
+ }
+ text := strings.Join(lines, "\n")
+ for _, want := range []string{"All teams", "harbor", "orbit", "Settings", "Close…"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the hosted page lost %q:\n%s", want, text)
+ }
+ }
+ drive(t, a, key("h"), key("i"))
+ if got := a.input.String(); got != "hi" {
+ t.Fatalf("typing on the hosted page put %q in the manager's box:\n%s", got, teamsFrameText(a))
+ }
+ if !a.at(pageTeams) {
+ t.Fatalf("typing left the page for %q", a.page.word())
+ }
+ // alt+↓ puts the keyboard on the page's buttons, and esc gives it back.
+ drive(t, a, tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModAlt})
+ if !a.tp.focus {
+ t.Fatal("alt+↓ did not put the keyboard on the page")
+ }
+ drive(t, a, key("esc"))
+ if a.tp.focus || !a.at(pageTeams) {
+ t.Fatalf("esc did not give the keyboard back (focus %v, page %q)", a.tp.focus, a.page.word())
+ }
+ // And tab still walks the places.
+ drive(t, a, key("tab"))
+ if a.at(pageTeams) {
+ t.Fatal("tab on the hosted page did not walk on")
+ }
+}
+
+// ── the team's card ─────────────────────────────────────────────────────────
+
+// THE CARD SAYS WHERE EVERY VALUE COMES FROM: an inherited one dim with
+// `· from Settings` or `· from `, an override in ink with `reset`, and
+// reset gives the value back to what it inherits.
+func TestTeamsCardShowsProvenanceAndResets(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ five := 5.0
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ return f.SetSettings(harbor, func(s *teamstore.Settings) { s.CapUSDDay = &five })
+ }); err != nil {
+ t.Fatal(err)
+ }
+ drive(t, a, runCmd(a.teamSheetOpen(orbit, teamSheetSettings))...)
+ if !a.tsheet.on {
+ t.Fatal("the card did not open")
+ }
+ text := teamsFrameText(a)
+ for _, want := range []string{"from Settings", "from harbor", "$5.00 a day"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the card lost %q:\n%s", want, text)
+ }
+ }
+ if strings.Contains(text, "reset") {
+ t.Fatalf("a card with no override offers reset:\n%s", text)
+ }
+ a.teamSheetSave(tsDepth, "2")
+ if got, _ := a.teamByID(orbit); got.Settings.DepthLimit == nil || *got.Settings.DepthLimit != 2 {
+ t.Fatalf("the depth was not kept on orbit: %+v", got.Settings)
+ }
+ if text = teamsFrameText(a); !strings.Contains(text, "reset") || !strings.Contains(text, "2 levels") {
+ t.Fatalf("an override does not offer reset:\n%s", text)
+ }
+ a.teamSheetDo(tsDepth + tsReset)
+ if got, _ := a.teamByID(orbit); got.Settings.DepthLimit != nil {
+ t.Fatalf("reset left the override: %+v", got.Settings)
+ }
+ // A value out of its band is refused in the card's own words.
+ a.teamSheetSave(tsShare, "140")
+ if a.tsheet.err == "" {
+ t.Fatal("a share of 140% was taken")
+ }
+}
+
+// ── closing ─────────────────────────────────────────────────────────────────
+
+// NOTHING RUNNING IS ONE CLOSE AND AN UNDO; the team moves to Closed and Undo
+// puts it back.
+func TestTeamsCloseWithNothingRunningIsOneClickAndUndo(t *testing.T) {
+ a, _, orbit := teamsPlaceLabIDs(t)
+ drive(t, a, runCmd(a.teamsCloseAsk(orbit))...)
+ if a.tsheet.on {
+ t.Fatal("a quiet team asked before closing")
+ }
+ if got, _ := a.teamByID(orbit); !got.Closed() {
+ t.Fatal("orbit did not close")
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "Undo") || !strings.Contains(text, "Closed · 1") {
+ t.Fatalf("the close offers no Undo or no Closed fold:\n%s", text)
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActUndo, "")))...)
+ if got, _ := a.teamByID(orbit); got.Closed() {
+ t.Fatal("Undo did not reopen orbit")
+ }
+}
+
+// SOMETHING RUNNING PUTS UP THE CARD: `Close now` first when no manager runs
+// the team, `Wrap up first` first when one does, and Cancel changes nothing.
+func TestTeamsCloseCardOffersWrapUpNowAndCancel(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ a.state = stateWorking
+ drive(t, a, runCmd(a.teamsCloseAsk(harbor))...)
+ if !a.tsheet.on || a.tsheet.mode != teamSheetClose || a.tsheet.cursor != tsCloseNow {
+ t.Fatalf("the card for a team with no manager: %+v", a.tsheet)
+ }
+ text := teamsFrameText(a)
+ if strings.Contains(text, "Wrap up first") || !strings.Contains(text, "Close now") || !strings.Contains(text, "Cancel") {
+ t.Fatalf("the card with no manager:\n%s", text)
+ }
+ a.teamSheetKey(key("esc"))
+ if got, _ := a.teamByID(harbor); a.tsheet.on || got.Closed() {
+ t.Fatal("Cancel closed the team or left the card up")
+ }
+ for _, tab := range a.tabList() {
+ if tab.key == a.frontTabKey() {
+ if err := a.teamMakeManager(harbor, tab); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ drive(t, a, runCmd(a.teamsCloseAsk(harbor))...)
+ if !a.tsheet.on || a.tsheet.cursor != tsWrapUp {
+ t.Fatalf("a managed team's card does not lead with Wrap up first: %+v", a.tsheet)
+ }
+ drive(t, a, runCmd(a.teamSheetDo(tsWrapUp))...)
+ if got, _ := a.teamByID(harbor); got.Closed() {
+ t.Fatal("Wrap up first closed the team at once")
+ }
+ if !strings.Contains(a.tp.msg, "wrap up") {
+ t.Fatalf("the wrap-up said nothing: %q", a.tp.msg)
+ }
+ drive(t, a, runCmd(a.teamsCloseAsk(harbor))...)
+ drive(t, a, runCmd(a.teamSheetDo(tsCloseNow))...)
+ if got, _ := a.teamByID(harbor); !got.Closed() {
+ t.Fatal("Close now did not close the team")
+ }
+}
+
+// THE CLOSED FOLD shows a closed team's report, members and dates with
+// Reopen and Delete…, and a team under a closed parent offers to reopen the
+// parent too.
+func TestTeamsClosedFoldReopensWithItsParent(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ drive(t, a, runCmd(a.teamsCloseAsk(harbor))...)
+ if got, _ := a.teamByID(orbit); !got.Closed() {
+ t.Fatal("closing the parent left the sub-team open")
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActClosedFold, "")))...)
+ drive(t, a, runCmd(a.teamsSelect(orbit))...)
+ text := teamsFrameText(a)
+ for _, want := range []string{"Reopen harbor too", "Delete…", "closed"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the closed sub-team lost %q:\n%s", want, text)
+ }
+ }
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActReopenParent, orbit)))...)
+ for _, id := range []string{harbor, orbit} {
+ if got, _ := a.teamByID(id); got.Closed() {
+ t.Fatalf("%s is still closed", got.Name)
+ }
+ }
+}
+
+// ── members ─────────────────────────────────────────────────────────────────
+
+// A MEMBER THIS WINDOW DOES NOT HOLD IS RESUMED BEHIND, in its own tab, and
+// the person stays where they are.
+func TestTeamsMemberPressResumesItBehind(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ far := "/tmp/lab/far-away.jsonl"
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ return f.AddMember(harbor, teamstore.Member{Key: a.convKey(far), File: far, Where: "/tmp/lab", Word: "far", Handle: "far"})
+ }); err != nil {
+ t.Fatal(err)
+ }
+ opened := ""
+ a.open = func(where, file string) (Conversation, error) {
+ opened = file
+ return Conversation{Agent: &fakeAgent{model: "m"}, Workspace: where, SessionFile: file}, nil
+ }
+ front := a.frontTabKey()
+ // An idle member is not on the header; the members card lists it, with
+ // `Resume` for one this window does not hold, and never says `not open`.
+ drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActCrew, harbor)))...)
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "@far") || !strings.Contains(text, "Resume") || strings.Contains(text, "not open") {
+ t.Fatalf("the members card does not list the member this window does not hold:\n%s", text)
+ }
+ var member teamsCrewRow
+ for _, r := range a.teamCrewRows() {
+ if r.key == a.convKey(far) {
+ member = r
+ }
+ }
+ if member.key == "" {
+ t.Fatalf("no row for @far:\n%s", teamsFrameText(a))
+ }
+ drive(t, a, runCmd(a.teamCrewGo(member))...)
+ if opened != far {
+ t.Fatalf("the press opened %q", opened)
+ }
+ if a.frontTabKey() != front || !a.at(pageTeams) {
+ t.Fatalf("the resume moved the person: front %q page %q", a.frontTabKey(), a.page.word())
+ }
+ if !a.trafficHeld(a.convKey(far)) {
+ t.Fatal("the member is not held behind")
+ }
+}
+
+// ── over --host ─────────────────────────────────────────────────────────────
+
+// OVER --host THE SETTINGS TEAMS TAB SAYS WHOSE DEFAULTS THE TEAMS READ and
+// does not edit this machine's.
+func TestTeamsSettingsTabOverHostIsReadOnlyAndSaysWhose(t *testing.T) {
+ a := placeApp(t)
+ a.host = "spark"
+ drive(t, a, key(placeChord(pageSettings)))
+ for i, title := range settingTabs {
+ if title == tabTeams {
+ a.sheet.tab = i
+ }
+ }
+ a.sheet.build()
+ if note := a.sheet.footNote(); !strings.Contains(note, "on spark") {
+ t.Fatalf("the Teams tab over --host says %q", note)
+ }
+ before := a.sheet.items[a.sheet.cursor].row.Value()
+ drive(t, a, key("enter"))
+ if a.sheet.edit != nil || !strings.Contains(a.sheet.msg, "spark") {
+ t.Fatalf("a Teams row took an edit over --host (msg %q)", a.sheet.msg)
+ }
+ if after := a.sheet.items[a.sheet.cursor].row.Value(); after != before {
+ t.Fatalf("the row changed from %q to %q", before, after)
+ }
+ a.host = ""
+ a.sheet.host = ""
+ if note := a.sheet.footNote(); !strings.Contains(note, "a team can override any of these on its card") {
+ t.Fatalf("the Teams tab says %q", note)
+ }
+}
+
+// ── organize ────────────────────────────────────────────────────────────────
+
+// ORGANIZE OFFERS TO CLOSE THE QUIET TEAMS, ticked like every suggestion,
+// never on its own, and Undo reopens them.
+func TestOrganizeOffersToCloseQuietTeamsWithUndo(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ old := a.now().Add(-10 * 24 * time.Hour)
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ for i := range f.Teams {
+ f.Teams[i].Made = old
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ flushTeams(t, a)
+ _ = a.openWall()
+ a.wallSetTeam("")
+ drive(t, a, runCmd(a.wallOrganizeOpen())...)
+ var quiet *orgProp
+ for i := range a.wall.org.props {
+ if a.wall.org.props[i].team == orgCloseRow {
+ quiet = &a.wall.org.props[i]
+ }
+ }
+ if quiet == nil || len(quiet.closes) != 2 || quiet.name != "Close 2 quiet teams" {
+ t.Fatalf("Organize did not offer the quiet teams: %+v", a.wall.org.props)
+ }
+ // Its row is its sentence whole, then the teams: no colour dot and no
+ // second count, and not cut to a team name's width.
+ a.width, a.height = 110, 30
+ frame := wallPlainFrame(a.wallFrame(a.width, a.height))
+ if !strings.Contains(frame, "☑ Close 2 quiet teams harbor, orbit") {
+ t.Fatalf("the quiet-teams row reads:\n%s", frame)
+ }
+ for _, id := range []string{harbor, orbit} {
+ if got, _ := a.teamByID(id); got.Closed() {
+ t.Fatal("a suggestion closed a team before Apply")
+ }
+ }
+ a.wallOrganizeApply()
+ for _, id := range []string{harbor, orbit} {
+ if got, _ := a.teamByID(id); !got.Closed() {
+ t.Fatalf("Apply left %s open", got.Name)
+ }
+ }
+ a.wallOrganizeUndo()
+ for _, id := range []string{harbor, orbit} {
+ if got, _ := a.teamByID(id); got.Closed() {
+ t.Fatalf("Undo left %s closed", got.Name)
+ }
+ }
+}
+
+// ── the session's contract (DESIGN.md 8.8) ──────────────────────────────────
+
+// WRAP UP FIRST IS THE ONE REQUEST LINE in the team's Traffic, which the
+// manager's session reads; the team stays open until its report is accepted,
+// and accepting it closes the team on its report.
+func TestTeamsWrapUpAsksTheManagerAndTheReportCloses(t *testing.T) {
+ a, harbor, _ := teamsHostedLab(t)
+ flushTeams(t, a)
+ a.state = stateWorking
+ drive(t, a, runCmd(a.teamsCloseAsk(harbor))...)
+ if a.tsheet.cursor != tsWrapUp {
+ t.Fatalf("the card does not lead with Wrap up first: %+v", a.tsheet)
+ }
+ drive(t, a, runCmd(a.teamSheetDo(tsWrapUp))...)
+ log, err := teamstore.ReadTraffic(a.profileDir, harbor, "", 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ asked := false
+ for _, e := range log {
+ asked = asked || teamstore.IsWrapUp(e)
+ }
+ if !asked {
+ t.Fatalf("no wrap-up request in harbor's Traffic: %+v", log)
+ }
+ a.state = stateIdle
+ seam := a.teamsSeam()
+ p, err := seam.Raise(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 on this report"},
+ {ID: teamstore.OptionKeepGoing, Label: "Keep going", Consequence: "the team goes on"}},
+ Recommendation: &teamstore.Recommendation{Option: teamstore.OptionClose, Reason: "everything is committed"},
+ Report: &teamstore.ClosingReport{Done: "the parser ships", Left: "the docs", SpendUSD: 1.5}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ drive(t, a, runCmd(a.teamsRead(false))...)
+ text := teamsFrameText(a)
+ for _, want := range []string{"close harbor?", "the parser ships", "the docs", "Keep going"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the closing report lost %q:\n%s", want, text)
+ }
+ }
+ var closeBtn teamsTarget
+ for _, tg := range a.tp.targets {
+ if tg.act == teamsActOption && tg.arg == p.ID && tg.opt == teamstore.OptionClose {
+ closeBtn = tg
+ }
+ }
+ drive(t, a, runCmd(a.teamsDo(closeBtn))...)
+ if got, _ := a.teamByID(harbor); !got.Closed() || got.Report != p.ID {
+ t.Fatalf("accepting the report did not close harbor on it: %+v", got)
+ }
+}
+
+// A CAP PACKET SAYS ITS FIGURES, and `Raise to $X` is the decision alone: the
+// session lifts the day's ceiling, so the team's own cap is not rewritten.
+func TestTeamsCapPacketSaysItsFiguresAndRaisingWritesNoSetting(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ flushTeams(t, a)
+ p, err := a.teamsSeam().Raise(teamstore.Packet{Team: teamstore.Person, Origin: harbor, Kind: teamstore.PacketCap,
+ RaisedBy: teamstore.FromManager, Question: "harbor reached its $5 cap today",
+ Options: []teamstore.Option{{ID: teamstore.OptionRaiseCap, Label: "Raise to $10", Consequence: "harbor may spend $10 today"},
+ {ID: teamstore.OptionStopToday, Label: "Stop for today", Consequence: "nothing new starts until tomorrow"}},
+ Recommendation: &teamstore.Recommendation{Option: teamstore.OptionStopToday, Reason: "the day is nearly done"},
+ Cap: &teamstore.CapFacts{Team: harbor, Day: teamstore.Today(), CapUSD: 5, SpentUSD: 5.2, RaiseTo: 10}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the cap card lost %q:\n%s", want, text)
+ }
+ }
+ var raise teamsTarget
+ for _, tg := range a.tp.targets {
+ if tg.act == teamsActOption && tg.arg == p.ID && tg.opt == teamstore.OptionRaiseCap {
+ raise = tg
+ }
+ }
+ drive(t, a, runCmd(a.teamsDo(raise))...)
+ got, err := teamstore.PacketByID(a.profileDir, p.ID)
+ if err != nil || got.Decision != teamstore.OptionRaiseCap || got.DecidedBy != teamstore.Person {
+ t.Fatalf("the raise was not decided by the person: %+v %v", got, err)
+ }
+ if team, _ := a.teamByID(harbor); team.Settings.CapUSDDay != nil {
+ t.Fatalf("the raise rewrote harbor's cap: %+v", team.Settings)
+ }
+}
+
+// WAKE IS ON THE CARD with where it comes from, and a shared member says whose
+// manager it reports to.
+func TestTeamsCardShowsWakeAndASharedMemberSaysWhoseItIs(t *testing.T) {
+ a, harbor, orbit := teamsHostedLab(t)
+ drive(t, a, runCmd(a.teamSheetOpen(harbor, teamSheetSettings))...)
+ if text := teamsFrameText(a); !strings.Contains(text, "team messages wake") {
+ t.Fatalf("the card has no wake row:\n%s", text)
+ }
+ a.teamSheetDo(tsWake)
+ if got, _ := a.teamByID(harbor); got.Settings.Wake == nil {
+ t.Fatal("the wake row did not override")
+ }
+ a.tsheet = teamSheet{}
+ // orbit's member, made a member of harbor too, keeps orbit as its home.
+ o, _ := a.teamByID(orbit)
+ m := o.Members[0]
+ if err := a.teamEdit(func(f *teamstore.File) error {
+ if err := f.AddMember(harbor, teamstore.Member{Key: m.Key, File: m.File, Where: m.Where, Word: m.Word, Handle: m.Handle}); err != nil {
+ return err
+ }
+ if err := f.AddMember(orbit, teamstore.Member{Key: "orbit-boss", Handle: "oboss", Word: "orbit's boss"}); err != nil {
+ return err
+ }
+ if err := f.SetManager(orbit, "orbit-boss"); err != nil {
+ return err
+ }
+ return f.SetHome(m.Key, orbit)
+ }); err != nil {
+ t.Fatal(err)
+ }
+ a.tp.top = teamsTopCache{}
+ // It says so on the members card, as a quiet tag whose hint names the
+ // manager, never as prose on the header.
+ if text := teamsFrameText(a); strings.Contains(text, "reports to orbit") {
+ t.Fatalf("the header still says whose the shared member is in prose:\n%s", text)
+ }
+ drive(t, a, runCmd(a.teamCrewOpen(harbor))...)
+ if text := teamsFrameText(a); !strings.Contains(text, "also in orbit") {
+ t.Fatalf("the members card does not tag the shared member:\n%s", text)
+ }
+ for i, r := range a.teamCrewRows() {
+ if r.key == m.Key {
+ a.tcrew.cursor = i
+ }
+ }
+ if hint := a.teamCrewHint(); !strings.Contains(hint, "reports to orbit's manager") {
+ t.Fatalf("the shared member's hint does not say whose it is: %q", hint)
+ }
+}
+
+// BESIDE THE MANAGER THE INBOX LEAVES THE CONVERSATION ROOM. Three packets on
+// a laptop's 34 rows used to take the whole pane, leaving the manager's chat
+// one row; now the newest card is whole, the older ones fold to one line each
+// that still says whose they are, and a press unfolds one. Each card leads
+// with whose it is: the needs-you `?`, never the manager's mark.
+func TestTeamsHostedInboxLeavesTheConversationRoom(t *testing.T) {
+ a, harbor, _ := teamsHostedLab(t)
+ a.width, a.height = 110, 34
+ flushTeams(t, a)
+ seam := a.teamsSeam()
+ raise := func(kind, q string) teamstore.Packet {
+ p, err := seam.Raise(teamstore.Packet{Team: teamstore.Person, Origin: harbor, Kind: kind, RaisedBy: "boss", Question: q,
+ Options: []teamstore.Option{{ID: "a", Label: "One way", Consequence: "this happens"}, {ID: "b", Label: "The other", Consequence: "that happens"}},
+ Recommendation: &teamstore.Recommendation{Option: "b", Reason: "it is cheaper"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p
+ }
+ first := raise(teamstore.PacketQuestion, "Ship on Friday or Monday?")
+ raise(teamstore.PacketConflict, "Which parser wins?")
+ raise(teamstore.PacketQuestion, "Keep the old field names?")
+ drive(t, a, runCmd(a.teamsRead(false))...)
+ a.teamsSync()
+ if top := a.teamsHostTopHeight(); top > a.height/2 {
+ t.Fatalf("the inbox takes %d of %d rows over the manager's chat:\n%s", top, a.height, teamsFrameText(a))
+ }
+ text := teamsFrameText(a)
+ if !strings.Contains(text, "Keep the old field names?") || !strings.Contains(text, "The other") {
+ t.Fatalf("the newest card is not whole:\n%s", text)
+ }
+ lines := strings.Split(text, "\n")
+ folded := 0
+ for _, l := range lines {
+ if strings.Contains(l, "▸ question · Ship on Friday") || strings.Contains(l, "▸ conflict · Which parser") {
+ folded++
+ if !strings.Contains(l, "waiting on you") {
+ t.Fatalf("a folded card does not say it waits on you: %q", l)
+ }
+ }
+ if strings.Contains(l, teamManagerGlyph+" question") || strings.Contains(l, teamManagerGlyph+" conflict") {
+ t.Fatalf("a card waiting on the person leads with the manager's mark: %q", l)
+ }
+ }
+ if folded != 2 {
+ t.Fatalf("%d cards folded, want 2:\n%s", folded, text)
+ }
+ if !strings.Contains(text, "? question · raised by @boss") {
+ t.Fatalf("the whole card does not lead with the needs-you mark:\n%s", text)
+ }
+ // A press on a folded card unfolds it and keeps the budget.
+ var fold teamsTarget
+ for _, tg := range a.tp.targets {
+ if tg.act == teamsActOption && tg.arg == first.ID && tg.opt == "" {
+ fold = tg
+ }
+ }
+ drive(t, a, runCmd(a.teamsDo(fold))...)
+ if text := teamsFrameText(a); !strings.Contains(text, "One way") || !strings.Contains(text, "Ship on Friday or Monday?") {
+ t.Fatalf("the press did not unfold the oldest card:\n%s", text)
+ }
+ if top := a.teamsHostTopHeight(); top > a.height/2 {
+ t.Fatalf("an unfolded card let the inbox take %d of %d rows", top, a.height)
+ }
+}
+
+// THE MEMBERS CARD COUNTS WHAT THE HEADER COUNTS: `◆ Manager 1 member` on the
+// header is `◆ Manager · 1 member` on the card, never `2 members`.
+func TestTeamsMembersCardCountsLikeTheHeader(t *testing.T) {
+ a, harbor, _ := teamsHostedLab(t)
+ head := teamsFrameText(a)
+ if !strings.Contains(head, "1 member") {
+ t.Fatalf("the header's count:\n%s", head)
+ }
+ drive(t, a, runCmd(a.teamCrewOpen(harbor))...)
+ if text := teamsFrameText(a); !strings.Contains(text, "harbor · "+teamManagerGlyph+" Manager · 1 member") {
+ t.Fatalf("the card's title does not count like the header:\n%s", text)
+ }
+}
+
+// A TEAM'S CARD STANDS OVER THE PANE, so the rail beside it still says which
+// team is selected.
+func TestTeamsCardsStandOverThePane(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ a.width, a.height = 110, 34
+ a.teamsSync()
+ drive(t, a, runCmd(a.teamSheetOpen(harbor, teamSheetSettings))...)
+ teamsFrameText(a)
+ if rail := teamsRailCols(a.width); a.tsheet.rect.x0 < rail {
+ t.Fatalf("the settings card starts at %d, over the rail's %d columns:\n%s", a.tsheet.rect.x0, rail, teamsFrameText(a))
+ }
+}
diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go
new file mode 100644
index 0000000000..aa4514a65b
--- /dev/null
+++ b/internal/tui3/teamspagedraw.go
@@ -0,0 +1,1031 @@
+package tui3
+
+import (
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/x/ansi"
+
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+ "github.com/Agent-Field/codeaf/internal/tui2/tokens"
+)
+
+// ── DRAWING THE TEAMS PAGE (teamspage.go says what it is) ──────────────────
+//
+// Every function here reads memory and nothing else, and records where each
+// pressable thing landed ([teamsTarget]) so the pointer and the keyboard act on
+// what was drawn. A target is lit by name ([teamsRef]): the pointer's ground
+// when it is under the pointer, the cursor's when the keyboard is on it.
+
+// teamsRailCols is the rail's columns at width, its separator column
+// included, and 0 under [teamsRailFloor].
+func teamsRailCols(width int) int {
+ if width < teamsRailFloor {
+ return 0
+ }
+ return min(max(width/5, teamsRailMin), teamsRailMax)
+}
+
+// teamsDraw collects the targets of one frame as it is drawn.
+type teamsDraw struct {
+ a *app
+ targets []teamsTarget
+}
+
+// lit reports whether the target named r wears a ground, and which.
+func (d *teamsDraw) lit(r teamsRef) (hot, cur bool) {
+ tp := &d.a.tp
+ cur = tp.focus && tp.cur == r
+ hot = tp.hot == r
+ return hot, cur
+}
+
+// button paints one word button: the word with a cell of air either side, on
+// the pointer's ground or the cursor's, and records it at x0 on row y.
+func (d *teamsDraw) button(word string, t teamsTarget, ink func(string) string) (string, int) {
+ pal := d.a.pal
+ chip := " " + word + " "
+ w := ansi.StringWidth(chip)
+ t.x1 = t.x0 + w
+ d.targets = append(d.targets, t)
+ hot, cur := d.lit(t.ref())
+ switch {
+ case cur:
+ return pal.selected(pal.bold(pal.ink(chip)), w), w
+ case hot:
+ return pal.cursor(pal.ink(chip), w), w
+ }
+ return ink(chip), w
+}
+
+// row paints a whole-row target of width cells: its text, grounded when lit.
+func (d *teamsDraw) row(text string, width int, t teamsTarget, selected bool) string {
+ pal := d.a.pal
+ t.x1 = t.x0 + width
+ d.targets = append(d.targets, t)
+ text = fit(text, width)
+ text += strings.Repeat(" ", max(width-ansi.StringWidth(text), 0))
+ hot, cur := d.lit(t.ref())
+ switch {
+ case cur:
+ return pal.cursor(text, width)
+ case selected:
+ return pal.selected(text, width)
+ case hot:
+ return pal.cursor(text, width)
+ }
+ return text
+}
+
+// shift moves the targets recorded from index from by dx and dy.
+func (d *teamsDraw) shift(from, dx, dy int) {
+ for i := from; i < len(d.targets); i++ {
+ d.targets[i].x0 += dx
+ d.targets[i].x1 += dx
+ d.targets[i].y += dy
+ }
+}
+
+// pad is s padded or cut to exactly width cells.
+func teamsPad(s string, width int) string {
+ if width <= 0 {
+ return ""
+ }
+ s = fit(s, width)
+ return s + strings.Repeat(" ", max(width-ansi.StringWidth(s), 0))
+}
+
+// ── THE RAIL ────────────────────────────────────────────────────────────────
+
+// teamsRail is the rail, height rows of width cells (the separator not
+// included), with its targets on rows counted from its first.
+func (a *app) teamsRail(d *teamsDraw, width, height int) []string {
+ pal := a.pal
+ rows := a.teamsRailRows()
+ // THE CLOSED FOLD STANDS AT THE FOOT, where a person looks for what is put
+ // away; everything above it is the tree and its two doors.
+ split := len(rows)
+ for i, r := range rows {
+ if r.kind == railRowClosed {
+ split = i - 1
+ break
+ }
+ }
+ headRows, tailRows := rows[:split], rows[split:]
+ if len(headRows)+len(tailRows) > height {
+ tailRows = tailRows[:max(0, min(len(tailRows), height-len(headRows)))]
+ }
+ out := make([]string, 0, height)
+ topSaid := false
+ paint := func(r teamsRailRow, y int) string {
+ switch r.kind {
+ case railRowAll:
+ return a.teamsRailAll(d, width, y)
+ case railRowTeam:
+ t, _ := a.teamByID(r.id)
+ return a.teamsRailTeam(d, t, r.depth, width, y)
+ case railRowNew:
+ return a.teamsRailNew(d, width, y, false)
+ case railRowNewIn:
+ return a.teamsRailNew(d, width, y, true)
+ case railRowBlank:
+ // DURING A TEAM DRAG THE FIRST BLANK UNDER THE TREE SAYS WHAT IT IS:
+ // the top level, where a drop on the empty rail puts the team.
+ if a.tdrag.on && !a.tdrag.member && !topSaid {
+ topSaid = true
+ word := " " + a.linearMark("↳", "->") + " Top level"
+ if a.teamDropLit(teamMoveTop) {
+ return a.pal.cursor(teamsPad(a.pal.ink(word), width), width)
+ }
+ return teamsPad(a.pal.dim(word), width)
+ }
+ case railRowOrganize:
+ return d.row(" "+a.teamsSpark()+" Organize", width, teamsTarget{act: teamsActOrganize, y: y,
+ hint: "Suggest teams for your conversations, and close quiet ones" + hintSegment + "o"}, false)
+ case railRowClosed:
+ fold := bandFoldGlyph
+ if a.tp.closedOpen {
+ fold = a.linearMark("▾", "v")
+ }
+ word := " " + fold + " Closed " + a.teamsDot() + " " + itoa(len(a.teamsClosed()))
+ hint := "Show the closed teams"
+ if a.tp.closedOpen {
+ hint = "Fold the closed teams away"
+ }
+ return d.row(pal.muted(word), width, teamsTarget{act: teamsActClosedFold, y: y, hint: hint + hintSegment + "enter"}, false)
+ case railRowClosedTeam:
+ t, _ := a.teamByID(r.id)
+ return d.row(" "+pal.dim(t.Name), width, teamsTarget{act: teamsActSelect, id: t.ID, y: y,
+ hint: t.Name + " closed" + hintSegment + "its report, members, Reopen and Delete"}, a.tp.sel == t.ID)
+ }
+ return strings.Repeat(" ", width)
+ }
+ for _, r := range headRows {
+ out = append(out, paint(r, len(out)))
+ }
+ for len(out)+len(tailRows) < height {
+ out = append(out, strings.Repeat(" ", width))
+ }
+ for _, r := range tailRows {
+ out = append(out, paint(r, len(out)))
+ }
+ return out[:min(len(out), height)]
+}
+
+// teamsDot is the middle dot in this terminal's glyphs.
+func (a *app) teamsDot() string { return a.linearMark("·", "-") }
+
+// teamsSpark is Organize's mark.
+func (a *app) teamsSpark() string {
+ spark, _ := wallOrgMarks(a.pal)
+ return spark
+}
+
+// teamsRailAll is the `All teams` row: the root team when there is one, and
+// otherwise a row over the top level carrying `+ Manager`, the optional
+// global manager.
+func (a *app) teamsRailAll(d *teamsDraw, width, y int) string {
+ pal := a.pal
+ if root, ok := a.teamsRoot(); ok {
+ return a.teamsRailTeam(d, root, 0, width, y)
+ }
+ word := teamManagerSlotWord
+ bw := ansi.StringWidth(word) + 2
+ left := width - bw
+ selected := a.tp.sel == teamsAllRow
+ name := d.row(" "+pal.ink(teamstore.RootName), left, teamsTarget{act: teamsActSelect, id: teamsAllRow, y: y,
+ hint: "Every team, and what waits on you from any of them" + hintSegment + "enter"}, selected)
+ if left < 12 {
+ return teamsPad(name, width)
+ }
+ btn, _ := d.button(word, teamsTarget{act: teamsActRootManager, x0: left, y: y,
+ hint: "Start a manager over every team: you talk to it, it talks to theirs" + hintSegment + "m"}, pal.muted)
+ return name + btn
+}
+
+// teamsRailTeam is one team's row: indent, colour dot, name, and at most one
+// mark at the right. A team that is doing nothing draws no mark.
+func (a *app) teamsRailTeam(d *teamsDraw, t team, depth, width, y int) string {
+ pal := a.pal
+ mark, markW := "", 0
+ if n := a.teamsNeeds(t); n > 0 {
+ word := "? " + itoa(n)
+ mark, markW = pal.ask(word), ansi.StringWidth(word)
+ } else if a.teamsWorking(t) {
+ word := a.linearMark("⠿", "*")
+ mark, markW = pal.dim(word), ansi.StringWidth(word)
+ }
+ name := t.Name
+ if t.Root {
+ name = teamstore.RootName
+ }
+ lead := " " + strings.Repeat(" ", depth)
+ room := width - ansi.StringWidth(lead) - 2 - markW - 2
+ if room < 3 {
+ room = 3
+ }
+ if ansi.StringWidth(name) > room {
+ name = ansi.Truncate(name, room, a.linearMark("…", "~"))
+ }
+ // A TEAM PICKED WITH space wears the wall's own picked mark in place of
+ // its dot, so the rail says which teams one `Move into…` will move.
+ dot := a.tabTeamDot(t)
+ if a.tp.picked[t.ID] {
+ dot = pal.accent(wallGlyphsFor(pal.ascii).marked)
+ }
+ text := lead + dot + " " + pal.ink(name)
+ if t.Manager != "" {
+ text += " " + pal.dim(a.teamManagerMark())
+ }
+ if mark != "" {
+ gap := width - ansi.StringWidth(text) - markW - 1
+ text += strings.Repeat(" ", max(gap, 1)) + mark
+ }
+ hint := t.Name
+ if t.Manager != "" {
+ hint += hintSegment + "talk to its manager here"
+ } else {
+ hint += hintSegment + "no manager yet"
+ }
+ if !t.Root {
+ hint += hintSegment + "m move into…" + hintSegment + "space pick" + hintSegment + "drag to move"
+ }
+ tg := teamsTarget{act: teamsActSelect, id: t.ID, y: y, hint: hint + hintSegment + "enter"}
+ // DURING A DRAG only a row that takes the drop is grounded, and the row
+ // being dragged is dim, so the person sees what moves and where it can go.
+ if dr := a.tdrag; dr.on {
+ tg.x1 = tg.x0 + width
+ d.targets = append(d.targets, tg)
+ switch {
+ case a.teamDropLit(t.ID):
+ return pal.cursor(teamsPad(text, width), width)
+ case !dr.member && (dr.id == t.ID || a.tp.picked[dr.id] && a.tp.picked[t.ID]):
+ return teamsPad(pal.dim(ansi.Strip(text)), width)
+ }
+ return teamsPad(text, width)
+ }
+ return d.row(text, width, tg, a.tp.sel == t.ID)
+}
+
+// teamsRailNew is the rail's `+ New team`, which with a team chosen reads
+// `+ New team in harbor` and makes the new team inside it. On a rail too narrow
+// for that on one row it is two, `+ New team` and `in harbor` under it, each a
+// press on the same thing (second says which row this is); a name is never cut
+// to make it fit. A chosen team that cannot take one more level dims it and
+// says why.
+func (a *app) teamsRailNew(d *teamsDraw, width, y int, second bool) string {
+ pal := a.pal
+ t, ok := a.teamsSelected()
+ if !ok || t.Closed() || t.Root {
+ return d.row(" + "+teamsNewTeamWord, width, teamsTarget{act: teamsActNewTeam, y: y,
+ hint: "Make a team of the conversation in front" + hintSegment + "n"}, false)
+ }
+ words, split := a.teamsRailNewWords()
+ tg := teamsTarget{act: teamsActNewTeam, id: t.ID, y: y,
+ hint: "Make a team inside " + t.Name + " of the conversation in front" + hintSegment + "n"}
+ switch {
+ case split && second:
+ words = " in " + t.Name
+ tg.opt = "in"
+ case split:
+ words = " + " + teamsNewTeamWord
+ }
+ if ok, why := a.teamsCanNest(t.ID); !ok {
+ tg.hint = why
+ return d.row(pal.dim(words), width, tg, false)
+ }
+ return d.row(words, width, tg, false)
+}
+
+// teamsRailNewWords is `+ New team in harbor` for the chosen team, and whether
+// the rail is too narrow to hold it on one row.
+func (a *app) teamsRailNewWords() (string, bool) {
+ t, ok := a.teamsSelected()
+ if !ok || t.Closed() || t.Root {
+ return " + " + teamsNewTeamWord, false
+ }
+ words := " + " + teamsNewTeamWord + " in " + t.Name
+ return words, a.tp.railW > 0 && ansi.StringWidth(words) > a.tp.railW-2
+}
+
+// ── THE PANE'S HEAD: HEADER, MEMBERS, INBOX ─────────────────────────────────
+
+// teamsTopCache is the hosted pane's head rows as last drawn, and what they
+// were drawn from, so the rows and the height the conversation is laid out
+// under are one answer between frames.
+type teamsTopCache struct {
+ key teamsTopKey
+ rows []string
+ targets []teamsTarget
+ ok bool
+}
+
+// teamsTopKey is everything the head rows are drawn from that can move
+// without the page hearing of it.
+type teamsTopKey struct {
+ width, edits int
+ height int
+ stamp, sel string
+ cur, hot teamsRef
+ focus bool
+ sig uint64
+ minute int64
+ answering string
+ answer string
+ expand string
+ ascii, linear bool
+ undoing bool
+ moving string
+ dragging bool
+}
+
+// teamsTopSig is a digest of the members' states this window can see change
+// on its own: each held member's signal. It allocates nothing.
+func (a *app) teamsTopSig(t team) uint64 {
+ var h uint64 = 14695981039346656037
+ front := a.frontTabKey()
+ for _, m := range t.Members {
+ s := uint64(0)
+ if a.trafficHeld(m.Key) {
+ s = uint64(a.tabSignalFor(m.Key, m.Key == front)) + 1
+ }
+ h = (h ^ s) * 1099511628211
+ }
+ return h
+}
+
+// teamsTop is the pane's head for the selection, width cells wide: the header
+// row, the members, and the inbox. Targets are recorded on rows counted from
+// its first and columns from the pane's.
+func (a *app) teamsTop(d *teamsDraw, width int) []string {
+ pal := a.pal
+ t, ok := a.teamsSelected()
+ // A MOVE WAITING ON THE PERSON, OR A CLOSE OR A MOVE JUST MADE, is said
+ // first, over whichever team is shown now (teammove.go, teamclose.go).
+ out := a.teamsNoticeRows(d, width, 0)
+ if len(out) > 0 {
+ // Air between the notice and the team it is not about.
+ out = append(out, "")
+ }
+ if !ok {
+ if a.tp.sel == teamsAllRow {
+ out = append(out, " "+pal.bold(pal.ink(teamstore.RootName)))
+ out = append(out, a.teamsInboxRows(d, width, len(out))...)
+ }
+ return out
+ }
+ out = append(out, a.teamsHeader(d, t, width, len(out)))
+ if t.Closed() {
+ return out
+ }
+ // THE MEMBERS ARE ON THE HEADER (teamcrew.go): the ones doing something
+ // as chips, everyone else one word that opens the members card.
+ out = append(out, a.teamsNoManagerRows(d, t, width, len(out))...)
+ if !a.teamsCanDelegate() && !a.teamsOff() && a.hosted() {
+ out = append(out, "", " "+pal.dim(fit(teamsHostedWord, width-2)))
+ }
+ out = append(out, a.teamsPromptRows(d, t, width, len(out))...)
+ out = append(out, a.teamsInboxRows(d, width, len(out))...)
+ return out
+}
+
+// teamsSpendWords is today's spend against the cap that applies: `$1.20
+// today` with no cap, `$1.20 of $5 today` with the team's own, and the pool
+// owner's figure named when the cap is inherited, because a cap is a pool and
+// `$1.20 of $5` beside a sub-team would read as a second $5. "" with nothing
+// read yet.
+func (a *app) teamsSpendWords(t team) string {
+ if !a.tp.defaultsOK {
+ return ""
+ }
+ owner, e := a.teamsPool(t)
+ s, ok := a.tp.spend[owner]
+ if !ok {
+ return ""
+ }
+ if e.CapUSDDay <= 0 {
+ // Nothing spent and no cap is nothing to say: an idle team draws no
+ // figure.
+ if s.USD <= 0 {
+ return ""
+ }
+ return dollars(s.USD) + " today"
+ }
+ words := dollars(s.USD) + " of " + teamsMoney(e.CapUSDDay) + " today"
+ if owner != t.ID {
+ if o, ok := a.teamByID(owner); ok {
+ name := o.Name
+ if o.Root {
+ name = teamstore.RootName
+ }
+ words += " " + a.teamsDot() + " " + name + "'s cap"
+ }
+ }
+ return words
+}
+
+// teamsClosedWords is a closed team's dates: when it was made and closed.
+func (a *app) teamsClosedWords(t team) string {
+ words := "closed"
+ if !t.ClosedAt.IsZero() {
+ words += " " + t.ClosedAt.Local().Format("2 Jan")
+ }
+ if !t.Made.IsZero() {
+ words = "opened " + t.Made.Local().Format("2 Jan") + " " + a.teamsDot() + " " + words
+ }
+ return words
+}
+
+// teamsParentClosed is the closed team above t that keeps it closed, false
+// when reopening t alone would work.
+func (a *app) teamsParentClosed(t team) (team, bool) {
+ for _, up := range a.teamAncestors(t.ID) {
+ if up.Closed() {
+ return up, true
+ }
+ }
+ return team{}, false
+}
+
+// teamsMembersRows is the members, every one whether this window has it open
+// or not: its handle, what it is doing, and when it last moved, each a door. A
+// press opens a member this window holds, and resumes one it does not behind
+// the page, never moving the person's focus.
+func (a *app) teamsMembersRows(d *teamsDraw, t team, width, y int) []string {
+ pal := a.pal
+ var pieces []string
+ var targets []teamsTarget
+ now := a.now()
+ for _, m := range t.Members {
+ if m.Key == t.Manager {
+ continue
+ }
+ st := a.teamsMember(m)
+ name := m.Word
+ if m.Handle != "" {
+ name = "@" + m.Handle
+ }
+ if strings.TrimSpace(name) == "" {
+ continue
+ }
+ name = fitConversationTitle(name, 24)
+ word := pal.ink(name) + " "
+ switch {
+ case st.asking:
+ word += pal.ask(st.word)
+ case st.word == "running" || strings.HasPrefix(st.word, "busy for "):
+ word += pal.muted(st.word)
+ default:
+ word += pal.dim(st.word)
+ }
+ plain := name + " " + st.word
+ if age := sinceAt(st.at, now); age != "" && st.word != "running" && !strings.HasPrefix(st.word, "busy for ") {
+ word += " " + pal.dim(age)
+ plain += " " + age
+ }
+ hint := name + " " + st.word
+ if st.open && a.trafficHeld(m.Key) {
+ hint += hintSegment + "click opens it"
+ } else {
+ hint += hintSegment + "click resumes it behind, in its own tab"
+ }
+ pieces = append(pieces, word)
+ targets = append(targets, teamsTarget{act: teamsActMember, id: t.ID, arg: m.Key, x1: ansi.StringWidth(plain), hint: hint})
+ }
+ if len(pieces) == 0 {
+ return []string{" " + pal.dim("no members yet")}
+ }
+ var out []string
+ line, x := " ", 1
+ sep := " " + a.teamsDot() + " "
+ for i, p := range pieces {
+ w := targets[i].x1
+ if x > 1 && x+ansi.StringWidth(sep)+w > width-1 {
+ out = append(out, line)
+ line, x = " ", 1
+ }
+ if x > 1 {
+ line += pal.dim(sep)
+ x += ansi.StringWidth(sep)
+ }
+ tg := targets[i]
+ tg.x0, tg.x1, tg.y = x, x+w, y+len(out)
+ d.targets = append(d.targets, tg)
+ hot, cur := d.lit(tg.ref())
+ switch {
+ case cur:
+ p = pal.selected(p, 0)
+ case hot:
+ p = pal.cursor(p, 0)
+ }
+ line += p
+ x += w
+ }
+ return append(out, line)
+}
+
+// ── THE INBOX ───────────────────────────────────────────────────────────────
+
+// teamsPromptRows is the members' permission prompts, each a card with the
+// answers the member's session offered: the person's own gate, answered through
+// home's own door ([app.sendAnswer]). A manager never answers these.
+func (a *app) teamsPromptRows(d *teamsDraw, t team, width, y int) []string {
+ pal := a.pal
+ var out []string
+ now := time.Now()
+ for _, row := range a.teamsPrompts(t) {
+ question, _ := answerable(row, now)
+ who := "@" + a.teamsHandleOf(t, row.Transcript)
+ head := question.Text
+ if question.Full != nil && strings.TrimSpace(question.Full.Head) != "" {
+ head = question.Full.Head
+ }
+ out = append(out, "")
+ out = append(out, " "+pal.ask("? "+who+" asks")+pal.dim(" "+a.teamsDot()+" ")+pal.ink(fit(switcherFirstLine(head), width-ansi.StringWidth(who)-12)))
+ if _, sent := a.answerSent(row, question); sent {
+ out = append(out, " "+pal.dim(answerWaitingWord))
+ continue
+ }
+ line, x := " ", 2
+ for _, chip := range answerChips(question) {
+ s, w := d.button(chip.label, teamsTarget{act: teamsActPrompt, id: t.ID, arg: row.Transcript, opt: chip.key,
+ x0: x, y: y + len(out), hint: chip.label + " for " + who + hintSegment + "your own gate; a manager never answers it"}, pal.ink)
+ if x+w > width {
+ break
+ }
+ line += s + " "
+ x += w + 1
+ }
+ out = append(out, line)
+ }
+ return out
+}
+
+// teamsHandleOf is the handle, or failing that the name, of the member whose
+// transcript is file.
+func (a *app) teamsHandleOf(t team, file string) string {
+ for _, m := range t.Members {
+ if strings.TrimSpace(m.File) == strings.TrimSpace(file) {
+ if m.Handle != "" {
+ return m.Handle
+ }
+ return m.Word
+ }
+ }
+ return "member"
+}
+
+// teamsInboxRows is the packets waiting on the person or on this team's
+// manager, one card each, newest last, each beyond the last three folded to
+// one line a press unfolds.
+//
+// BESIDE THE MANAGER THE INBOX HAS A HEIGHT. While the pane hosts the
+// manager's conversation, the cards are rows pinned over it, so three whole
+// cards on a laptop's 34 rows left the conversation one row: the person could
+// decide packets and could no longer read or steer the manager, which is what
+// the page is for. So hosted, the cards may take about a third of the
+// frame: the newest (or the one a press unfolded) is always whole, older ones
+// stay whole while they fit, and the rest are one line each, as the fourth
+// card always was. Unhosted, nothing else shares the pane and the old rule
+// stands.
+func (a *app) teamsInboxRows(d *teamsDraw, width, y int) []string {
+ pal := a.pal
+ if !a.teamsCanDelegate() {
+ return nil
+ }
+ packets := a.teamsInbox()
+ whole := make([]bool, len(packets))
+ for i := range packets {
+ whole[i] = i >= len(packets)-teamsInboxWhole || a.tp.expand == packets[i].ID
+ }
+ if a.teamsHosting() && len(packets) > 1 {
+ budget := max(a.height/3-y, teamsInboxFloor)
+ // Measured on a scratch draw so the real one records only the targets
+ // it draws.
+ height := func(p teamstore.Packet) int {
+ return len(a.teamsCard(&teamsDraw{a: a}, p, width, 0)) + 1
+ }
+ used := 0
+ order := make([]int, 0, len(packets))
+ for i := len(packets) - 1; i >= 0; i-- {
+ if a.tp.expand == packets[i].ID {
+ order = append([]int{i}, order...)
+ continue
+ }
+ order = append(order, i)
+ }
+ for n, i := range order {
+ if !whole[i] {
+ used++
+ continue
+ }
+ h := height(packets[i])
+ if n > 0 && used+h > budget && a.tp.expand != packets[i].ID {
+ whole[i] = false
+ used++
+ continue
+ }
+ used += h
+ }
+ }
+ var out []string
+ for i, p := range packets {
+ if !whole[i] {
+ // One blank line over a run of folded lines, not one each.
+ if i == 0 || whole[i-1] {
+ out = append(out, "")
+ }
+ line := " " + pal.muted(a.linearMark("▸", ">")+" "+p.Kind+" "+a.teamsDot()+" ") + pal.ink(p.Question)
+ if p.Team == teamstore.Person {
+ line = teamsPad(line, width-ansi.StringWidth(teamsFoldWaiting)-1) + pal.ask(teamsFoldWaiting)
+ }
+ out = append(out, d.row(line, width, teamsTarget{act: teamsActOption, arg: p.ID, opt: "", y: y + len(out),
+ hint: "Unfold this card" + hintSegment + "enter"}, false))
+ continue
+ }
+ out = append(out, "")
+ out = append(out, a.teamsCard(d, p, width, y+len(out))...)
+ }
+ return out
+}
+
+// teamsInboxFloor is the fewest rows the hosted inbox is given however short
+// the frame: one card's head, question and a couple of options.
+const teamsInboxFloor = 8
+
+// teamsFoldWaiting is a folded card's right edge when it waits on the person:
+// the same needs-you word its whole card carries, so folding a card never
+// hides that it is the person's to answer.
+const teamsFoldWaiting = "waiting on you"
+
+// teamsCard is one decision packet as a card:
+//
+// ? conflict · raised by @web waiting on you
+// which shape does the signup form send?
+// @web the form posts JSON
+// @api the endpoint takes form data
+// JSON @api changes the handler; the form stays recommended
+// form data @web rewrites the submit; the handler stays
+// Your own answer…
+//
+// The options are word buttons with their consequence dim beside them; the
+// recommended one says so; a packet waiting on a manager is dim and says whose,
+// and the person may still decide it (authority: the person first).
+//
+// THE LEAD MARK SAYS WHOSE IT IS, never what a manager is. A card waiting on
+// the person opens with the needs-you `?` the rail and the tabs already use;
+// one waiting on a manager opens with the manager's mark. Every card used to
+// open with the manager's mark, so `◆ question · raised by @boss` read as the
+// manager's question.
+func (a *app) teamsCard(d *teamsDraw, p teamstore.Packet, width, y int) []string {
+ pal := a.pal
+ mine := p.Team == teamstore.Person
+ var out []string
+ kind := p.Kind
+ if p.Kind == teamstore.PacketClosing && p.Report != nil && p.Report.Incomplete {
+ kind += " " + a.teamsDot() + " wrap-up incomplete"
+ }
+ lead := a.teamManagerMark()
+ if mine {
+ lead = "?"
+ }
+ head := kind
+ if by := strings.TrimSpace(p.RaisedBy); by != "" {
+ switch by {
+ case teamstore.FromManager:
+ by = a.teamManagerMark() + " manager"
+ case teamstore.Person:
+ default:
+ by = "@" + by
+ }
+ if by != teamstore.Person {
+ head += " " + a.teamsDot() + " raised by " + by
+ }
+ }
+ if t, ok := a.teamByID(p.Origin); ok && p.Origin != a.tp.sel {
+ head += " " + a.teamsDot() + " " + t.Name
+ }
+ waiting := teamsFoldWaiting
+ right := pal.ask(waiting)
+ if !mine {
+ name := p.Team
+ if t, ok := a.teamByID(p.Team); ok {
+ name = t.Name
+ }
+ waiting = "waiting on " + a.teamManagerMark() + " " + name
+ right = pal.dim(waiting)
+ }
+ headInk, leadInk := pal.muted, pal.muted
+ if mine {
+ headInk, leadInk = pal.ink, pal.ask
+ }
+ room := width - ansi.StringWidth(waiting) - 3 - ansi.StringWidth(lead) - 1
+ line := " " + leadInk(lead) + " " + headInk(fit(head, room))
+ line = teamsPad(line, width-ansi.StringWidth(waiting)-1) + right
+ out = append(out, line)
+ for _, l := range wrap(p.Question, max(width-3, 8)) {
+ out = append(out, " "+pal.ink(l))
+ }
+ for _, party := range p.Parties {
+ who := party.Handle
+ if who == "" {
+ who = "member"
+ }
+ who = "@" + strings.TrimPrefix(who, "@")
+ ctx := strings.Join(strings.Fields(party.Context), " ")
+ out = append(out, " "+pal.muted(teamsPad(who, 10))+pal.dim(fit(ctx, max(width-14, 8))))
+ }
+ // A CAP PACKET SAYS ITS FIGURES (DESIGN.md 8.8): what the pool spent of
+ // its cap today, and which team's pool that is. Its two options,
+ // `Raise to $X` and `Stop for today`, are the person's alone; the session
+ // reads a raise back from these same figures.
+ if c := p.Cap; c != nil {
+ pool := c.Team
+ 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"
+ out = append(out, " "+pal.dim(fit(said, max(width-4, 8))))
+ }
+ if r := p.Report; r != nil {
+ add := func(label, text string) {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return
+ }
+ out = append(out, " "+pal.muted(teamsPad(label, 7))+pal.dim(fit(text, max(width-11, 8))))
+ }
+ add("done", r.Done)
+ add("left", r.Left)
+ add("files", strings.Join(r.Files, ", "))
+ if r.SpendUSD > 0 {
+ add("spent", dollars(r.SpendUSD))
+ }
+ }
+ labelW := 0
+ for _, o := range p.Options {
+ labelW = max(labelW, ansi.StringWidth(o.Label)+2)
+ }
+ labelW = min(labelW, max(width/2, 12))
+ rec := ""
+ if p.Recommendation != nil {
+ rec = p.Recommendation.Option
+ }
+ for _, o := range p.Options {
+ word := o.Label
+ if ansi.StringWidth(word) > labelW-2 {
+ word = ansi.Truncate(word, labelW-2, a.linearMark("…", "~"))
+ }
+ hint := o.Label + hintSegment + o.Consequence
+ s, w := d.button(word, teamsTarget{act: teamsActOption, arg: p.ID, opt: o.ID, x0: 1, y: y + len(out), hint: hint}, pal.ink)
+ rest := strings.Repeat(" ", max(labelW-w, 0)) + " " + pal.dim(o.Consequence)
+ if o.ID == rec {
+ // THE RECOMMENDED OPTION IS MARKED ON ITS OWN ROW, and its reason
+ // is a line of its own under the options, where it can be read
+ // whole at any width.
+ rest += " " + pal.muted(a.linearMark(a.icon(tokens.GSettled), "*")+" recommended")
+ }
+ out = append(out, fit(" "+s+rest, width))
+ }
+ if p.Recommendation != nil {
+ if reason := strings.TrimSpace(p.Recommendation.Reason); reason != "" {
+ for _, l := range wrap("recommended because "+reason, max(width-4, 8)) {
+ out = append(out, " "+pal.dim(l))
+ }
+ }
+ }
+ if a.tp.answering == p.ID {
+ box, _, _ := draftBlock(&a.tp.answer, pal, width-4, 1, "your own answer, then enter", "")
+ for _, l := range box {
+ out = append(out, " "+l)
+ }
+ out = append(out, " "+pal.dim("enter decides with your words "+a.teamsDot()+" esc puts it away"))
+ } else {
+ s, _ := d.button("Your own answer"+a.linearMark("…", "..."), teamsTarget{act: teamsActOwnAnswer, arg: p.ID, x0: 1, y: y + len(out),
+ hint: "Decide it in your own words" + hintSegment + "enter"}, pal.muted)
+ out = append(out, " "+s)
+ }
+ return out
+}
+
+// ── THE PANE'S BODY WHEN THE MANAGER IS NOT IN IT ───────────────────────────
+
+// teamsPaneRest is what the pane draws under its head when it hosts no
+// conversation: `+ Manager` for a team without one, the manager being brought
+// in front, a closed team's report, or the `All teams` row's offer.
+func (a *app) teamsPaneRest(d *teamsDraw, width, y int) []string {
+ pal := a.pal
+ t, ok := a.teamsSelected()
+ var out []string
+ switch {
+ case a.teamsOff():
+ out = append(out, "", " "+pal.dim(fit(teamHostedWord, width-2)))
+ case !ok && a.tp.sel == teamsAllRow:
+ out = append(out, "", " "+pal.dim(fit("a manager over every team: you talk to it, and it talks to each team's own", width-2)))
+ s, _ := d.button(teamManagerSlotWord, teamsTarget{act: teamsActRootManager, x0: 1, y: y + len(out) + 1,
+ hint: "Start the manager of every team" + hintSegment + "m"}, pal.ink)
+ out = append(out, "", " "+s)
+ case !ok:
+ case t.Closed():
+ out = append(out, a.teamsClosedRows(d, t, width, y)...)
+ case t.Manager == "":
+ // Its offer is drawn under the members ([app.teamsNoManagerRows]).
+ default:
+ // The manager on its way in, or why it is not (teamsopen.go).
+ out = append(out, a.teamsOpeningRows(d, t, width, y+len(out))...)
+ }
+ return out
+}
+
+// teamsNoManagerRows is a team without a manager's one offer, under its
+// members and above anything waiting, so a long inbox never pushes it off the
+// pane: `+ Manager` and what a manager is, wrapped beside it.
+func (a *app) teamsNoManagerRows(d *teamsDraw, t team, width, y int) []string {
+ if t.Manager != "" || t.Closed() || t.Root || a.teamsOff() {
+ return nil
+ }
+ pal := a.pal
+ s, w := d.button(teamManagerSlotWord, teamsTarget{act: teamsActManager, id: t.ID, x0: 1, y: y + 1,
+ hint: "Start " + t.Name + "'s manager: a conversation that runs the team for you" + hintSegment + "m"}, pal.ink)
+ lead := 1 + w + 2
+ said := wrap(teamsNoManagerWord, max(width-lead, 8))
+ out := []string{""}
+ for i, l := range said {
+ if i == 0 {
+ out = append(out, " "+s+" "+pal.dim(l))
+ continue
+ }
+ out = append(out, strings.Repeat(" ", lead)+pal.dim(l))
+ }
+ return out
+}
+
+// teamsClosedRows is a closed team's view: its closing report when there is
+// one, and its members, each still a door to its conversation.
+func (a *app) teamsClosedRows(d *teamsDraw, t team, width, y int) []string {
+ pal := a.pal
+ var out []string
+ var report *teamstore.Packet
+ for i, p := range a.tp.history[t.ID] {
+ if p.ID == t.Report || (t.Report == "" && p.Kind == teamstore.PacketClosing) {
+ report = &a.tp.history[t.ID][i]
+ }
+ }
+ out = append(out, "")
+ switch {
+ case report != nil && report.Report != nil:
+ out = append(out, " "+pal.muted("closing report"))
+ r := report.Report
+ add := func(label, text string) {
+ if text = strings.TrimSpace(text); text != "" {
+ for i, l := range wrap(text, max(width-11, 8)) {
+ if i == 0 {
+ out = append(out, " "+pal.muted(teamsPad(label, 7))+pal.ink(l))
+ } else {
+ out = append(out, " "+pal.ink(l))
+ }
+ }
+ }
+ }
+ add("done", r.Done)
+ add("left", r.Left)
+ add("files", strings.Join(r.Files, ", "))
+ if r.SpendUSD > 0 {
+ add("spent", dollars(r.SpendUSD))
+ }
+ case !a.teamsCanReadHistory():
+ out = append(out, " "+pal.dim(fit("its closing report is kept where the team ran, and is not readable over this connection", width-2)))
+ default:
+ out = append(out, " "+pal.dim("closed without a report"))
+ }
+ out = append(out, "", " "+pal.muted("members"))
+ members := a.teamsMembersRows(d, t, width, y+len(out))
+ return append(out, members...)
+}
+
+// teamsEmpty is the page with no teams at all: what a team is, and the two
+// ways to make one.
+func (a *app) teamsEmpty(d *teamsDraw, width, height int) []string {
+ pal := a.pal
+ room := min(width-4, 72)
+ lead := max((width-room)/2, 2)
+ pad := strings.Repeat(" ", lead)
+ var out []string
+ lines := wrap(teamsExplainWord, room)
+ top := max((height-len(lines)-4)/3, 1)
+ for range top {
+ out = append(out, "")
+ }
+ for _, l := range lines {
+ out = append(out, pad+pal.ink(l))
+ }
+ out = append(out, "")
+ y := len(out)
+ s1, w1 := d.button(a.teamsSpark()+" "+teamsOrganizeWord, teamsTarget{act: teamsActOrganize, x0: lead, y: y,
+ hint: "Suggest teams; nothing changes until you apply" + hintSegment + "o"}, pal.ink)
+ s2, _ := d.button("+ "+teamsNewTeamWord, teamsTarget{act: teamsActNewTeam, x0: lead + w1 + 2, y: y,
+ hint: "Make a team of the conversation in front" + hintSegment + "n"}, pal.ink)
+ out = append(out, pad+s1+" "+s2)
+ return out
+}
+
+// ── THE WHOLE PAGE WHEN IT HOSTS NO CONVERSATION ────────────────────────────
+
+// teamsBody is the page's body inside the shared place frame: the rail and the
+// pane side by side, or the explainer alone with no teams. Its targets are
+// recorded in frame cells.
+func (a *app) teamsBody(width, room int) []placeRow {
+ d := &teamsDraw{a: a}
+ a.teamsSettle()
+ var lines []string
+ if !a.teamsAny() {
+ lines = a.teamsEmpty(d, width, room)
+ for i := range d.targets {
+ d.targets[i].line = d.targets[i].y
+ }
+ } else {
+ railW := teamsRailCols(width)
+ paneW := width - railW
+ var rail []string
+ if railW > 0 {
+ rail = a.teamsRail(d, railW-1, room)
+ for i := range d.targets {
+ d.targets[i].line = d.targets[i].y
+ }
+ } else {
+ // TOO NARROW FOR TWO COLUMNS, the rail stands over the pane as one
+ // list, and the arrows walk the two as one.
+ lines = a.teamsRail(d, width, len(a.teamsRailRows()))
+ lines = append(lines, a.pal.dim(rule(width)))
+ for i := range d.targets {
+ d.targets[i].line = d.targets[i].y
+ }
+ }
+ top := len(lines)
+ mark := len(d.targets)
+ pane := a.teamsTop(d, paneW-1)
+ pane = append(pane, a.teamsPaneRest(d, paneW-1, len(pane))...)
+ for i := mark; i < len(d.targets); i++ {
+ d.targets[i].line = top + d.targets[i].y
+ }
+ // THE PANE SCROLLS TO KEEP THE CURSOR ON IT: a long inbox moves up
+ // under a cursor walking down it, rather than walking it off the frame.
+ if vis := room - top; len(pane) > vis && vis > 0 {
+ off := 0
+ for _, t := range d.targets[mark:] {
+ if t.ref() == a.tp.cur && t.y >= vis {
+ off = min(t.y-vis+1, len(pane)-vis)
+ }
+ }
+ if off > 0 {
+ pane = pane[off:]
+ d.shift(mark, 0, -off)
+ }
+ }
+ d.shift(mark, railW, top)
+ for i := mark; i < len(d.targets) && railW > 0; i++ {
+ d.targets[i].pane = true
+ }
+ sep := a.pal.dim(a.linearMark("│", "|"))
+ for i := 0; i < room-top; i++ {
+ left := ""
+ if railW > 0 {
+ left = strings.Repeat(" ", railW-1)
+ if i < len(rail) {
+ left = rail[i]
+ }
+ left += sep
+ }
+ right := ""
+ if i < len(pane) {
+ right = pane[i]
+ }
+ lines = append(lines, left+teamsPad(right, paneW))
+ }
+ }
+ // Only what was drawn can be pressed.
+ kept := d.targets[:0]
+ for _, t := range d.targets {
+ if t.y >= 0 && t.y < room {
+ kept = append(kept, t)
+ }
+ }
+ d.targets = kept
+ d.shift(0, 0, placeHeadRows)
+ a.tp.targets = d.targets
+ // A CURSOR WHOSE BUTTON IS GONE (a team closed, a card decided) comes home
+ // to the selected team's row for the next frame, rather than standing on
+ // nothing.
+ if a.tp.focus && len(d.targets) > 0 && a.teamsCursorIndex() < 0 {
+ a.teamsCursorHome()
+ }
+ rows := make([]placeRow, 0, room)
+ for i := 0; i < room; i++ {
+ text := ""
+ if i < len(lines) {
+ text = lines[i]
+ }
+ rows = append(rows, placeRow{text: text})
+ }
+ return rows
+}
diff --git a/internal/tui3/teamspagehost.go b/internal/tui3/teamspagehost.go
new file mode 100644
index 0000000000..09cb78a951
--- /dev/null
+++ b/internal/tui3/teamspagehost.go
@@ -0,0 +1,495 @@
+package tui3
+
+import (
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// ── THE MANAGER'S REAL CONVERSATION, HOSTED ON THE TEAMS PAGE ───────────────
+//
+// The ruling (c-2) is that the right pane of the teams page IS the selected
+// team's manager conversation, the full chat: typing steers it, its prompts are
+// answered in it, its Traffic rail is there. Not a render of it: the same
+// conversation, with every key and every press it would have had.
+//
+// SO THE PAGE DOES NOT DRAW A CONVERSATION; IT LENDS ONE A RECTANGLE. While the
+// pane hosts the manager ([app.teamsHosting]), the conversation's own geometry
+// is the terminal less the rail on the left: [app.size] answers the narrower
+// width, so every layout, scroll and hit test the conversation makes resolves
+// against the cells it is really drawn in. The frame is the conversation's own
+// ([app.chatFrameLines]) with two changes: the head's rows are the places'
+// head at the full width (so the bar still says `teams`), and the page's header,
+// members and inbox are pinned rows between the head and the transcript,
+// charged in [app.topHeight] exactly as the task strip is, so the scrolling
+// knows they are there. The rail is joined on the left of every row under the
+// head.
+//
+// EVERY KEY AND POINTER EVENT THE PAGE DOES NOT TAKE IS HANDED TO THE
+// CONVERSATION as if no place were standing ([app.teamsRoute]): the page is
+// set aside for the length of that one message, the pointer's column is moved
+// into the conversation's own cells, and the conversation answers exactly as
+// it does on its own screen. The page keeps `tab`, the digits and the map (the
+// place grammar), `alt+↑↓` (the page's buttons), and presses on its own rows.
+//
+// THE MANAGER IS BROUGHT IN FRONT when its team is selected
+// ([app.teamsBringManager]), and that is the one move of the front this page
+// makes: choosing a team is choosing whom to talk to. The conversation that was
+// in front stays open behind.
+
+// teamsHosting reports whether the pane hosts the manager now: the page is
+// standing (or a message is being handed to the conversation), the selected
+// team's manager is the conversation in front, and no surface that takes the
+// whole frame is up over it. It is asked by [app.size] on every layout, so it
+// reads a handful of fields and allocates nothing.
+func (a *app) teamsHosting() bool {
+ return a.tp.host != "" && (a.at(pageTeams) || a.tp.forwarding) && !a.wall.on && !a.setup.open &&
+ !a.pasteEdit.open && a.tp.host == a.frontTabKey()
+}
+
+// teamsHostRail is the rail's columns while the pane hosts the manager, its
+// separator included, and 0 when it does not.
+func (a *app) teamsHostRail() int {
+ if !a.teamsHosting() {
+ return 0
+ }
+ return a.tp.railW
+}
+
+// teamsSync settles the page after every message: the selection, the manager
+// the pane hosts, and the rail's width. It reads memory only, and costs one
+// comparison on every other place. When the selected team's manager is not in
+// front and nothing has tried to bring it, it starts that attempt and hands
+// back its command (teamsopen.go), so no road to this state leaves the pane
+// saying `opening` with nothing behind the word.
+func (a *app) teamsSync() tea.Cmd {
+ if !a.at(pageTeams) {
+ a.tp.host = ""
+ return nil
+ }
+ a.teamsSettle()
+ host := ""
+ var bring tea.Cmd
+ if t, ok := a.teamsSelected(); ok && !t.Closed() && t.Manager != "" && !a.teamsOff() {
+ if t.Manager == a.frontTabKey() {
+ host = t.Manager
+ if a.tp.open.key != t.Manager || !a.tp.open.done {
+ a.tp.open = teamsOpen{key: t.Manager, gen: a.tp.open.gen, done: true}
+ }
+ } else if !a.tp.forwarding {
+ bring = a.teamsKeepManager(t)
+ }
+ }
+ if host != a.tp.host {
+ a.tp.host = host
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+ if host == "" {
+ a.tp.focus = true
+ }
+ a.tp.railW = teamsRailCols(a.width)
+ return bring
+}
+
+// ── THE FRAME ───────────────────────────────────────────────────────────────
+
+// teamsHostTopHeight is the page's pinned rows over the hosted transcript,
+// charged in [app.topHeight]. Zero when the pane hosts nothing.
+func (a *app) teamsHostTopHeight() int {
+ if !a.teamsHosting() {
+ return 0
+ }
+ width, _ := a.size()
+ return len(a.teamsHostTop(width))
+}
+
+// teamsHostTop is the page's header, members and inbox as rows over the hosted
+// transcript, kept between frames while nothing they are drawn from moved.
+// Their targets are recorded in frame cells.
+func (a *app) teamsHostTop(width int) []string {
+ t, _ := a.teamsSelected()
+ key := teamsTopKey{
+ width: width, height: a.height, edits: a.traffic.edits, stamp: a.traffic.stamp, sel: a.tp.sel,
+ 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,
+ }
+ if c := &a.tp.top; c.ok && c.key == key {
+ return c.rows
+ }
+ d := &teamsDraw{a: a}
+ rows := a.teamsTop(d, width)
+ rows = append(rows, a.pal.dim(rule(width)))
+ for i := range rows {
+ rows[i] = teamsPad(rows[i], width)
+ }
+ a.tp.top = teamsTopCache{key: key, rows: rows, targets: d.targets, ok: true}
+ return rows
+}
+
+// teamsNoticeRows is the pane's first rows: a move waiting on `Move` or
+// `Cancel`, else the newest of a move and a close with its Undo, else nothing.
+// One Undo at a time, so `u` and the button always mean the same thing.
+func (a *app) teamsNoticeRows(d *teamsDraw, width, y int) []string {
+ if p := a.tmove.pend; len(p.ids) > 0 && p.from == teamMoveFromPage {
+ return a.teamsMoveRows(d, width, y)
+ }
+ if a.teamsUndoMoveNewer() {
+ return a.teamsMoveRows(d, width, y)
+ }
+ return a.teamsUndoRow(d, width, y)
+}
+
+// teamsUndoMoveNewer reports whether the Undo on offer is a move's rather than a
+// close's.
+func (a *app) teamsUndoMoveNewer() bool {
+ if !a.teamMoveUndoing() || a.tmove.undo.from != teamMoveFromPage {
+ return false
+ }
+ return !a.teamsUndoing() || a.tmove.undo.at.After(a.tp.undo.at)
+}
+
+// teamsUndoAny is `u` and the Undo button: the move or the close on offer.
+func (a *app) teamsUndoAny() tea.Cmd {
+ if a.teamMoveUndoing() && (a.teamsUndoMoveNewer() || !a.teamsUndoing()) {
+ return a.teamMoveUndo()
+ }
+ return a.teamsUndoClose()
+}
+
+// teamsUndoRow is the one row that offers Undo for a close, while it is offered.
+func (a *app) teamsUndoRow(d *teamsDraw, width, y int) []string {
+ if !a.teamsUndoing() {
+ return nil
+ }
+ pal := a.pal
+ word := " " + pal.dim(a.tp.undo.name+" is closed") + " "
+ 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}
+}
+
+// teamsHostFrame is the hosted page's whole frame, and false when the pane
+// hosts nothing (the shared place frame draws the page then).
+func (a *app) teamsHostFrame() ([]string, []placeHit, int, int, bool) {
+ if !a.teamsHosting() {
+ return nil, nil, 0, 0, false
+ }
+ width, height := max(a.width, 8), max(a.height, 1)
+ railW := a.tp.railW
+ paneW, _ := a.size()
+ // THE CONVERSATION'S OWN FRAME, laid out in its own cells: the page is set
+ // aside for the draw so every surface inside it answers as it does in the
+ // conversation.
+ a.tp.forwarding, a.page = true, pageNone
+ chat, caretX, caretY := a.chatFrameLines(paneW, height)
+ headN := a.tabsHeight(paneW) + a.headSealHeight(paneW)
+ topAt := a.headHeight() + a.stripHeight()
+ a.tp.forwarding, a.page = false, pageTeams
+ // THE HEAD IS THE PLACES' HEAD, AT THE FULL WIDTH, drawn exactly as every
+ // place draws it, so the nav says where the person is standing.
+ was := a.pal
+ a.pal = was.onPlaces()
+ a.pal.placeRows = true
+ a.tabRow = -1
+ head := []string(nil)
+ if headN > 0 {
+ // THE PLACE'S HEAD HAS NO STRIP. The pane under it is the manager's
+ // conversation; the strip is that conversation's row when it is in
+ // front on its own, not while the teams place is the page.
+ head = a.headRows(width, "", a.pal)
+ for len(head) < headN {
+ head = append(head, "")
+ }
+ head = head[:headN]
+ }
+ a.pal = was
+ d := &teamsDraw{a: a}
+ var rail []string
+ if railW > 0 {
+ rail = a.teamsRail(d, railW-1, height-headN)
+ d.shift(0, 0, headN)
+ }
+ // The pane's own rows were recorded as they were drawn; they are moved into
+ // frame cells here.
+ for _, t := range a.tp.top.targets {
+ t.x0 += railW
+ t.x1 += railW
+ t.y += topAt
+ t.pane = railW > 0
+ d.targets = append(d.targets, t)
+ }
+ for i := range d.targets {
+ d.targets[i].line = d.targets[i].y
+ }
+ a.tp.targets = d.targets
+ sep := a.pal.dim(a.linearMark("│", "|"))
+ lines := make([]string, 0, height)
+ lines = append(lines, head...)
+ for i := headN; i < height; i++ {
+ row := ""
+ if i < len(chat) {
+ row = chat[i]
+ }
+ left := ""
+ if railW > 0 {
+ left = strings.Repeat(" ", railW-1)
+ if r := i - headN; r < len(rail) {
+ left = rail[r]
+ }
+ left += sep
+ }
+ lines = append(lines, left+row)
+ }
+ // A reply is read once its transcript is on screen, as in the conversation.
+ delete(a.unreadChats, a.frontTabKey())
+ return lines, nil, caretX + railW, caretY, true
+}
+
+// ── THE KEYS AND THE POINTER ────────────────────────────────────────────────
+
+// teamsRoute takes what the page keeps of a message and hands the rest to the
+// conversation it hosts. It is read at the top of [app.route], and answers
+// false at once on every other place and with a card or a menu up.
+func (a *app) teamsRoute(msg tea.Msg) (tea.Cmd, bool) {
+ if !a.at(pageTeams) || a.tp.forwarding || a.tsheet.on || a.teamMenu.on || a.wall.on || a.tmove.on {
+ return nil, false
+ }
+ switch m := msg.(type) {
+ case tea.KeyPressMsg:
+ return a.teamsRouteKey(m)
+ case tea.MouseClickMsg:
+ return a.teamsRouteMouse(m, m.Mouse())
+ case tea.MouseReleaseMsg:
+ return a.teamsRouteMouse(m, m.Mouse())
+ case tea.MouseMotionMsg:
+ return a.teamsRouteMouse(m, m.Mouse())
+ case tea.MouseWheelMsg:
+ return a.teamsRouteMouse(m, m.Mouse())
+ }
+ return nil, false
+}
+
+// teamsRouteKey is a key on the page. The page's own keys are read when it has
+// the keyboard; a hosted page hands everything else to the composer.
+func (a *app) teamsRouteKey(msg tea.KeyPressMsg) (tea.Cmd, bool) {
+ key := msg.String()
+ if key == "ctrl+c" {
+ return nil, false
+ }
+ if key == "u" && (a.teamsUndoing() || a.teamMoveUndoing()) && a.teamsHasKeys() {
+ return a.teamsUndoAny(), true
+ }
+ // A DRAG IS DROPPED BY esc, and nothing happens (teamdrag.go).
+ if key == "esc" && a.tdrag.press {
+ a.teamDragCancel()
+ return nil, true
+ }
+ // THE MEMBERS CARD has the keyboard while it is up (teamcrew.go).
+ if a.tcrew.on {
+ return a.teamCrewKey(msg), true
+ }
+ // A MOVE WAITING ON THE PERSON is answered by esc too.
+ if key == "esc" && len(a.tmove.pend.ids) > 0 && a.tmove.pend.from == teamMoveFromPage {
+ a.teamMoveCancel()
+ return nil, true
+ }
+ if !a.teamsHosting() {
+ // The shared place grammar and the page's own keys ([placeTeams.key]).
+ return nil, false
+ }
+ switch key {
+ case "alt+up", "alt+down":
+ a.tp.focus = true
+ if a.teamsCursorIndex() < 0 {
+ a.teamsCursorHome()
+ } else if key == "alt+up" {
+ a.teamsWalk(0, -1)
+ } else {
+ a.teamsWalk(0, 1)
+ }
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ return nil, true
+ case "tab", "shift+tab":
+ if a.menu.open || a.comp.open {
+ return a.teamsForward(msg), true
+ }
+ return a.walkPage(key == "shift+tab"), true
+ case placeMapKey:
+ a.mapShowing = !a.mapShowing
+ a.touch()
+ return nil, true
+ }
+ if cmd, took := a.placeJumpKey(msg); took {
+ return cmd, true
+ }
+ if a.tp.answering != "" {
+ return a.teamsAnswerKey(msg), true
+ }
+ if a.tp.focus {
+ if cmd, took := a.teamsKey(msg); took {
+ a.tp.top = teamsTopCache{}
+ return cmd, true
+ }
+ // ANY OTHER KEY GOES BACK TO THE COMPOSER, and types there: a person
+ // who starts a sentence has stopped choosing a button.
+ a.tp.focus = false
+ a.tp.top = teamsTopCache{}
+ }
+ return a.teamsForward(msg), true
+}
+
+// teamsRouteMouse is a pointer event on the page: the rail and the page's own
+// rows answer, the head is the router's, and everything over the hosted
+// conversation is handed to it with its column moved into its own cells.
+func (a *app) teamsRouteMouse(msg tea.Msg, m tea.Mouse) (tea.Cmd, bool) {
+ hosted := a.teamsHosting()
+ // THE MEMBERS CARD, AND A DRAG, TAKE THE POINTER FIRST (teamcrew.go,
+ // teamdrag.go): a drag that started on the card goes on over the rail.
+ if a.tdrag.press {
+ switch msg.(type) {
+ case tea.MouseMotionMsg:
+ a.teamDragMotion(m.X, m.Y, m.Button == tea.MouseLeft)
+ return nil, true
+ case tea.MouseReleaseMsg:
+ cmd, _ := a.teamDragRelease()
+ return cmd, true
+ case tea.MouseClickMsg:
+ // A second press with the first never let go: the first is over.
+ a.teamDragCancel()
+ }
+ }
+ if a.tcrew.on {
+ if cmd, took := a.teamCrewMouse(msg, m); took {
+ return cmd, true
+ }
+ if _, click := msg.(tea.MouseClickMsg); click {
+ return nil, true
+ }
+ }
+ if m.Y < placeHeadRows && a.tabRow >= 0 {
+ if !hosted {
+ return nil, false
+ }
+ // The bar and the rest of the head are the router's, as on every place.
+ return nil, false
+ }
+ if t, ok := a.teamsTargetAt(m.X, m.Y); ok {
+ switch msg.(type) {
+ case tea.MouseClickMsg:
+ if m.Button == tea.MouseLeft {
+ a.tp.hot = t.ref()
+ // A TEAM ROW OR A MEMBER CHIP MAY BE DRAGGED (teamdrag.go). A team
+ // row selects on the press, as it always has; a member's door
+ // waits for the release, so a drag from it never opens it.
+ if a.teamDraggable(t) {
+ member := t.act == teamsActMember
+ id := t.id
+ a.teamDragPress(m.X, m.Y, member, id, t.arg, t, member)
+ if member {
+ return nil, true
+ }
+ }
+ return a.teamsDo(t), true
+ }
+ case tea.MouseMotionMsg:
+ a.teamsHover(m.X, m.Y)
+ return nil, true
+ }
+ }
+ if _, isMotion := msg.(tea.MouseMotionMsg); isMotion && a.tp.hot != (teamsRef{}) {
+ a.tp.hot = teamsRef{}
+ a.tp.top = teamsTopCache{}
+ a.touch()
+ }
+ if !hosted {
+ if _, click := msg.(tea.MouseClickMsg); click {
+ return nil, true
+ }
+ return nil, false
+ }
+ railW := a.tp.railW
+ if m.X < railW {
+ return nil, true
+ }
+ topAt := a.headHeight() + a.stripHeight()
+ if m.Y >= topAt && m.Y < topAt+a.teamsHostTopHeight() {
+ return nil, true
+ }
+ // THE CONVERSATION'S OWN CELLS: the column moved by the rail, and the
+ // conversation answers as it does on its own screen.
+ m.X -= railW
+ switch msg.(type) {
+ case tea.MouseClickMsg:
+ return a.teamsForward(tea.MouseClickMsg(m)), true
+ case tea.MouseReleaseMsg:
+ return a.teamsForward(tea.MouseReleaseMsg(m)), true
+ case tea.MouseMotionMsg:
+ return a.teamsForward(tea.MouseMotionMsg(m)), true
+ case tea.MouseWheelMsg:
+ return a.teamsForward(tea.MouseWheelMsg(m)), true
+ }
+ return nil, false
+}
+
+// teamsForward hands one message to the hosted conversation as if no place were
+// standing, and puts the page back after it unless the message itself went
+// somewhere else (a place, a page, another conversation's own screen).
+//
+// A MESSAGE THAT PUT ANOTHER CONVERSATION IN FRONT TAKES THE PERSON TO IT. A
+// press on a Traffic row goes to that member through the chat surface's own
+// door ([app.trafficPress], [app.trafficGo]), and the member is what the person
+// asked to see: the page steps down for it exactly as a press on a member row
+// does ([app.teamsMemberGo]), rather than standing over a conversation it does
+// not host with the manager's pane saying `opening`.
+func (a *app) teamsForward(msg tea.Msg) tea.Cmd {
+ front := a.frontTabKey()
+ a.tp.forwarding, a.page = true, pageNone
+ _, cmd := a.route(msg)
+ a.tp.forwarding = false
+ if !a.pageShowing() {
+ a.page = pageTeams
+ if a.frontTabKey() != front {
+ a.leavePlace()
+ }
+ } else if !a.at(pageTeams) {
+ // The message walked to another place; this one closes as it would
+ // have under the router.
+ placeTeams{}.close(a)
+ }
+ return cmd
+}
+
+// teamsPageHint is what the hint line says over the page's own rows while the
+// pane hosts the conversation, and the page's two keys otherwise.
+func (a *app) teamsPageHint() string {
+ if !a.teamsHosting() {
+ return ""
+ }
+ words := a.teamsTargetHint()
+ if words != "" && a.tp.focus {
+ words += hintSegment + "esc back to the box"
+ }
+ return words
+}
+
+// teamsComposerWord is the composer's `to` while the pane hosts the manager:
+// `to ◆ harbor manager`, which says which team the words go to as well as who.
+func (a *app) teamsComposerWord() string {
+ if !a.teamsHosting() {
+ return ""
+ }
+ t, ok := a.teamsSelected()
+ if !ok {
+ return ""
+ }
+ name := t.Name
+ if t.Root {
+ name = "all teams"
+ }
+ return "to " + a.teamManagerMark() + " " + name + " manager"
+}
diff --git a/internal/tui3/teamsread_test.go b/internal/tui3/teamsread_test.go
new file mode 100644
index 0000000000..e96806d907
--- /dev/null
+++ b/internal/tui3/teamsread_test.go
@@ -0,0 +1,170 @@
+package tui3
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Agent-Field/codeaf/internal/session"
+ teamstore "github.com/Agent-Field/codeaf/internal/teams"
+)
+
+// ── HOW THE TEAMS PAGE READS (teamspage.go's [app.teamsRead]) ───────────────
+
+// A TEAM CHOSEN WHILE A READ IS OUT IS READ, NOT DROPPED. The beat's read left
+// with harbor selected; orbit is chosen before it answers, and orbit's spend is
+// on the page when everything has landed, with no second beat. The choice's
+// read used to be dropped because one was out, and a team chosen in that window
+// showed no spend until the next beat, or ever on a page whose clock was still.
+func TestTeamsAChoiceWhileAReadIsOutIsReadNotDropped(t *testing.T) {
+ a, harbor, orbit := teamsPlaceLabIDs(t)
+ if a.tp.sel != harbor {
+ t.Fatalf("the lab opened on %q, want harbor", a.tp.sel)
+ }
+ if _, read := a.tp.spend[orbit]; read {
+ t.Fatal("the lab already read orbit's spend")
+ }
+ beat := a.teamsRead(true)
+ if beat == nil {
+ t.Fatal("the beat's read was not made")
+ }
+ choice := a.teamsSelect(orbit)
+ drive(t, a, runCmd(beat)...)
+ drive(t, a, runCmd(choice)...)
+ if _, read := a.tp.spend[orbit]; !read {
+ t.Fatalf("orbit was chosen while a read was out and its spend was never read: %+v", a.tp.spend)
+ }
+ if a.tp.reading || a.tp.again {
+ t.Fatalf("a read is still out or owed after everything landed (reading %v, again %v)", a.tp.reading, a.tp.again)
+ }
+}
+
+// A READ ASKS ABOUT THE OPEN TEAMS' MEMBERS AND NO OTHER CONVERSATION. On this
+// machine's disk the rows are read by name through the page's door, never by a
+// walk of every session under the root; over a connection they are the
+// members' rows out of the world the window holds, and the page keeps nothing
+// else.
+func TestTeamsReadAsksAboutTheMembersAlone(t *testing.T) {
+ a, _, _ := teamsPlaceLabIDs(t)
+ members := map[string]bool{}
+ for _, tm := range a.wall.teams {
+ if tm.Closed() {
+ continue
+ }
+ for _, m := range tm.Members {
+ members[filepath.Clean(m.File)] = true
+ }
+ }
+ if len(members) == 0 {
+ t.Fatal("the lab has no members")
+ }
+ stranger := filepath.Join(t.TempDir(), "-work-else", "aaaa000000000009", "transcript.jsonl")
+
+ // This machine's disk.
+ a.world = nil
+ var calls int
+ var asked []string
+ a.teamsDisk.rows = func(files []string) map[string]session.SessionRow {
+ calls++
+ asked = append(asked, files...)
+ out := map[string]session.SessionRow{}
+ for _, f := range files {
+ out[f] = session.SessionRow{Transcript: f}
+ }
+ return out
+ }
+ drive(t, a, runCmd(a.teamsRead(true))...)
+ if calls != 1 {
+ t.Fatalf("one read asked the rows door %d times", calls)
+ }
+ if len(asked) != len(members) {
+ t.Fatalf("the read asked about %d conversations, want the %d members: %q", len(asked), len(members), asked)
+ }
+ for _, f := range asked {
+ if !members[f] {
+ t.Fatalf("the read asked about %q, which is no open team's member", f)
+ }
+ }
+ if len(a.tp.world) != len(members) {
+ t.Fatalf("the page holds %d rows, want the %d members'", len(a.tp.world), len(members))
+ }
+
+ // Over a connection: the world the window holds, cut to the members.
+ var rows []session.SessionRow
+ for f := range members {
+ rows = append(rows, session.SessionRow{Transcript: f, Title: "member"})
+ }
+ rows = append(rows, session.SessionRow{Transcript: stranger, Title: "stranger"})
+ a.world = func() (session.World, bool) {
+ return session.World{Projects: []session.Project{{Sessions: rows}}}, true
+ }
+ calls = 0
+ a.tp.world = nil
+ drive(t, a, runCmd(a.teamsRead(true))...)
+ if calls != 0 {
+ t.Fatal("a window with a world door read this machine's disk")
+ }
+ if _, kept := a.tp.world[stranger]; kept || len(a.tp.world) != len(members) {
+ t.Fatalf("the page kept %d rows over a connection, want the %d members' alone", len(a.tp.world), len(members))
+ }
+}
+
+// THE FIRST FRAME IS DRAWN FROM MEMORY AND THE READ FILLS IT IN PLACE. The
+// page's rail and the selected team's header are on the frame the opening
+// draws, before any read has answered, with no word or mark saying it is
+// loading; and the read landing moves none of those rows.
+func TestTeamsFirstFrameIsDrawnBeforeAnyReadAndTheReadMovesNothing(t *testing.T) {
+ a, harbor, orbit := menuApp(t)
+ a.profileDir = t.TempDir()
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.SetParent(orbit, harbor) }); err != nil {
+ t.Fatal(err)
+ }
+ flushTeams(t, a)
+ a.width, a.height = 120, 24
+ cmd := a.showPage(pageTeams)
+ before := strings.Split(teamsFrameText(a), "\n")
+ text := strings.Join(before, "\n")
+ for _, want := range []string{"All teams", "harbor", "orbit", "Settings", "Close"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("the first frame, drawn before any read, lacks %q:\n%s", want, text)
+ }
+ }
+ for _, loading := range []string{"loading", "reading", "⠋", "⠙", "⠹"} {
+ if strings.Contains(text, loading) {
+ t.Fatalf("the first frame says %q while the read is out:\n%s", loading, text)
+ }
+ }
+ drive(t, a, runCmd(cmd)...)
+ after := strings.Split(teamsFrameText(a), "\n")
+ if len(after) != len(before) {
+ t.Fatalf("the read changed the frame's height from %d to %d", len(before), len(after))
+ }
+ for y := range before {
+ if before[y] != after[y] {
+ t.Fatalf("the read moved row %d:\nbefore %q\nafter %q", y, before[y], after[y])
+ }
+ }
+}
+
+// A CLOSED TEAM ON A SEAM WITH NO HISTORY DOOR READS WITHOUT ITS RECORD. An
+// older engine over --host carries no History; the read used to index the
+// histories it never asked for, once per closed team, and the window died on a
+// press of a closed team's link.
+func TestTeamsReadOfAClosedTeamWithNoHistoryDoorDoesNotPanic(t *testing.T) {
+ a, harbor, _ := teamsPlaceLabIDs(t)
+ if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(harbor, a.now(), "") }); err != nil {
+ t.Fatal(err)
+ }
+ door := localTeams(a.profileDir, &a.teamsDisk.watch)
+ door.History = nil
+ a.teamsDisk.door = door
+ a.tp.reading = false
+ read := a.teamsRead(true)
+ if read == nil {
+ t.Fatal("no read was made")
+ }
+ drive(t, a, runCmd(read)...)
+ if _, ok := a.tp.history[harbor]; ok {
+ t.Fatal("a seam with no History door put a record on the closed team")
+ }
+}
diff --git a/internal/tui3/teamthread.go b/internal/tui3/teamthread.go
index e0b4f6fe0d..3a0466af08 100644
--- a/internal/tui3/teamthread.go
+++ b/internal/tui3/teamthread.go
@@ -6,323 +6,27 @@ import (
"github.com/charmbracelet/x/ansi"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
- "github.com/Agent-Field/codeaf/internal/tui2/tokens"
)
-// ── THE RAIL'S THREADS (teamrail.go says what the rail is) ─────────────────
+// ── THE TRAFFIC'S THREADS (teamrail.go says what the Traffic is) ──────────
//
-// The Traffic is drawn as threads (internal/teams' thread.go): a message and
-// what answered it, the thread that moved last at the TOP, and inside each one
-// in the order it happened.
-//
-// ◆ manager → @agent @checking @review do 2m
-// Please provide a brief status update…
-// ├ @checking ✓ Status update: the lexer… 1m
-// ├ @agent working…
-// └ @review ✓ finished now
+// The Traffic is threads (internal/teams' thread.go): a message and what
+// answered it, the thread that moved last first, and inside each one in the
+// order it happened. The side column draws each as one row of work
+// (sidetraffic.go) and the thread cards in the manager's conversation draw
+// each answer under its question (teamthreadcard.go); both read the answers
+// through [replyLines].
//
// ONE QUESTION IS ONE THREAD, NOT TWELVE ROWS. The wake that started a member
-// is not a row: it is why the member reads `working…` until it answers. A
-// member's finishing is not a row: it is the `✓` on its reply, or its own line
-// when it said nothing. A failure is `✗`; a member asking the person is the one
-// line in the needs-you amber. A stop, a start with no answers yet, a handle
-// changing and every entry written before threads stay one line each.
-//
-// EVERY HANDLE IS A LINK, inked and grounded exactly as a handle in the chat
-// is (teamlink.go): a press opens the member, resumed first when this window
-// does not hold it, and the hint line names it. A message's words are a door
-// of their own: under the pointer the hint line says them whole, and a press
-// lays them out in full under the row, and a second press folds them again.
-//
-// THE FRAME DRAWS THE CACHE. All of this is worked out when the rail's cache
-// key moves ([trafficCacheKey]), off the entries already in memory.
-
-// trafficDoor is one pressable span of a rail row: a member a press opens, or
-// an entry whose words a press lays out or folds. span is in the row's own
-// columns.
-type trafficDoor struct {
- span hudSpan
- member string
- expand string
- hint string
- link bool
- // land is the entry a member's handle opens its conversation at, and here
- // the entry a message's words bring into view in the manager's own
- // conversation (teamjump.go).
- land, here string
-}
-
-// trafficLaid is one drawn row of the rail and its doors.
-type trafficLaid struct {
- text string
- doors []trafficDoor
-}
-
-// railSeg is one piece of a row being laid: its plain words, how they are
-// painted, and the door they are (-1 for none).
-type railSeg struct {
- text string
- paint func(string) string
- door int
-}
-
-// trafficSheet lays a rail's rows one at a time, width wide, lighting the
-// door under the pointer as it goes.
-type trafficSheet struct {
- a *app
- t team
- width int
- rows []trafficLaid
- hotRow int
- hotDoor int
- // first is the row index the sheet's row 0 lands on, for the hover.
- first int
- // land and here are the entry the doors being laid open at (teamjump.go).
- land, here string
-}
-
-// add lays one row from segs, with age flush right when there is room for
-// it. The last segment that does not fit is cut with an ellipsis and the ones
-// after it are dropped; the doors keep the columns the words ended up in.
-func (s *trafficSheet) add(segs []railSeg, doors []trafficDoor, age string) {
- pal := s.a.pal
- room := s.width
- if age != "" && s.width >= 24 {
- room = s.width - ansi.StringWidth(age) - 1
- } else {
- age = ""
- }
- hot := len(s.rows)+s.first == s.hotRow
- var b strings.Builder
- used := 0
- spans := make([]hudSpan, len(doors))
- for _, seg := range segs {
- if used >= room {
- break
- }
- text := seg.text
- w := ansi.StringWidth(text)
- if used+w > room {
- text = ansi.Truncate(text, room-used, s.a.linearMark("…", "~"))
- w = ansi.StringWidth(text)
- }
- painted := text
- switch {
- case seg.door >= 0 && hot && seg.door == s.hotDoor && doors[seg.door].link:
- painted = teamLinkHotInk(pal, text)
- case seg.door >= 0 && hot && seg.door == s.hotDoor:
- painted = pal.cursor(pal.ink(text), 0)
- case seg.door >= 0 && doors[seg.door].link:
- painted = teamLinkInk(pal, text)
- case seg.paint != nil:
- painted = seg.paint(text)
- }
- if seg.door >= 0 && seg.door < len(spans) {
- if !spans[seg.door].pressable() {
- spans[seg.door] = hudSpan{from: used, to: used + w}
- } else {
- spans[seg.door].to = used + w
- }
- }
- b.WriteString(painted)
- used += w
- }
- line := b.String() + strings.Repeat(" ", max(room-used, 0))
- if age != "" {
- line += " " + pal.dim(age)
- }
- laid := trafficLaid{text: line}
- for i, d := range doors {
- if spans[i].pressable() {
- d.span = spans[i]
- laid.doors = append(laid.doors, d)
- }
- }
- s.rows = append(s.rows, laid)
-}
-
-// fits reports whether segs and age lay out whole in the sheet's width.
-func (s *trafficSheet) fits(segs []railSeg, age string) bool {
- room := s.width
- if age != "" && s.width >= 24 {
- room -= ansi.StringWidth(age) + 1
- }
- used := 0
- for _, seg := range segs {
- used += ansi.StringWidth(seg.text)
- }
- return used <= room
-}
-
-// trafficReplyAgeCols is the narrowest rail whose answers carry their age: on
-// a narrower one the words need the cells more, and the thread's header still
-// says when it began.
-const trafficReplyAgeCols = 40
+// is not a line: it is why the member reads `working…` until it answers. A
+// member's finishing is not a line: it is the `✓` on its reply, or its own line
+// when it said nothing. A failure is `✗`, and a member asking the person is
+// the state `asking`.
-// blank lays an empty row.
-func (s *trafficSheet) blank() {
- s.rows = append(s.rows, trafficLaid{text: strings.Repeat(" ", s.width)})
-}
-
-// full reports whether the sheet holds at least n rows.
-func (s *trafficSheet) full(n int) bool { return len(s.rows) >= n }
-
-// handleSeg is a member's handle as a link, and its door, or plain words when
-// the handle is nobody's in the team.
-func (s *trafficSheet) handleSeg(handle string, doors *[]trafficDoor, paint func(string) string) railSeg {
- word := "@" + strings.TrimPrefix(handle, "@")
- if m, ok := s.t.ByHandle(strings.TrimPrefix(handle, "@")); ok && m.Key != s.a.frontTabKey() {
- hint := s.a.teamMemberHint(m)
- if s.land != "" {
- hint = strings.Replace(hint, " @"+m.Handle, " @"+m.Handle+" at this message", 1)
- }
- *doors = append(*doors, trafficDoor{member: m.Key, link: true, land: s.land, hint: hint})
- return railSeg{text: word, door: len(*doors) - 1}
- }
- return railSeg{text: word, paint: paint, door: -1}
-}
-
-// managerSegs is the manager's two words, `◆ manager`: the mark in the accent,
-// the word muted.
-func (s *trafficSheet) managerSegs() []railSeg {
- pal := s.a.pal
- return []railSeg{
- {text: s.a.teamManagerMark(), paint: pal.accent, door: -1},
- {text: " manager", paint: pal.muted, door: -1},
- }
-}
-
-// addressSegs is where an entry was sent, as the rail spells it.
-func (s *trafficSheet) addressSegs(e teamstore.Entry, doors *[]trafficDoor) []railSeg {
- pal := s.a.pal
- switch e.To {
- case teamstore.ToManager:
- return s.managerSegs()
- case teamstore.ToEveryone:
- return []railSeg{{text: "everyone", paint: pal.muted, door: -1}}
- case teamstore.ToRoom:
- return []railSeg{{text: "room", paint: pal.muted, door: -1}}
- }
- var out []railSeg
- for i, h := range e.Recipients() {
- if i > 0 {
- out = append(out, railSeg{text: " ", door: -1})
- }
- out = append(out, s.handleSeg(h, doors, pal.muted))
- }
- return out
-}
-
-// speakerSegs is who wrote an entry.
-func (s *trafficSheet) speakerSegs(from string, doors *[]trafficDoor) []railSeg {
- switch from {
- case teamstore.FromManager:
- return s.managerSegs()
- case teamstore.FromSystem:
- return []railSeg{{text: "codeaf", paint: s.a.pal.dim, door: -1}}
- }
- return []railSeg{s.handleSeg(from, doors, s.a.pal.muted)}
-}
-
-// trafficOpenKey is an entry's key in the rail's record of what is laid out
-// in full.
+// trafficOpenKey is an entry's key in the thread cards' record of what is laid
+// out in full.
func trafficOpenKey(teamID, id string) string { return teamID + "/" + id }
-// words lays an entry's words: one row cut to the width, or, when the person
-// opened it, every line of it. lead is what stands before the words on the
-// first row and under it on the rest; paint inks the words.
-func (s *trafficSheet) words(e teamstore.Entry, lead []railSeg, under string, paint func(string) string, age string, extra []trafficDoor) {
- text := strings.Join(strings.Fields(e.Text), " ")
- key := trafficOpenKey(s.t.ID, e.ID)
- open := s.a.traffic.open[key]
- doors := append([]trafficDoor(nil), extra...)
- hint := text + hintSegment + "click shows it all"
- if open {
- hint = "click folds it" + hintSegment + trafficKey + " hides the traffic"
- }
- door := len(doors)
- doors = append(doors, trafficDoor{expand: key, here: s.here, hint: hint})
- if !open {
- s.add(append(lead, railSeg{text: text, paint: paint, door: door}), doors, age)
- return
- }
- leadW := 0
- for _, seg := range lead {
- leadW += ansi.StringWidth(seg.text)
- }
- lines := wrap(strings.TrimSpace(e.Text), max(s.width-leadW, 8))
- for i, line := range lines {
- if i == 0 {
- s.add(append(lead, railSeg{text: line, paint: paint, door: door}), doors, age)
- continue
- }
- s.add([]railSeg{{text: under, paint: s.a.pal.dim, door: -1}, {text: line, paint: paint, door: 0}},
- []trafficDoor{{expand: key, here: s.here, hint: hint}}, "")
- }
-}
-
-// thread lays one thread: a line of its own, or its message under a header
-// and its answers under that as a tree.
-func (s *trafficSheet) thread(th teamstore.Thread) {
- pal := s.a.pal
- e := th.Root
- arrow := " " + s.a.linearMark("→", "->") + " "
- var doors []trafficDoor
- // A HANDLE ON THE HEADER OPENS ITS MEMBER AT THIS MESSAGE, and the
- // message's words bring its card into view here (teamjump.go).
- defer func() { s.land, s.here = "", "" }()
- switch e.Kind {
- case teamstore.KindStop:
- segs := append(s.speakerSegs(e.From, &doors), railSeg{text: " stopped ", paint: pal.muted, door: -1})
- segs = append(segs, s.handleSeg(e.To, &doors, pal.muted))
- if reason := strings.TrimSpace(e.Text); reason != "" {
- segs = append(segs, railSeg{text: " · " + strings.Join(strings.Fields(reason), " "), paint: pal.dim, door: -1})
- }
- s.add(segs, doors, s.a.trafficAge(e))
- return
- case teamstore.KindEvent:
- s.event(e)
- return
- case teamstore.KindStart:
- segs := append(s.speakerSegs(e.From, &doors), railSeg{text: " started ", paint: pal.muted, door: -1})
- segs = append(segs, s.handleSeg(e.To, &doors, pal.muted))
- s.add(segs, doors, s.a.trafficAge(e))
- default:
- s.land, s.here = e.ID, e.ID
- tag := "fyi"
- if e.Kind == teamstore.KindDirective {
- tag = "do"
- }
- // A HEADER THAT CANNOT NAME EVERYONE NAMES WHOM IT CAN AND COUNTS THE
- // REST, `@agent @checking +1 do`, so the tag is never the part cut.
- named := e.Recipients()
- for keep := len(named); ; keep-- {
- doors = doors[:0]
- segs := append(s.speakerSegs(e.From, &doors), railSeg{text: arrow, paint: pal.dim, door: -1})
- if keep == len(named) {
- segs = append(segs, s.addressSegs(e, &doors)...)
- } else {
- for i, h := range named[:keep] {
- if i > 0 {
- segs = append(segs, railSeg{text: " ", door: -1})
- }
- segs = append(segs, s.handleSeg(h, &doors, pal.muted))
- }
- segs = append(segs, railSeg{text: " +" + itoa(len(named)-keep), paint: pal.dim, door: -1})
- }
- segs = append(segs, railSeg{text: " " + tag, paint: pal.dim, door: -1})
- if keep <= 1 || s.fits(segs, s.a.trafficAge(e)) {
- s.add(segs, doors, s.a.trafficAge(e))
- break
- }
- }
- }
- if strings.TrimSpace(e.Text) != "" {
- s.words(e, []railSeg{{text: " ", door: -1}}, " ", pal.dim, "", nil)
- }
- s.replies(th)
-}
-
// trafficReply is one member's line under a thread: its words, or the state
// its turn is in.
type trafficReply struct {
@@ -380,7 +84,7 @@ func replyLines(th teamstore.Thread) []trafficReply {
continue
}
push(trafficReply{who: who, state: state, note: note, last: e})
- case e.Kind == teamstore.KindNote || e.Kind == teamstore.KindDirective:
+ case e.Kind == teamstore.KindNote || e.Kind == teamstore.KindDirective || e.Kind == teamstore.KindAnswer || e.Kind == teamstore.KindQuestion:
if l, ok := lineOf(e.From); ok && l.state == "working" && !l.said {
l.entry, l.said, l.state, l.last = e, true, "", e
continue
@@ -391,103 +95,6 @@ func replyLines(th teamstore.Thread) []trafficReply {
return lines
}
-// replies lays a thread's answers as a tree under it.
-func (s *trafficSheet) replies(th teamstore.Thread) {
- pal := s.a.pal
- lines := replyLines(th)
- for i, r := range lines {
- lastOne := i == len(lines)-1
- glyph, under := s.a.linearMark("├ ", "|-"), s.a.linearMark("│ ", "| ")
- if lastOne {
- glyph, under = s.a.linearMark("└ ", "`-"), " "
- }
- var doors []trafficDoor
- // AN ANSWER'S HANDLE OPENS ITS MEMBER AT ITS OWN POST, and a line with
- // nothing said yet at the message it answers.
- s.land, s.here = th.Root.ID, th.Root.ID
- if r.said {
- s.land = r.entry.ID
- }
- lead := []railSeg{{text: glyph, paint: pal.dim, door: -1}}
- if r.who == teamstore.FromManager {
- lead = append(lead, s.managerSegs()...)
- } else {
- lead = append(lead, s.handleSeg(r.who, &doors, pal.muted))
- }
- lead = append(lead, railSeg{text: " ", door: -1})
- age := ""
- if s.width >= trafficReplyAgeCols {
- age = s.a.trafficAge(r.last)
- }
- mark := ""
- switch r.state {
- case teamstore.StateFinished:
- mark = s.a.linearMark(s.a.icon(tokens.GSettled), "ok") + " "
- case teamstore.StateFailed:
- mark = s.a.linearMark(s.a.icon(tokens.GFailed), "x") + " "
- }
- switch {
- case r.state == teamstore.StateAsking:
- // THE ONE LINE IN THE NEEDS-YOU AMBER: a member waiting on the person.
- quote := r.last
- quote.Text = "asking: " + strings.TrimPrefix(r.note, "asks: ")
- s.words(quote, lead, under+" ", pal.ask, age, doors)
- case r.said:
- if mark != "" {
- lead = append(lead, railSeg{text: mark, paint: pal.muted, door: -1})
- }
- s.words(r.entry, lead, under+" ", pal.muted, age, doors)
- case r.state == "working":
- s.add(append(lead, railSeg{text: "working" + s.a.linearMark("…", "..."), paint: pal.dim, door: -1}), doors, age)
- default:
- word := r.state
- if r.state == teamstore.StateFailed && r.note != "" {
- word = ""
- }
- segs := append(lead, railSeg{text: mark + word, paint: pal.muted, door: -1})
- if r.note != "" && r.note != r.state {
- sep := " · "
- if word == "" {
- sep = ""
- }
- segs = append(segs, railSeg{text: sep + r.note, paint: pal.dim, door: -1})
- }
- s.add(segs, doors, age)
- }
- }
-}
-
-// event lays an event that answers nothing: one line.
-func (s *trafficSheet) event(e teamstore.Entry) {
- pal := s.a.pal
- var doors []trafficDoor
- text := strings.Join(strings.Fields(e.Text), " ")
- segs := append(s.speakerSegs(e.From, &doors), railSeg{text: " ", door: -1})
- switch {
- case trafficAsking(e):
- quote := e
- quote.Text = "asking: " + strings.TrimPrefix(text, "asks: ")
- s.words(quote, segs, " ", pal.ask, s.a.trafficAge(e), doors)
- return
- case e.State == teamstore.StateFinished:
- segs = append(segs, railSeg{text: s.a.linearMark(s.a.icon(tokens.GSettled), "ok") + " finished", paint: pal.muted, door: -1})
- if text != "" && text != e.State {
- segs = append(segs, railSeg{text: " · " + text, paint: pal.dim, door: -1})
- }
- case e.State == teamstore.StateFailed:
- if text == "" {
- text = "failed"
- }
- segs = append(segs, railSeg{text: s.a.linearMark(s.a.icon(tokens.GFailed), "x") + " " + text, paint: pal.muted, door: -1})
- default:
- if text == "" {
- text = e.State
- }
- segs = append(segs, railSeg{text: text, paint: pal.dim, door: -1})
- }
- s.add(segs, doors, s.a.trafficAge(e))
-}
-
// trafficThreads is team t's cached entries that the rail draws, as threads,
// newest activity first.
func (a *app) trafficThreads(t team) []teamstore.Thread {
@@ -497,34 +104,16 @@ func (a *app) trafficThreads(t team) []teamstore.Thread {
if !trafficShown(e) {
continue
}
+ // A MANAGER'S ANSWER NAMES THE QUESTION IT ANSWERS in Reply, the
+ // field it was written with before threads; it threads under it.
+ if e.Kind == teamstore.KindAnswer && e.Answers == "" {
+ e.Answers = e.Reply
+ }
shown = append(shown, e)
}
return teamstore.Threads(shown)
}
-// trafficSheetOf lays team t's threads into height rows width wide, newest
-// thread first, a blank row between threads. first is the body row the
-// sheet's first row lands on, which is what the pointer holds.
-func (a *app) trafficSheetOf(t team, height, width, first int) []trafficLaid {
- s := &trafficSheet{a: a, t: t, width: width, hotRow: -1, first: first}
- if a.hot.kind == hoverTraffic {
- s.hotRow, s.hotDoor = a.hot.index, a.hot.entry
- }
- for i, th := range a.trafficThreads(t) {
- if s.full(height) {
- break
- }
- if i > 0 {
- s.blank()
- }
- s.thread(th)
- }
- if len(s.rows) > height {
- s.rows = s.rows[:height]
- }
- return s.rows
-}
-
// teamMemberHint is what the hint line says with the pointer on a member's
// handle: `Open @web · web frontend · click`, or Resume when this window does
// not hold it.
diff --git a/internal/tui3/teamthread_test.go b/internal/tui3/teamthread_test.go
index fcb41f60d1..cdf40b870b 100644
--- a/internal/tui3/teamthread_test.go
+++ b/internal/tui3/teamthread_test.go
@@ -4,29 +4,37 @@ import (
"strings"
"testing"
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
"github.com/Agent-Field/codeaf/internal/tui2/tokens"
)
-// railLines is the rail's column on the next frame, plain, one string a row,
-// with the body row each landed on.
+// railLines is the side column on the next frame, plain, one string a frame
+// row, so an index into it is a screen row.
func railLines(t *testing.T, a *app) []string {
t.Helper()
frame, _, _ := a.frame()
- cols := a.trafficWidth()
- if cols <= trafficGripCols {
- t.Fatalf("the rail is not a column: %d", cols)
+ cols := a.railWidth()
+ if cols <= railGripCols {
+ t.Fatalf("the side column is not a column: %d", cols)
}
+ // A ROW IS AS LONG AS WHAT IS ON IT, so a short row of the column is cut
+ // from where the column starts to wherever it ends.
var out []string
for _, r := range strings.Split(ansi.Strip(frame), "\n") {
- out = append(out, strings.TrimRight(plainCells(r, a.width-cols, a.width), " "))
+ cells := []rune(r)
+ cell := ""
+ if len(cells) > a.width-cols {
+ cell = string(cells[a.width-cols : min(len(cells), a.width)])
+ }
+ out = append(out, strings.TrimRight(cell, " "))
}
return out
}
-// railRowOf is the first frame row whose rail cells hold want, -1 for none.
+// railRowOf is the first frame row whose column cells hold want, -1 for none.
func railRowOf(rows []string, want string) int {
for y, r := range rows {
if strings.Contains(r, want) {
@@ -36,6 +44,39 @@ func railRowOf(rows []string, want string) int {
return -1
}
+// sidePainted is the side column's rows as the frame paints them, ANSI and
+// all, so an assertion about ink reads the ink.
+func sidePainted(a *app) string {
+ return strings.Join(a.railRows(a.viewHeight()), "\n")
+}
+
+// sideClick presses and lets go at a screen cell, as a hand does.
+func sideClick(t *testing.T, a *app, x, y int) {
+ t.Helper()
+ drive(t, a, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
+ drive(t, a, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})
+}
+
+// sideRowDoor is the screen cell of the door on row key that does act kind,
+// failing the test when the row is not drawn or has no such door.
+func sideRowDoor(t *testing.T, a *app, key string, kind int) (int, int) {
+ t.Helper()
+ view, _ := a.railDrawnView(a.viewHeight())
+ for i, line := range view {
+ if line.side == nil || line.side.key != key {
+ continue
+ }
+ for _, d := range line.side.doors {
+ if d.act.kind == kind {
+ return a.railLeft() + ansi.StringWidth(railSeam) + d.span.from, a.topHeight() + i
+ }
+ }
+ t.Fatalf("row %q has no door that does act %d: %+v", key, kind, line.side.doors)
+ }
+ t.Fatalf("the column does not draw row %q", key)
+ return 0, 0
+}
+
// threadScenario is the owner's measured afternoon: an older note, then one
// question to three members as ONE entry, each member woken on it, three
// replies, two finishings and the manager woken by them, all linked the way
@@ -62,52 +103,78 @@ func threadScenario(t *testing.T, a *app, harbor, price, rail string) string {
return q
}
-// ONE QUESTION TO THREE MEMBERS IS ONE THREAD, at the top. Its header names
-// all three, its words are on their own line, each answer is one row of a
-// tree with its finishing folded in as ✓, a member woken with nothing said yet
-// reads working…, no wake is a row, and the older thread is under it.
-func TestTrafficThreadOneQuestionIsOneThreadAtTheTop(t *testing.T) {
+// ONE QUESTION TO THREE MEMBERS IS ONE ROW OF WORK, at the top. The row names
+// whom it went to and what it asked, says the state its answers leave it in
+// and how many messages it holds, and folds its replies behind ▸. Laid open,
+// each member is one `↳` line with its finishing folded in as ✓, a member
+// woken with nothing said yet reads working…, and no wake or finishing is a
+// line of its own. The chatter older than it is the one General row under it.
+func TestTrafficThreadOneQuestionIsOneWorkRow(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 180, 40
price, _ := trafficHandle(t, a, harbor, "openrouter")
rail, _ := trafficHandle(t, a, harbor, "Refactor")
- threadScenario(t, a, harbor, price, rail)
+ q := threadScenario(t, a, harbor, price, rail)
rows := railLines(t, a)
- head := railRowOf(rows, trafficWord)
- top := railRowOf(rows, teamManagerGlyph+" manager → @"+price+" @"+rail+" @review do")
+ joined := strings.Join(rows, "\n")
+ head := railRowOf(rows, sideTrafficWord)
+ top := railRowOf(rows, "@"+price+" +2 Pleas")
if head < 0 || top != head+1 {
- t.Fatalf("the question's thread is not straight under the header (%d, %d):\n%s", head, top, strings.Join(rows, "\n"))
+ t.Fatalf("the question's row is not straight under the header (%d, %d):\n%s", head, top, joined)
+ }
+ // AT THE COLUMN'S WIDEST THE COUNT GIVES WAY TO THE WORDS, and the hint
+ // line says it.
+ if !strings.Contains(rows[top], "running "+glyphShut) {
+ t.Fatalf("the row does not say its state and its fold:\n%s", joined)
}
- if !strings.Contains(rows[top+1], "Please provide a brief status") {
- t.Fatalf("the question's words are not on their own line:\n%s", strings.Join(rows, "\n"))
+ if hint := a.sideRowOf("thread/" + q).hint; !strings.Contains(hint, "running · 3 msgs") {
+ t.Fatalf("the row's hint does not say its state and its messages: %q", hint)
+ }
+ general := railRowOf(rows, "General")
+ if general != top+1 || !strings.Contains(rows[general], "1 msg "+glyphShut) {
+ t.Fatalf("the older chatter is not one General row under the work:\n%s", joined)
+ }
+ for _, never := range []string{"woke", "finished", "Status update", "an older aside"} {
+ if strings.Contains(joined, never) {
+ t.Fatalf("%q is drawn on a folded column:\n%s", never, joined)
+ }
}
+
+ x, y := sideRowDoor(t, a, "thread/"+q, sideActThread)
+ sideClick(t, a, x, y)
+ rows = railLines(t, a)
+ joined = strings.Join(rows, "\n")
want := []string{
- "├ @" + price + " ✓ Status update: pri",
- "├ @" + rail + " working…",
- "└ @review ✓ Status update: two",
- }
- // The tree is in the order things happened: each member's line stands
- // where it was first woken, and its answer takes that line.
- for i, w := range want {
- if !strings.Contains(rows[top+2+i], w) {
- t.Fatalf("row %d of the tree reads %q, want %q:\n%s", i, rows[top+2+i], w, strings.Join(rows, "\n"))
+ "↳ ",
+ "@" + price + " → " + teamManagerGlyph + " ✓ Status update",
+ "@" + rail + " → " + teamManagerGlyph + " working…",
+ "@review → " + teamManagerGlyph + " ✓ Status update",
+ }
+ for i, w := range want[1:] {
+ if r := rows[top+1+i]; !strings.Contains(r, want[0]) || !strings.Contains(r, w) {
+ t.Fatalf("reply %d reads %q, want %q:\n%s", i, r, w, joined)
}
}
- joined := strings.Join(rows, "\n")
- for _, never := range []string{"woke", "finished"} {
+ for _, never := range []string{"woke", ": finished", ": ✓ finished"} {
if strings.Contains(joined, never) {
- t.Errorf("%q is drawn as a row:\n%s", never, joined)
+ t.Fatalf("%q is drawn as a line of its own:\n%s", never, joined)
}
}
- older := railRowOf(rows, teamManagerGlyph+" manager → @"+price+" fyi")
- if older <= top+4 || !strings.Contains(rows[older+1], "an older aside") {
- t.Fatalf("the older thread is not under the newer one (%d):\n%s", older, joined)
+ if !strings.Contains(rows[top], glyphOpen) || railRowOf(rows, "General") != top+4 {
+ t.Fatalf("the open row does not say it is open, or General moved off its place:\n%s", joined)
+ }
+ // AND THE SAME DOOR FOLDS IT AGAIN.
+ x, y = sideRowDoor(t, a, "thread/"+q, sideActThread)
+ sideClick(t, a, x, y)
+ if joined := strings.Join(railLines(t, a), "\n"); strings.Contains(joined, "Status update") {
+ t.Fatalf("the second press did not fold the replies:\n%s", joined)
}
}
-// A MEMBER ASKING IS THE ONE AMBER LINE; A FAILURE IS ✗; AN OLD ENTRY THAT
-// ANSWERS NOTHING IS ONE LINE OF ITS OWN.
-func TestTrafficThreadEventsFoldAndOldEntriesStand(t *testing.T) {
+// A MEMBER ASKING IS THE BAND'S ONE AMBER ROW, and its thread says asking; a
+// failure after it is a ✗ in ordinary ink and nothing on the column is amber.
+// An old finishing that answers nothing is chatter, under General.
+func TestTrafficThreadAnAskIsTheBandAndAFailureIsInk(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 180, 40
price, _ := trafficHandle(t, a, harbor, "openrouter")
@@ -119,122 +186,144 @@ func TestTrafficThreadEventsFoldAndOldEntriesStand(t *testing.T) {
trafficReadNow(t, a)
rows := railLines(t, a)
joined := strings.Join(rows, "\n")
- ask := railRowOf(rows, "└ @"+price+" asking: may I run")
- if ask < 0 {
- t.Fatalf("the asking member is not its thread's line:\n%s", joined)
+ head := railRowOf(rows, sideTrafficWord)
+ ask := railRowOf(rows, "@"+price+" → "+teamManagerGlyph+" may I run")
+ if head < 0 || ask != head+1 {
+ t.Fatalf("the ask is not the band's row under the header (%d, %d):\n%s", head, ask, joined)
+ }
+ if strings.HasSuffix(strings.TrimRight(rows[ask], " "), "now") || strings.Contains(rows[ask], "asks:") {
+ t.Fatalf("the band row still carries its age or the old asks lead: %q", rows[ask])
}
- old := railRowOf(rows, "@"+rail+" ✓ finished")
- if old < 0 || old < ask {
- t.Fatalf("the old unlinked finishing is not a line of its own under the newer thread:\n%s", joined)
+ for _, r := range a.side.last {
+ if strings.HasPrefix(r.key, "ask/") && !strings.Contains(r.hint, "now") {
+ t.Fatalf("the band's hint does not carry the age: %q", r.hint)
+ }
+ }
+ work := railRowOf(rows, teamManagerGlyph+" → @"+price)
+ if work <= ask || !strings.Contains(rows[work], "asking") || !strings.Contains(rows[work], "run the") {
+ t.Fatalf("the thread does not say it is asking, under the band:\n%s", joined)
+ }
+ if general := railRowOf(rows, "General"); general <= work {
+ t.Fatalf("the old finishing is not chatter under the work:\n%s", joined)
}
- // The amber is the asking line's words and nothing else's.
- a.traffic.cache = trafficCache{}
- body, _, _, _ := a.trafficBody(mustTeam(t, a, harbor), 20, 60)
probe := a.pal.ask("x")
warm := probe[:strings.Index(probe, "x")]
- amber := 0
- for _, r := range body {
- if warm != "" && strings.Contains(r, warm) {
- amber++
+ amber := func() int {
+ n := 0
+ for _, r := range strings.Split(sidePainted(a), "\n") {
+ if warm != "" && strings.Contains(r, warm) {
+ n++
+ }
}
+ return n
}
- if warm != "" && amber != 1 {
- t.Fatalf("%d rows carry the needs-you amber", amber)
+ if warm != "" && amber() != 1 {
+ t.Fatalf("%d rows carry the needs-you amber, want the band's one", amber())
}
+
trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindEvent, From: price, To: teamstore.ToManager, State: teamstore.StateFailed, Text: "the migration failed: lock timeout", Answers: q})
trafficReadNow(t, a)
- if rows := railLines(t, a); railRowOf(rows, "└ @"+price+" "+tokens.GlyphFailed+" the migration failed") < 0 {
+ rows = railLines(t, a)
+ joined = strings.Join(rows, "\n")
+ if strings.Contains(joined, "asks: may I run") {
+ t.Fatalf("an answered ask is still in the band:\n%s", joined)
+ }
+ work = railRowOf(rows, teamManagerGlyph+" → @"+price)
+ if work < 0 || !strings.Contains(rows[work], "failed") || !strings.Contains(rows[work], "run the") {
+ t.Fatalf("the thread does not say it failed:\n%s", joined)
+ }
+ if warm != "" && amber() != 0 {
+ t.Fatalf("a failure is painted amber on %d rows", amber())
+ }
+ x, y := sideRowDoor(t, a, "thread/"+q, sideActThread)
+ sideClick(t, a, x, y)
+ if rows := railLines(t, a); railRowOf(rows, "@"+price+" → "+teamManagerGlyph+" "+tokens.GlyphFailed+" the migration") < 0 {
t.Fatalf("the failure did not fold into the member's line:\n%s", strings.Join(rows, "\n"))
}
}
-// A MESSAGE'S WORDS LAY OUT IN FULL ON A PRESS AND FOLD ON THE NEXT, and the
-// hint line says them whole before either.
-func TestTrafficThreadWordsExpandAndCollapse(t *testing.T) {
+// EVERY ROW IS ONE LINE AND THE HINT SAYS IT WHOLE. A long reply is cut with
+// an ellipsis in the column and said in full on the hint line; enter on a
+// thread with the column holding the keyboard lays it open and folds it, and
+// neither moves the conversation in front.
+func TestTrafficThreadRowsAreOneLineAndEnterFoldsThem(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 180, 40
price, _ := trafficHandle(t, a, harbor, "openrouter")
long := "Status update: " + strings.Repeat("the scrape is cached and every price is checked twice ", 3) + "END"
q, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "status?"})
- trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: long, Answers: q})
+ reply, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: long, Answers: q})
trafficReadNow(t, a)
- rows := railLines(t, a)
- reply := railRowOf(rows, "└ @"+price)
- if reply < 0 || strings.Contains(strings.Join(rows, "\n"), "END") {
- t.Fatalf("the reply is not one cut row:\n%s", strings.Join(rows, "\n"))
- }
- cols := a.trafficWidth()
- x := a.width - cols + 2 + len("└ @"+price+" ✓ ") + 2
- at, ok := a.trafficHoverAt(x, reply)
- if !ok || at.kind != hoverTraffic {
- t.Fatalf("the words do not answer the pointer: %+v", at)
- }
- a.hot = at
- if words := a.dockHoverWords(); !strings.Contains(words, "END") {
- t.Fatalf("the hint line does not say the words whole: %q", words)
- }
- a.hot = hoverAt{}
front := a.frontTabKey()
- if _, took := a.trafficPress(x, reply); !took {
- t.Fatal("a press on the words was not taken")
+
+ drive(t, a, altT())
+ if !a.railHold {
+ t.Fatal("alt+t did not give the column the keyboard")
}
- if a.frontTabKey() != front {
- t.Fatal("laying the words out moved the focus")
+ _ = railLines(t, a) // the loop draws a frame before any key reaches it
+ a.railWhere = railSpot{key: "thread/" + q}
+ drive(t, a, key("enter"))
+ rows := railLines(t, a)
+ y := railRowOf(rows, "@"+price+" → "+teamManagerGlyph+" Status update")
+ if y < 0 || strings.Contains(strings.Join(rows, "\n"), "END") || !strings.Contains(rows[y], "…") {
+ t.Fatalf("enter did not lay the reply open as one cut line:\n%s", strings.Join(rows, "\n"))
}
- open := railLines(t, a)
- if !strings.Contains(strings.Join(open, "\n"), "END") || railRowOf(open, "END") <= reply {
- t.Fatalf("the press did not lay the words out under the row:\n%s", strings.Join(open, "\n"))
+ if a.frontTabKey() != front || !a.railHold {
+ t.Fatal("laying the thread open moved the focus or took the keyboard back")
}
- if _, took := a.trafficPress(x, reply); !took {
- t.Fatal("the second press was not taken")
+ x, ry := sideRowOn(t, a, "reply/"+reply)
+ a.setHover(x, ry)
+ if words := a.dockHoverWords(); !strings.Contains(words, "END") {
+ t.Fatalf("the hint line does not say the reply whole: %q", words)
}
- if folded := railLines(t, a); strings.Contains(strings.Join(folded, "\n"), "END") {
- t.Fatalf("the second press did not fold the words:\n%s", strings.Join(folded, "\n"))
+ a.dropHover()
+ a.railWhere = railSpot{key: "thread/" + q}
+ drive(t, a, key("enter"))
+ if joined := strings.Join(railLines(t, a), "\n"); strings.Contains(joined, "Status update") {
+ t.Fatalf("the second enter did not fold the thread:\n%s", joined)
}
}
-// A HANDLE ON THE RAIL IS A LINK, inked as the chat inks one, and a press on it
-// opens its member; an address that is nobody's is plain words.
+// A HANDLE ON THE COLUMN IS A LINK, inked as the chat inks one, and a press on
+// it opens its member at the message; an address that is nobody's is plain
+// words.
func TestTrafficThreadHandlesAreLinks(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 180, 40
price, priceKey := trafficHandle(t, a, harbor, "openrouter")
rail, _ := trafficHandle(t, a, harbor, "Refactor")
- threadScenario(t, a, harbor, price, rail)
+ q := threadScenario(t, a, harbor, price, rail)
+ a.sideToggleThread(sideThreadKey(harbor, q))
_ = railLines(t, a)
- d := a.traffic.drawn
- var links, strangers int
- for _, row := range d.doors {
- for _, door := range row {
- if door.link {
+ painted := sidePainted(a)
+ if !strings.Contains(painted, teamLinkInk(a.pal, "@"+price)) {
+ t.Fatal("a handle is not inked as a chat link is")
+ }
+ if strings.Contains(painted, teamLinkInk(a.pal, "@review")) {
+ t.Fatal("an address that is nobody's is inked as a link")
+ }
+ links := 0
+ for _, row := range a.side.last {
+ for _, d := range row.doors {
+ if d.act.kind == sideActJump {
links++
- if door.member == "" {
- t.Fatalf("a link opens nobody: %+v", door)
+ if d.act.key == "" || d.act.entry == "" {
+ t.Fatalf("a link opens nobody or nowhere: %+v", d)
}
}
}
}
- for _, row := range a.traffic.cache.out {
- if strings.Contains(row, teamLinkInk(a.pal, "@review")) {
- strangers++
- }
- }
- if links < 4 || strangers != 0 {
- t.Fatalf("%d links drawn, %d inked strangers", links, strangers)
+ if links < 3 {
+ t.Fatalf("%d handle links drawn", links)
}
- if !strings.Contains(strings.Join(a.traffic.cache.out, "\n"), teamLinkInk(a.pal, "@"+price)) {
- t.Fatal("a handle is not inked as a chat link is")
- }
- rows := railLines(t, a)
- y := railRowOf(rows, "├ @"+price)
- x := a.width - a.trafficWidth() + 2 + 3
- at, _ := a.trafficHoverAt(x, y)
- a.hot = at
+ x, y := sideRowDoor(t, a, "thread/"+q, sideActJump)
+ a.setHover(x, y)
if words := a.dockHoverWords(); !strings.Contains(words, "@"+price) || !strings.Contains(words, "click") {
t.Fatalf("the handle's hint says %q", words)
}
- a.hot = hoverAt{}
- if _, took := a.trafficPress(x, y); !took || a.frontTabKey() != priceKey {
+ a.dropHover()
+ sideClick(t, a, x, y)
+ if a.frontTabKey() != priceKey {
t.Fatalf("the handle went to %q, want %q", a.frontTabKey(), priceKey)
}
}
diff --git a/internal/tui3/teamthreadcard_test.go b/internal/tui3/teamthreadcard_test.go
index de4e440fa2..674c51b554 100644
--- a/internal/tui3/teamthreadcard_test.go
+++ b/internal/tui3/teamthreadcard_test.go
@@ -42,7 +42,7 @@ func sendRow(a *app, args, output string) {
func TestThreadCardGrowsAsRepliesLand(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 160, 40
- a.traffic.hidden = true
+ a.railAway = true
price, _ := trafficHandle(t, a, harbor, "openrouter")
rail, _ := trafficHandle(t, a, harbor, "Refactor")
q, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager,
@@ -80,7 +80,7 @@ func TestThreadCardGrowsAsRepliesLand(t *testing.T) {
func TestThreadCardAnswerExpandsOnPress(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 160, 40
- a.traffic.hidden = true
+ a.railAway = true
price, priceKey := trafficHandle(t, a, harbor, "openrouter")
q, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "status?"})
long := strings.Repeat("every price is checked twice and cached for an hour ", 6) + "END"
@@ -130,7 +130,7 @@ func TestThreadCardAnswerExpandsOnPress(t *testing.T) {
func TestThreadCardFoldsTheDeliveredAnswers(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 160, 40
- a.traffic.hidden = true
+ a.railAway = true
price, _ := trafficHandle(t, a, harbor, "openrouter")
q, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "status?"})
trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: "prices are cached", Answers: q})
@@ -178,3 +178,29 @@ func TestThreadCardMemberMirror(t *testing.T) {
t.Fatalf("the member's chat does not mirror its answer under the manager's line:\n%s", text)
}
}
+
+// A MANAGER'S STEPS ARE SAID AS TEAM WORK. Its fold read `▾ team_send 1 call
+// … 1 call`: the tool's own name where the work goes, and the count twice.
+func TestTeamToolCaptionsSayTheWork(t *testing.T) {
+ send := func(to string) entry {
+ return entry{kind: entryTool, tool: "team_send", status: toolOK, detail: toolDetail{Args: `{"to":"` + to + `","text":"status?"}`}}
+ }
+ if got := composeCaption([]entry{send("@scrape model")}, 0, 1); got != "messaging @scrape @model" {
+ t.Fatalf("one team_send is captioned %q", got)
+ }
+ if got := captionPast(composeCaption([]entry{send("everyone")}, 0, 1)); got != "messaged everyone" {
+ t.Fatalf("a finished broadcast is captioned %q", got)
+ }
+ if got := composeCaption([]entry{send("scrape"), send("model")}, 0, 2); got != "sending 2 messages" {
+ t.Fatalf("two sends are captioned %q", got)
+ }
+ start := entry{kind: entryTool, tool: "team_start", status: toolOK, detail: toolDetail{Args: `{"handle":"lexer","brief":"x"}`}}
+ if got := composeCaption([]entry{start}, 0, 1); got != "starting @lexer" {
+ t.Fatalf("a start is captioned %q", got)
+ }
+ for _, e := range []entry{send("a"), start} {
+ if got := composeCaption([]entry{e}, 0, 1); strings.Contains(got, "team_") {
+ t.Fatalf("a caption names the tool: %q", got)
+ }
+ }
+}
diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go
index dc9ea2f2b8..6054c49ef2 100644
--- a/internal/tui3/teamtraffic.go
+++ b/internal/tui3/teamtraffic.go
@@ -98,34 +98,22 @@ type trafficState struct {
edits int
wrote int
// seen is, per team, the newest entry the person has had in front of them
- // on the rail, which is what the closed edge counts past.
+ // on the side column's Traffic, which is what its word and the closed edge
+ // count past (sidecol.go).
seen map[string]string
- // hidden says the person put the rail away on a frame wide enough for it,
- // and over that the traffic is laid over the body as a card on a frame
- // that is not. Both are this window's, in memory: another window keeps its
- // own, as it keeps its own tabs.
- hidden bool
- over bool
- // tasks says the column shows the manager's own tasks instead of the
- // traffic (teamrail.go's [app.trafficTasksShowing]); it counts only while
- // the manager has live tasks. Memory only, and this window's.
- tasks bool
// jump is a jump to a message waiting for its conversation to open, and
// landing the message lifted after one (teamjump.go).
jump trafficJumpTo
landing trafficLanding
- // open is every message the person laid out in full on the rail or under
- // a thread card, by team and entry id (teamthread.go), and opened counts the
- // presses that changed it, which is what the rail's cache keys on. Memory
- // only, and this window's.
+ // open is every message the person laid out in full under a thread card,
+ // by team and entry id (teamthreadcard.go), and opened counts the presses
+ // that changed it, which is what the cards key on. Memory only, and this
+ // window's.
open map[string]bool
opened int
// version counts the reads that changed any team's cache, which is what a
// thread card in the conversation keys on (teamthreadcard.go).
version int
- // The last frame's rail, for the pointer (teamrail.go).
- drawn trafficDrawn
- cache trafficCache
}
// trafficTickMsg is one turn of the Traffic clock coming round.
diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go
index 3fa51eb0bf..1ad937c771 100644
--- a/internal/tui3/teamtraffic_test.go
+++ b/internal/tui3/teamtraffic_test.go
@@ -73,19 +73,21 @@ func trafficReadNow(t *testing.T, a *app) {
teamsFlush(t, a)
}
-// THE RAIL IS THE CACHE, BESIDE THE MANAGER, AND IT HOLDS THE RIGHT. With the
-// manager in front on a wide frame, the right of the body is the team's
-// traffic under a `Traffic` header, the newest thread straight under it, each
-// headed `from → to do|fyi age` over its words; the person's own lines are
-// not drawn; the conversation is narrowed by exactly the rail; the composer
-// says the words go to the manager; and a handle pressed goes to its member.
-func TestTrafficRailBesideTheManager(t *testing.T) {
+// THE COLUMN IS THE CACHE, BESIDE THE MANAGER, AND IT OPENS ON THE TRAFFIC.
+// With the manager in front on a wide frame the right of the body is the side
+// column, its header `Tasks 0 · Traffic N` with the Traffic in front; the
+// directive is a row of work, the note that answers nothing is General's; the
+// person's own lines are not drawn; the conversation is narrowed by exactly
+// the column; the composer says the words go to the manager; and a handle
+// pressed goes to its member, where the same column, the same width, shows
+// that member's own chat.
+func TestTrafficColumnBesideTheManager(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 180, 40
price, priceKey := trafficHandle(t, a, harbor, "openrouter")
rail, _ := trafficHandle(t, a, harbor, "Refactor")
+ d, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: rail, Text: "take the scope model"})
trafficAppend(t, a, harbor,
- teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: rail, Text: "take the scope model"},
teamstore.Entry{Kind: teamstore.KindNote, From: price, To: rail, Text: "prices are in"},
teamstore.Entry{Kind: teamstore.KindYou, From: teamstore.FromYou, To: teamstore.ToManager, Text: "my own words"},
)
@@ -93,66 +95,67 @@ func TestTrafficRailBesideTheManager(t *testing.T) {
if got := len(a.traffic.rows[harbor]); got != 3 {
t.Fatalf("the cache holds %d entries", got)
}
-
- frame, _, _ := a.frame()
- rows := strings.Split(ansi.Strip(frame), "\n")
- cols := a.trafficWidth()
- if cols < trafficColsMin || a.bodyWidth() != a.width-a.railWidth()-cols {
- t.Fatalf("the rail takes %d columns and leaves the body %d of %d", cols, a.bodyWidth(), a.width)
- }
- var head, directive, note = -1, -1, -1
- for y, r := range rows {
- right := plainCells(r, a.width-cols, a.width)
- if strings.Contains(right, trafficWord) && strings.Contains(right, "hide "+trafficKey) {
- head = y
- }
- if strings.Contains(right, teamManagerGlyph+" manager → @"+rail+" do") {
- directive = y
- }
- if strings.Contains(right, "@"+price+" → @"+rail+" fyi") {
- note = y
- }
- if strings.Contains(right, "my own words") {
- t.Fatalf("the person's own line is on the rail: %q", right)
- }
+ if a.sideView() != sideTraffic {
+ t.Fatal("a manager's column does not open on the Traffic")
}
- if head < 0 || directive < 0 || note != head+1 || directive <= note {
- t.Fatalf("the rail does not draw its header and the newest thread straight under it (%d, %d, %d):\n%s", head, directive, note, strings.Join(rows, "\n"))
+ a.sideToggleThread(sideThreadKey(harbor, sideGeneral))
+ rows := railLines(t, a)
+ joined := strings.Join(rows, "\n")
+ cols := a.railWidth()
+ if cols != sideColsFor(a.width) || a.bodyWidth() != a.width-cols {
+ t.Fatalf("the column takes %d columns and leaves the body %d of %d", cols, a.bodyWidth(), a.width)
}
- if !strings.Contains(plainCells(rows[note+1], a.width-cols, a.width), "prices are in") ||
- !strings.Contains(plainCells(rows[directive+1], a.width-cols, a.width), "take the scope model") {
- t.Fatalf("a message is not on its own line under its header:\n%s", strings.Join(rows, "\n"))
+ head := railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord)
+ general := railRowOf(rows, "General")
+ work := railRowOf(rows, "@"+rail+" take the scope model")
+ note := railRowOf(rows, "@"+price+" → ")
+ if head < 0 || !strings.HasSuffix(rows[head], sideHideKey) || general != head+1 || note != general+1 || work <= note {
+ t.Fatalf("the column does not draw its header, General open with the note, and the work under it (%d, %d, %d, %d):\n%s", head, general, note, work, joined)
}
+ if strings.Contains(joined, "my own words") {
+ t.Fatalf("the person's own line is on the column:\n%s", joined)
+ }
+ frame, _, _ := a.frame()
if !strings.Contains(ansi.Strip(frame), "to "+teamManagerGlyph+" manager") {
t.Fatalf("the composer does not say where the words go:\n%s", ansi.Strip(frame))
}
- // Under the pointer the handle names its member, and the words say
- // themselves whole.
- at, ok := a.trafficHoverAt(a.width-cols+2, note)
- if !ok || at.kind != hoverTraffic {
- t.Fatalf("the handle does not answer the pointer: %+v", at)
- }
- a.hot = at
- if words := a.dockHoverWords(); !strings.Contains(words, "@"+price) || !strings.Contains(words, "click") {
+ // Under the pointer the handle names its member, and the row says itself
+ // whole.
+ x, y := sideDoorOf(t, a, sideActJump)
+ a.setHover(x, y)
+ if a.hot.kind != hoverSide || a.hot.index < 0 {
+ t.Fatalf("the handle does not answer the pointer: %+v", a.hot)
+ }
+ if words := a.dockHoverWords(); !strings.Contains(words, "@") || !strings.Contains(words, "click") {
t.Fatalf("the hint line over the handle says %q", words)
}
- a.hot, _ = a.trafficHoverAt(a.width-cols+4, note+1)
- if words := a.dockHoverWords(); !strings.Contains(words, "prices are in") {
- t.Fatalf("the hint line over the words says %q", words)
+ wx, wy := sideRowOn(t, a, "thread/"+d)
+ a.setHover(wx, wy)
+ if words := a.dockHoverWords(); !strings.Contains(words, "take the scope model") {
+ t.Fatalf("the hint line over the row says %q", words)
}
- a.hot = hoverAt{}
+ a.dropHover()
// The note's handle goes to the member who wrote it.
- if _, took := a.trafficPress(a.width-cols+2, note); !took {
- t.Fatal("the rail did not take a press on its row")
- }
+ px, py := sideRowDoor(t, a, railKeyOfReply(t, a, "prices are in"), sideActJump)
+ sideClick(t, a, px, py)
if a.frontTabKey() != priceKey {
- t.Fatalf("the row went to %q, want %q", a.frontTabKey(), priceKey)
+ t.Fatalf("the handle went to %q, want %q", a.frontTabKey(), priceKey)
+ }
+ // AND IN THE MEMBER'S CHAT THE COLUMN HOLDS STILL: the same width, the
+ // tasks in front, and its Traffic that member's own messages.
+ if a.railWidth() != cols || a.bodyWidth() != a.width-cols {
+ t.Fatalf("the column moved beside a member: %d, was %d", a.railWidth(), cols)
+ }
+ if a.sideKind() != sideKindMember || a.sideView() != sideTasks {
+ t.Fatalf("a member's column is kind %d view %d", a.sideKind(), a.sideView())
}
- // And away from the manager the column is gone and the body has it back.
- if a.trafficWidth() != 0 || a.bodyWidth() != a.width-a.railWidth() {
- t.Fatalf("the rail stayed beside a member: %d", a.trafficWidth())
+ a.sideSetView(sideTraffic)
+ rows = railLines(t, a)
+ joined = strings.Join(rows, "\n")
+ if railRowOf(rows, "@"+rail+" prices are in") < 0 || strings.Contains(joined, "take the scope model") {
+ t.Fatalf("the member's Traffic is not its own messages only:\n%s", joined)
}
// A member of a managed team is told where its words go too.
if got := a.trafficHint(); got != "to @"+price {
@@ -160,117 +163,122 @@ func TestTrafficRailBesideTheManager(t *testing.T) {
}
}
-// AT 110 COLUMNS THE RAIL STANDS. A 110-column laptop terminal is not narrow:
-// the Traffic takes the right column, and the task column is not on the frame
-// at all, not even as an edge.
-func TestTrafficRailStandsAt110(t *testing.T) {
- a, harbor, _, _ := trafficApp(t)
+// railKeyOfReply is the key of the drawn row that says words.
+func railKeyOfReply(t *testing.T, a *app, words string) string {
+ t.Helper()
+ view, _ := a.railDrawnView(a.viewHeight())
+ for _, line := range view {
+ if line.side != nil && strings.Contains(ansi.Strip(line.text), words) {
+ return line.side.key
+ }
+ }
+ t.Fatalf("no row of the column says %q", words)
+ return ""
+}
+
+// AT 110 COLUMNS THE COLUMN STANDS. A 110-column laptop terminal is not
+// narrow: the one column takes the right, at the width every chat gives it,
+// and leaves the conversation its floor.
+func TestTrafficColumnStandsAt110(t *testing.T) {
+ a, _, _, _ := trafficApp(t)
a.width, a.height = 110, 30
a.welcome.open = false
trafficReadNow(t, a)
- _ = harbor
- if got := a.trafficWidth(); got < trafficColsMin {
- t.Fatalf("at 110 the rail has %d columns", got)
+ if got := a.railWidth(); got != sideColsFor(110) || got < sideColsMin {
+ t.Fatalf("at 110 the column has %d columns", got)
}
- if a.bodyWidth() < trafficBodyFloor {
+ if a.bodyWidth() < sideBodyFloor {
t.Fatalf("the conversation is left %d columns", a.bodyWidth())
}
- if a.railWidth() != 0 || a.railShowing() || a.railStowed() {
- t.Fatalf("the task column still stands beside the rail: %d", a.railWidth())
+ if !a.railShowing() || a.railStowed() {
+ t.Fatal("the column does not stand at 110")
+ }
+ rows := railLines(t, a)
+ if railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord) < 0 {
+ t.Fatalf("the header does not carry both words:\n%s", strings.Join(rows, "\n"))
}
}
-// THE RAIL IS PUT AWAY BY A WORD AND BROUGHT BACK BY ITS EDGE OR ITS KEY. The
-// header's `hide` puts the column away and leaves the edge, the word Traffic
-// with a count of what came in since; the key brings it back; and asking the
-// task column back (ctrl+g's road) with no tasks leaves the Traffic where it is.
-func TestTrafficRailHidesAndShows(t *testing.T) {
+// THE COLUMN IS PUT AWAY BY ITS KEY AND BROUGHT BACK BY ITS EDGE OR ITS KEY.
+// The header's `alt+l` puts it away and leaves the edge with a count of what
+// came in since; the edge's hint says so; alt+l brings it back, and ctrl+g is
+// the same key under its older name.
+func TestTrafficColumnHidesAndShows(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 160, 40
price, _ := trafficHandle(t, a, harbor, "openrouter")
trafficReadNow(t, a)
- _, _, _ = a.frame()
- d := a.traffic.drawn
- if d.mode != trafficColumn || !d.hide.pressable() {
- t.Fatalf("the column has no hide word: %+v", d)
- }
- if _, took := a.trafficPress(d.hide.from, a.bodyTop()+d.hideY); !took || !a.traffic.hidden {
- t.Fatal("hide did not put the rail away")
+ _ = railLines(t, a)
+ x, y := sideDoorOf(t, a, sideActHide)
+ sideClick(t, a, x, y)
+ if !a.railAway || a.railShowing() {
+ t.Fatal("the header's key did not put the column away")
}
- if got := a.trafficWidth(); got != trafficGripCols {
- t.Fatalf("a hidden rail costs %d columns", got)
+ if got := a.railWidth(); got != railGripCols {
+ t.Fatalf("a hidden column costs %d columns", got)
}
trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: "done"})
trafficReadNow(t, a)
frame, _, _ := a.frame()
var edge strings.Builder
for _, r := range strings.Split(ansi.Strip(frame), "\n") {
- edge.WriteString(strings.TrimSpace(plainCells(r, a.width-trafficGripCols, a.width)))
+ edge.WriteString(strings.TrimSpace(plainCells(r, a.width-railGripCols, a.width)))
}
- if !strings.Contains(edge.String(), trafficWord+"1") {
- t.Fatalf("the edge does not say Traffic and its one new entry: %q", edge.String())
+ if !strings.Contains(edge.String(), "1") {
+ t.Fatalf("the edge does not count its one new entry: %q", edge.String())
}
- a.hot = hoverAt{kind: hoverTrafficGrip}
- if words := a.dockHoverWords(); !strings.Contains(words, "Show the team's traffic") || !strings.Contains(words, trafficKey) {
+ a.hot = hoverAt{kind: hoverRailGrip}
+ if words := a.dockHoverWords(); !strings.Contains(words, "Show this column") || !strings.Contains(words, trafficKey) || !strings.Contains(words, "1 new") {
t.Fatalf("the edge's hint says %q", words)
}
a.hot = hoverAt{}
- if _, took := a.trafficKeyPress(tea.KeyPressMsg{Code: 'l', Mod: tea.ModAlt}); !took || a.traffic.hidden {
- t.Fatalf("%s did not bring the rail back", trafficKey)
+ if _, took := a.trafficKeyPress(tea.KeyPressMsg{Code: 'l', Mod: tea.ModAlt}); !took || a.railAway {
+ t.Fatalf("%s did not bring the column back", trafficKey)
}
- // ASKING FOR THE TASKS BACK, with no tasks of the manager's own, leaves the
- // Traffic holding the column: there is nothing to swap to.
- a.railStow(false)
- if a.traffic.hidden || !a.trafficHoldsRail() {
- t.Fatal("asking for the tasks back put the Traffic away")
+ drive(t, a, ctrlG())
+ if !a.railAway {
+ t.Fatal("ctrl+g is not the same key")
+ }
+ drive(t, a, ctrlG())
+ if a.railAway || a.railWidth() != sideColsFor(a.width) {
+ t.Fatal("ctrl+g did not bring the column back")
}
}
-// ON A NARROW FRAME THE RAIL IS AN EDGE, and the edge lays a card over the
-// lower body: bordered, titled Traffic, `Close esc` in its foot, with the top
-// of the conversation still drawn above it. esc takes it away, and a row
-// pressed goes to its member.
-func TestTrafficRailNarrowIsACard(t *testing.T) {
+// ON A NARROW FRAME THERE IS NO COLUMN AND NO EDGE, and the key lays the
+// column over the frame: the same header, the same rows. esc takes it away,
+// and a handle pressed on it goes to its member and takes the overlay with it.
+func TestTrafficColumnNarrowIsAnOverlay(t *testing.T) {
a, harbor, _, _ := trafficApp(t)
a.width, a.height = 80, 30
price, priceKey := trafficHandle(t, a, harbor, "openrouter")
- trafficAppend(t, a, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: price, To: teamstore.ToManager, Text: "done with the scrape"})
+ q, _ := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindDirective, From: teamstore.FromManager, To: price, Text: "scrape the prices"})
trafficReadNow(t, a)
- if got := a.trafficWidth(); got != trafficGripCols {
- t.Fatalf("a narrow frame gave the rail %d columns", got)
+ if a.railWidth() != 0 || a.railStowed() {
+ t.Fatalf("a narrow frame gave the column %d columns", a.railWidth())
}
frame, _, _ := a.frame()
- if strings.Contains(ansi.Strip(frame), "done with the scrape") {
- t.Fatal("the traffic is drawn before the edge was pressed")
+ if strings.Contains(ansi.Strip(frame), "scrape the prices") {
+ t.Fatal("the traffic is drawn before the key was pressed")
}
- top := a.bodyTop()
- if _, took := a.trafficPress(a.width-1, top+2); !took || !a.traffic.over {
- t.Fatal("the edge did not lay the card over the body")
+ drive(t, a, key(trafficKey))
+ if !a.railFull() {
+ t.Fatal("alt+l did not lay the column over the frame")
}
frame, _, _ = a.frame()
- rows := strings.Split(ansi.Strip(frame), "\n")
- at, card := -1, -1
- for y, r := range rows {
- if strings.Contains(r, "@"+price+" → ") && y+1 < len(rows) && strings.Contains(rows[y+1], "done with the scrape") {
- at = y
- }
- if strings.Contains(r, trafficWord) && card < 0 && y > top {
- card = y
- }
- }
- if at < 0 || card <= top || !strings.Contains(ansi.Strip(frame), "Close esc") {
- t.Fatalf("no card with its close word over the lower body (row %d, card %d):\n%s", at, card, strings.Join(rows, "\n"))
+ if !strings.Contains(ansi.Strip(frame), "scrape the prices") || !strings.Contains(ansi.Strip(frame), sideTrafficWord) {
+ t.Fatalf("the overlay does not draw the Traffic:\n%s", ansi.Strip(frame))
}
- if _, took := a.trafficKeyPress(tea.KeyPressMsg{Code: tea.KeyEscape}); !took || a.traffic.over {
- t.Fatal("esc did not close the card")
+ drive(t, a, key("esc"))
+ if a.railFull() {
+ t.Fatal("esc did not take the overlay away")
}
- a.trafficShow(true)
+ drive(t, a, key(trafficKey))
_, _, _ = a.frame()
- if _, took := a.trafficPress(6, at); !took {
- t.Fatal("a row of the card did not take the press")
- }
- if a.frontTabKey() != priceKey || a.traffic.over {
- t.Fatalf("the row went to %q with the card still over: %v", a.frontTabKey(), a.traffic.over)
+ x, y := sideRowDoor(t, a, "thread/"+q, sideActJump)
+ sideClick(t, a, x, y)
+ if a.frontTabKey() != priceKey || a.railFull() {
+ t.Fatalf("the handle went to %q with the overlay still up: %v", a.frontTabKey(), a.railFull())
}
}
@@ -384,11 +392,20 @@ func TestTrafficStopAndStartAreDoneOnceAndNeverReplayed(t *testing.T) {
if got, ok := disk[0].ByHandle("lexer"); !ok || got.Key != m.Key {
t.Fatalf("the new member did not reach the disk: %+v", disk[0].Members)
}
- // The rail says what the stop and the start were for.
+ // The column says what the stop and the start were for, under General.
+ // The route takes the cells the brief used to have, so a cut word is on
+ // the hint line, whole.
a.width, a.height = 180, 40
- frame := ansi.Strip(func() string { f, _, _ := a.frame(); return f }())
- if !strings.Contains(frame, "stopped @"+price+" · stuck in") || !strings.Contains(frame, "started @lexer") || !strings.Contains(frame, " rewrite the lexer") {
- t.Fatalf("the rail drops the stop's reason or the start's brief:\n%s", frame)
+ a.sideToggleThread(sideThreadKey(harbor, sideGeneral))
+ rows := strings.Join(railLines(t, a), "\n")
+ if !strings.Contains(rows, teamManagerGlyph+" → @lexer") || !strings.Contains(rows, "started @lexer") || !strings.Contains(rows, "stopped @"+price) {
+ t.Fatalf("the column drops the stop or the start:\n%s", rows)
+ }
+ if hint := a.sideRowOf(railKeyOfReply(t, a, "started @lexer")).hint; !strings.Contains(hint, "rewrite") {
+ t.Fatalf("the start's brief is not on the hint: %q", hint)
+ }
+ if hint := a.sideRowOf(railKeyOfReply(t, a, "stopped @"+price)).hint; !strings.Contains(hint, "stuck") {
+ t.Fatalf("the stop's reason is not on the hint: %q", hint)
}
// A second window on the same log, holding the same conversations.
@@ -408,7 +425,7 @@ func TestTrafficStopAndStartAreDoneOnceAndNeverReplayed(t *testing.T) {
t.Fatalf("a window opening did what was asked before it: %d stops, %d starts", olderB.stops, nb)
}
if len(b.traffic.rows[harbor]) != 2 {
- t.Fatalf("the history is not on the new window's rail: %+v", b.traffic.rows[harbor])
+ t.Fatalf("the history is not in the new window's cache: %+v", b.traffic.rows[harbor])
}
}
diff --git a/internal/tui3/title_test.go b/internal/tui3/title_test.go
index ce771af131..7652577678 100644
--- a/internal/tui3/title_test.go
+++ b/internal/tui3/title_test.go
@@ -71,6 +71,7 @@ func TestTheTerminalTitleSaysWhereYouAre(t *testing.T) {
func TestEveryPlaceTitlesTheTabWithItsWord(t *testing.T) {
want := map[page]string{
pageTasks: "sessions · codeaf",
+ pageTeams: "teams · codeaf",
pageStanding: "standing · codeaf",
pageMemory: "memory · codeaf",
pageSpend: "spend · codeaf",
diff --git a/internal/tui3/topnav.go b/internal/tui3/topnav.go
new file mode 100644
index 0000000000..7c9337d57c
--- /dev/null
+++ b/internal/tui3/topnav.go
@@ -0,0 +1,507 @@
+package tui3
+
+import (
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/Agent-Field/codeaf/internal/tui2/tokens"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// ── THE TOP NAV: THE PLACES, ON THE WORDMARK'S ROW, ON EVERY PAGE ───────────
+//
+// >● codeaf home teams chats sessions spend settings 2 want you · $1.20 thu 10:31pm
+// >● codeaf home teams chats sessions spend settings 2 ? · $1.20 / $20
+// >● codeaf home teams chats sessions more ▾ 2 ? · $1.20
+//
+// The first row of every frame is the product's name, the places a person can
+// go, and the machine's pulse on the far end (pulse.go). It is drawn by ONE
+// function ([app.navLine]) for a place, a conversation, a room inside one and
+// the grid of open tabs, so the words are in the same cells whichever page is
+// up and a hand that learned where `spend` is finds it there everywhere.
+//
+// ── THE LAWS ──
+//
+// - THE PLACE YOU ARE IN IS LIT IN THE ONE ACCENT and every other word is
+// muted. Inside a conversation, or on the grid of its tabs, the place is
+// `chats` ([app.navLit]): the strip under the nav is the chats, so the word
+// that names them is the one lit over it.
+// - EVERY WORD IS A WORD BUTTON: a one-cell pad either side, a ground under
+// the pointer that covers the pads too, and a press anywhere on that ground
+// goes to the place ([app.navPress]). The pad cells are the target because
+// they are drawn as the button; a one-cell miss beside a word is a miss
+// people make. Where colour cannot show a ground, the pointer's word wears
+// the linear mark `·` in its leading pad, as the strip's tabs do.
+// - THE HINT LINE SAYS WHAT A WORD OPENS AND ITS KEY while the pointer rests
+// on it ([app.headHint]), so a word is never a lone label a person has to
+// press to learn about.
+// - THE GAPS ARE FIXED. One cell of inset at each end of the row, [navLead]
+// cells between the wordmark and the first button, no cells between two
+// buttons (their pads are the air, two blank cells between two words), and
+// at least [navTailGap] cells before the pulse. None of them changes with
+// the width; what a narrow row gives up is words and clauses, never air.
+//
+// ── THE WIDTH LADDER ──
+//
+// A row too narrow for everything gives things up IN THE OWNER'S ORDER
+// (2026-09-24): the clock goes first (and the machine's name with it, the
+// other fact every terminal title already carries), then the moving count,
+// then the words of `2 want you`, which shortens to `2 ?` in the same amber,
+// then the allowance behind the day's figure, and only then do the trailing
+// places fold into `more ▾`, one word at a time from the right. The day's
+// figure goes after every place has folded, and THE COUNT THAT WANTS YOU
+// NEVER GOES: it is the one thing on this row a person must not have to go
+// looking for, so it outlasts the money and the places (pulse.go).
+// The wordmark, the place you are in and the word under the bar's cursor
+// never fold, and a place wearing a count keeps its word, because a number is
+// this row saying something moved in a room you are not in.
+//
+// `more ▾` IS A DOOR: a press opens a small menu of exactly the places it
+// folded (navmore.go), so a narrow row still reaches every place by pointer as
+// well as by `alt+N`.
+
+// navRow is the row of the frame the nav is drawn on, the first, over the
+// strip. The frame records that it drew one in [app.tabRow], which is -1 on a
+// frame with no head at all.
+const navRow = 0
+
+// tabStripRow is the row the chat strip is drawn on: under the nav and over
+// the rule, and only while a conversation is in front (head.go).
+const tabStripRow = 1
+
+const (
+ // navInset is the blank cell at each end of the row, the same inset every
+ // row of the head keeps.
+ navInset = 1
+ // navLead is the air between the wordmark and the first place's button.
+ // With the button's own pad it reads as three blank cells, which is what
+ // tells the product's name from the first word a person can press.
+ navLead = 2
+ // navTailGap is the least air between the last button and the pulse, so a
+ // clause of the pulse never reads as one more place.
+ navTailGap = 2
+)
+
+// navMoreWord is the fold's own word, `more ▾`, with the caret every menu on
+// this surface wears ([app.tabTeamWord]).
+func (a *app) navMoreWord(pal palette) string {
+ caret := a.linearMark("▾", "v")
+ if pal.ascii {
+ caret = "v"
+ }
+ return "more " + caret
+}
+
+// navLit is the place the nav lights: the one a person is standing in, and
+// `chats` everywhere that is not a place, since every such page is the chats.
+func (a *app) navLit() page {
+ if a.pageShowing() {
+ return a.page
+ }
+ return pageChats
+}
+
+// navMemo is the nav as it was last laid out, reused while nothing it is drawn
+// from has changed. THE ROW IS ON EVERY FRAME OF A SCROLL, and the scroll is
+// held to an allocation ceiling (inputsmooth_test.go); a row that said the
+// same thing for four thousand lines is laid out once. Every field is compared
+// as a value, so the check costs nothing.
+type navMemo struct {
+ width int
+ ink uint64
+ page, hover page
+ bar barCursor
+ every, moreOn, moreHot bool
+ facts machineFacts
+ minute int64
+ host string
+ counts [16]int
+ profile tokens.Profile
+ ascii, linear, places bool
+ accent, muted hue
+ line string
+ spans []placeTabSpan
+ more hudSpan
+ folded []page
+}
+
+// navMemoKey is the memo's key half for this frame.
+func (a *app) navMemoKey(width int, pal palette) navMemo {
+ m := navMemo{width: width, ink: a.inkState, page: a.page, hover: a.tabHover, bar: a.bar,
+ every: a.mapShowing, moreOn: a.navMore.on, moreHot: a.navMore.hot, facts: a.machine,
+ host: a.host, profile: pal.profile, ascii: pal.ascii, linear: a.linear || pal.linear, places: pal.placeRows,
+ accent: pal.ramp.accent, muted: pal.ramp.muted}
+ if now := a.now(); !now.IsZero() {
+ m.minute = now.Unix() / 60
+ }
+ for i, id := range placeOrder {
+ if i < len(m.counts) {
+ m.counts[i] = a.placeCount(id)
+ }
+ }
+ return m
+}
+
+func (m navMemo) same(k navMemo) bool {
+ return m.line != "" && m.width == k.width && m.ink == k.ink && m.page == k.page && m.hover == k.hover &&
+ m.bar == k.bar && m.every == k.every && m.moreOn == k.moreOn && m.moreHot == k.moreHot &&
+ m.facts == k.facts && m.minute == k.minute && m.host == k.host && m.counts == k.counts &&
+ m.profile == k.profile && m.ascii == k.ascii && m.linear == k.linear && m.places == k.places &&
+ m.accent == k.accent && m.muted == k.muted
+}
+
+// navLine is the head's first row: the wordmark, the places, and the pulse,
+// exactly `width` cells wide at most. It writes where every button landed
+// ([app.tabs], [navMore.span]) as it draws them.
+func (a *app) navLine(width int, pal palette) string {
+ key := a.navMemoKey(width, pal)
+ if a.navMemo.same(key) {
+ a.tabs = a.navMemo.spans
+ a.navMore.span, a.navMore.folded = a.navMemo.more, a.navMemo.folded
+ return a.navMemo.line
+ }
+ line := a.navLay(width, pal)
+ key.line = line
+ key.spans = append([]placeTabSpan(nil), a.tabs...)
+ key.more = a.navMore.span
+ key.folded = append([]page(nil), a.navMore.folded...)
+ a.navMemo = key
+ return line
+}
+
+// navLay does the layout [app.navLine] memoises.
+func (a *app) navLay(width int, pal palette) string {
+ a.tabs = a.tabs[:0]
+ a.navMore.span, a.navMore.folded = hudSpan{}, a.navMore.folded[:0]
+ // HOME'S LINE LEAVES ITS COUNTS TO THE PANELS UNDER IT, and every other
+ // frame's carries them (pulse.go's [pulseBudget]).
+ mode := pulseWhole
+ if a.at(pageHome) {
+ mode = pulseBudget
+ }
+ name := strings.Repeat(" ", navInset) + pal.wordmark(width)
+ lead := ansi.StringWidth(name) + navLead
+ lit := a.navLit()
+ shown := barPages(lit, a.mapShowing)
+ cost := func(id page) int { return ansi.StringWidth(a.barChipWord(id, a.mapShowing)) + tabPadCols }
+ all := 0
+ for _, id := range shown {
+ all += cost(id)
+ }
+ tails := a.navTails(pal, mode)
+ tailCost := func(tail string) int {
+ if tail == "" {
+ return 0
+ }
+ return navTailGap + ansi.StringWidth(tail)
+ }
+ // FIRST THE PULSE GIVES UP ITS CLAUSES, every place still on the row.
+ for _, tail := range tails[:len(tails)-1] {
+ if lead+all+tailCost(tail)+navInset <= width {
+ return a.navPaint(width, pal, name, lead, shown, nil, tail)
+ }
+ }
+ // THEN THE TRAILING PLACES FOLD INTO `more ▾`, from the right, with the
+ // day's figure still on the row, and last of all the figure goes too,
+ // leaving the count that wants you, which never goes.
+ keep := func(id page) bool { return id == lit || a.barKeeps(id) || a.placeCount(id) > 0 }
+ moreCost := ansi.StringWidth(a.navMoreWord(pal)) + tabPadCols
+ fixed := 0
+ var free []page
+ for _, id := range shown {
+ if keep(id) {
+ fixed += cost(id)
+ } else {
+ free = append(free, id)
+ }
+ }
+ for _, tail := range []string{tails[len(tails)-2], tails[len(tails)-1]} {
+ used := fixed
+ for _, id := range free {
+ used += cost(id)
+ }
+ for n := len(free) - 1; n >= 0; n-- {
+ used -= cost(free[n])
+ if lead+used+moreCost+tailCost(tail)+navInset <= width {
+ return a.navPaint(width, pal, name, lead, shown, free[n:], tail)
+ }
+ }
+ }
+ // A ROW TOO NARROW EVEN FOR THE NAME, THE PLACE YOU ARE IN AND THE FOLD is
+ // drawn with every foldable word folded and cut at the frame's edge; a
+ // button the cut reached is not a target ([app.navPaint]).
+ return a.navPaint(width, pal, name, lead, shown, free, tails[len(tails)-1])
+}
+
+// navTails is every spelling the pulse end of the row may take, widest first,
+// in the order the ladder gives clauses up (this file's header). The last but
+// one is the day's figure with the short count, which the places fold beside,
+// and the last is the short count alone ("" when nothing wants you).
+func (a *app) navTails(pal palette, mode pulseMode) [7]string {
+ p := a.pulseParts(a.now(), pal, mode)
+ host := ""
+ if name := strings.TrimSpace(a.host); name != "" {
+ // THE MACHINE THESE PAGES ARE ABOUT, dim, and nothing at all on a local
+ // session: it is invisible when there is nothing to say (host.go). It is
+ // `on spark` rather than a bare `spark` so it cannot read as a place.
+ host = pal.dim(placeMachineLead + name)
+ }
+ rung := func(parts ...string) string {
+ out := ""
+ for _, part := range parts {
+ if part == "" {
+ continue
+ }
+ if out != "" {
+ out += pulseGap
+ }
+ out += part
+ }
+ return out
+ }
+ return [7]string{
+ rung(p.wants, p.hands, p.money, host, p.clock),
+ rung(p.wants, p.hands, p.money, host),
+ rung(p.wants, p.hands, p.money),
+ rung(p.wants, p.money),
+ rung(p.ask, p.money),
+ rung(p.ask, p.spend),
+ rung(p.ask),
+ }
+}
+
+// navPaint draws the row with `folded` behind `more ▾` and `tail` on the far
+// end, and records where every button landed.
+func (a *app) navPaint(width int, pal palette, name string, lead int, shown, folded []page, tail string) string {
+ var b strings.Builder
+ b.WriteString(name)
+ b.WriteString(strings.Repeat(" ", navLead))
+ at := lead
+ lit := a.navLit()
+ isFolded := func(id page) bool {
+ for _, f := range folded {
+ if f == id {
+ return true
+ }
+ }
+ return false
+ }
+ for _, id := range shown {
+ if isFolded(id) {
+ continue
+ }
+ word := a.barChipWord(id, a.mapShowing)
+ w := ansi.StringWidth(word) + tabPadCols
+ b.WriteString(a.navChipPaint(pal, id, word, id == lit))
+ // A BUTTON THE FRAME'S EDGE CUT IS NOT A TARGET: a press resolves
+ // against what was drawn, and half a word is not a door.
+ if at+w <= width {
+ a.tabs = append(a.tabs, placeTabSpan{id: id, from: at, to: at + w})
+ }
+ at += w
+ }
+ if len(folded) > 0 {
+ word := a.navMoreWord(pal)
+ w := ansi.StringWidth(word) + tabPadCols
+ b.WriteString(a.navMorePaint(pal, word))
+ if at+w <= width {
+ a.navMore.span = hudSpan{from: at, to: at + w}
+ a.navMore.folded = append(a.navMore.folded, folded...)
+ }
+ at += w
+ }
+ line := b.String()
+ if tail != "" {
+ line += strings.Repeat(" ", max(width-at-ansi.StringWidth(tail)-navInset, navTailGap)) + tail + strings.Repeat(" ", navInset)
+ }
+ if ansi.StringWidth(line) > width {
+ return ansi.Truncate(line, width, "")
+ }
+ return line + strings.Repeat(" ", width-ansi.StringWidth(line))
+}
+
+// navChipPaint is one place's button: the cursor's band while the bar's
+// cursor is on it, the pointer's ground under the pointer, the one accent on
+// the place you are in, and muted otherwise.
+func (a *app) navChipPaint(pal palette, id page, word string, lit bool) string {
+ chip := tabPad + word + tabPad
+ ink, hover := pal.muted, pal.ink
+ if lit {
+ ink = func(s string) string { return pal.bold(pal.accent(s)) }
+ hover = ink
+ }
+ switch {
+ case a.bar.on && id == a.bar.at:
+ // THE CURSOR'S OWN BAND, and it replaces the hover and the lit ink
+ // rather than stacking on them: while the cursor is up here the row
+ // is the one a person is standing on ([barCursor]).
+ return pal.cursor(pal.bold(pal.ink(chip)), 0)
+ case id == a.tabHover:
+ return a.navHoverPaint(pal, word, hover)
+ case lit && pal.profile == tokens.NoColor:
+ // WITH NO COLOUR TO LIGHT IT, the place you are in wears brackets in
+ // its pad cells, as the strip's tab in front does ([tabLabel]); the
+ // button is the same width either way.
+ return pal.bold("[" + word + "]")
+ }
+ return ink(chip)
+}
+
+// navHoverPaint is a word button under the pointer: the strip's own hover
+// ground ([app.tabHoverPaint]) over the pads and the word, and the linear mark
+// in the leading pad where the terminal cannot show a ground.
+func (a *app) navHoverPaint(pal palette, word string, ink func(string) string) string {
+ chip := tabPad + word + tabPad
+ if pal.profile < tokens.ANSI256 {
+ chip = a.linearMark("·", ".") + word + tabPad
+ }
+ if pal.linear {
+ return ink(chip)
+ }
+ return pal.background(ink(chip), 0, pal.ramp.mark)
+}
+
+// navMorePaint is the fold's button: muted, on the pointer's ground under the
+// pointer and while its menu is open, as the team chip is.
+func (a *app) navMorePaint(pal palette, word string) string {
+ if a.navMore.hot || a.navMore.on {
+ return a.navHoverPaint(pal, word, pal.ink)
+ }
+ return pal.muted(tabPad + word + tabPad)
+}
+
+// ── THE POINTER ─────────────────────────────────────────────────────────────
+
+// navAt is whether row y is the nav: the row the last frame drew it on, on a
+// frame that still has one. A CONVERSATION UNDER THE STRIP'S FLOORS DRAWS NO
+// HEAD AT ALL ([app.tabsHeight]), and its body starts on row zero; the same
+// question [app.tabAt] asks of the strip keeps a press there from being read
+// as a press on a nav that a taller frame drew a moment ago.
+func (a *app) navAt(y int) bool {
+ if a.tabRow < 0 || y != a.tabRow {
+ return false
+ }
+ if a.pageShowing() || a.headCovers() {
+ return true
+ }
+ width, _ := a.size()
+ return a.tabsHeight(width) > 0
+}
+
+// navPress is a press on the nav's row: the place whose button it landed in,
+// or the fold's menu. THE WHOLE ROW IS THE NAV'S, so a press in the air
+// between two buttons, or on the wordmark or the pulse, stops here rather than
+// falling through to whatever the page under it would make of row zero.
+//
+// PRESSING THE PLACE YOU ARE ALREADY IN DOES NOTHING. Going there is closing
+// and reopening it, which throws away the filter somebody typed and the row
+// they were standing on. The one exception is `chats` pressed over a page that
+// covers the chats (the run's work tab): that is the way back to them.
+func (a *app) navPress(x, y int) (tea.Cmd, bool) {
+ if !a.navAt(y) {
+ return nil, false
+ }
+ if a.navMore.span.holds(x) {
+ if a.navMore.on {
+ a.closeNavMore()
+ } else {
+ a.openNavMore()
+ }
+ return nil, true
+ }
+ for _, span := range a.tabs {
+ if x < span.from || x >= span.to {
+ continue
+ }
+ if a.headCovers() {
+ a.headUncover()
+ }
+ if span.id == a.navLit() {
+ return nil, true
+ }
+ return a.showPage(span.id), true
+ }
+ return nil, true
+}
+
+// headCovers is whether a full-frame view that is not a place is drawn over
+// the chats: the grid of open tabs. It wears the head, and the nav's doors lead
+// out of it. A run's work opens in the task room, which is a room of its chat
+// (planroom.go) and stays where it is while a nav word opens a place.
+func (a *app) headCovers() bool { return a.wall.on }
+
+// headUncover takes that view down, so the page a nav word opens is the page
+// on screen.
+func (a *app) headUncover() {
+ if a.wall.on {
+ a.closeWall()
+ }
+ a.touch()
+}
+
+// navHover is the pointer over the nav's row: the button under it takes the
+// pointer's ground and nothing else on the frame moves. It reports whether the
+// pointer is on the row at all.
+//
+// THE POINTER LEAVING THE ROW IS NEWS TOO, and it is the half that is easy to
+// forget: a word left lit after the hand moved away is a door that claims to be
+// under a pointer that is somewhere else.
+func (a *app) navHover(x, y int) bool {
+ if !a.navAt(y) {
+ a.barHover(pageNone)
+ a.navMoreHot(false)
+ return false
+ }
+ under := pageNone
+ for _, span := range a.tabs {
+ if x >= span.from && x < span.to {
+ under = span.id
+ break
+ }
+ }
+ a.barHover(under)
+ a.navMoreHot(a.navMore.span.holds(x))
+ return true
+}
+
+// headHover is the pointer over the head. On every page that is the nav. The
+// strip is a chat's row, and the conversation's own hover ([app.setHover])
+// answers it there. It reports whether the pointer is on a row the head
+// answers for.
+func (a *app) headHover(x, y int) bool {
+ onNav := a.navHover(x, y)
+ if onNav && !a.pageShowing() {
+ // THE CONVERSATION'S OWN HOVER LETS GO of whatever it was on, a tab
+ // of the strip included, since the pointer is on neither.
+ a.setHover(x, y)
+ }
+ return onNav
+}
+
+// ── THE HINT ────────────────────────────────────────────────────────────────
+
+// headHint is the hint line's sentence while the pointer rests on a word of
+// the head that is a door the key legend does not already name, and "" when it
+// rests on none. It is `alt+2 teams · the teams you hand work to`: the key,
+// the word, and what it opens.
+//
+// `chats` AND `▦ All` ARE TOLD APART HERE, because they sit one over the other
+// and both are about conversations: `chats` is the place, every conversation
+// one at a time, and `▦ All` is the grid of the tabs this window has open,
+// all at once.
+func (a *app) headHint() string {
+ switch {
+ case a.navMore.on:
+ return navMoreFootWords
+ case placeFor(a.tabHover) != nil:
+ return a.chords.say(placeChord(a.tabHover)+" "+a.tabHover.word()) + hintSegment + placeFor(a.tabHover).about()
+ case a.navMore.hot:
+ return "more · the places this row has no room for"
+ case a.hot.kind == hoverTab:
+ // THE STRIP'S DOORS SAY WHAT THEY SAY IN A CONVERSATION, in the one
+ // place those sentences are written (walldock.go's
+ // [app.dockHoverWords]): `▦ All`, the team chip, the manager's place.
+ return a.dockHoverWords()
+ }
+ return ""
+}
diff --git a/internal/tui3/topnav_test.go b/internal/tui3/topnav_test.go
new file mode 100644
index 0000000000..b61923799a
--- /dev/null
+++ b/internal/tui3/topnav_test.go
@@ -0,0 +1,456 @@
+package tui3
+
+import (
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/Agent-Field/codeaf/internal/tui2/tokens"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// navPlaces is the nav's buttons alone, as a reader sees them: the cells from
+// the first button's first cell to the last button's last, `more ▾` included,
+// off the row [app.navLine] drew. The pulse at the row's end is not in it, so
+// a test about the places is not a test about the clock.
+func navPlaces(a *app, width int, numbered bool) string {
+ was := a.mapShowing
+ a.mapShowing = numbered
+ defer func() { a.mapShowing = was }()
+ line := a.navLine(width, a.pal)
+ if len(a.tabs) == 0 {
+ return ""
+ }
+ from, to := a.tabs[0].from, a.tabs[len(a.tabs)-1].to
+ if a.navMore.span.pressable() {
+ to = a.navMore.span.to
+ }
+ return strings.TrimSpace(plain(ansi.Cut(line, from, to)))
+}
+
+// navWidths are the widths the head is pinned at: a split pane, the classic
+// terminal, a laptop and a wide screen.
+var navWidths = []int{60, 80, 110, 160}
+
+// navChat is a conversation with three tabs on its strip, a team shown and
+// some money spent, the fixture every test here starts from.
+func navChat(t *testing.T) *app {
+ t.Helper()
+ a, _, _, _ := trafficApp(t)
+ a.open = func(workspace, transcript string) (Conversation, error) { return Conversation{}, nil }
+ a.welcome.open = false
+ a.railAway = true
+ a.height = 30
+ return a
+}
+
+// headRowsOf is the frame's first rows, as a reader sees them.
+func headRowsOf(a *app) []string {
+ n := placeHeadRows + 1
+ if !a.pageShowing() {
+ n = chatHeadRows + 1
+ }
+ rows := strings.Split(plain(frame(a)), "\n")
+ if len(rows) > n {
+ rows = rows[:n]
+ }
+ return rows
+}
+
+// ROW ZERO IS THE SAME ON EVERY PAGE. A chat then draws the strip, the rule
+// and a blank, and the body under those four rows. A place draws the rule and
+// a blank and no strip, and the body starts on the next row.
+func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) {
+ for _, width := range navWidths {
+ a := navChat(t)
+ a.width = width
+ a.touch()
+ chat := headRowsOf(a)
+ chatNav := append([]placeTabSpan(nil), a.tabs...)
+ if a.tabRow != navRow || a.headHeight() != chatHeadRows {
+ t.Fatalf("at %d the chat's nav is on row %d and its head is %d rows", width, a.tabRow, a.headHeight())
+ }
+ if !strings.Contains(chat[tabStripRow], "harbor") || !strings.HasPrefix(chat[2], "─") || strings.TrimSpace(chat[3]) != "" {
+ t.Fatalf("at %d the chat's head is not the nav, the strip, the rule and a blank:\n%s", width, strings.Join(chat, "\n"))
+ }
+ for _, to := range []page{pageHome, pageTeams, pageSpend} {
+ walkTo(t, a, to)
+ place := headRowsOf(a)
+ if a.tabRow != navRow || a.headHeight() != placeHeadRows {
+ t.Fatalf("at %d %s drew its nav on row %d and a %d-row head", width, to.word(), a.tabRow, a.headHeight())
+ }
+ if place[navRow] != chat[navRow] {
+ t.Fatalf("at %d %s moved row zero:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow])
+ }
+ if !strings.HasPrefix(place[1], "─") || strings.TrimSpace(place[2]) != "" || strings.Contains(place[1], "harbor") {
+ t.Fatalf("at %d %s drew a strip where the rule belongs:\n%s", width, to.word(), strings.Join(place, "\n"))
+ }
+ if len(a.chatTabHits) != 0 {
+ t.Fatalf("at %d %s kept %d strip targets for a row it did not draw", width, to.word(), len(a.chatTabHits))
+ }
+ // AND THE WORDS STAND IN THE SAME CELLS: every button the chat drew
+ // is where the place drew it, wherever both drew one.
+ for _, c := range chatNav {
+ for _, p := range a.tabs {
+ if c.id == p.id && c != p {
+ t.Fatalf("at %d %q moved from %+v on the chat to %+v on %s", width, c.id.word(), c, p, to.word())
+ }
+ }
+ }
+ a.showPage(pageNone)
+ }
+ if width == 80 || width == 110 {
+ t.Logf("the chat's head at %d:\n%s", width, strings.Join(chat, "\n"))
+ }
+ }
+}
+
+// THE NAV LIGHTS WHERE YOU STAND, IN THE ONE ACCENT. The place on a place, and
+// `chats` inside a conversation, since the strip under the nav is the chats.
+// Every other word is muted, and exactly one is lit.
+func TestTheNavLightsWhereYouStand(t *testing.T) {
+ a := navChat(t)
+ a.width = 160
+ lit := func(id page) string { return a.pal.bold(a.pal.accent(tabPad + id.word() + tabPad)) }
+ line := a.navLine(a.width, a.pal)
+ if !strings.Contains(line, lit(pageChats)) {
+ t.Fatalf("inside a conversation `chats` is not lit: %q", line)
+ }
+ for _, to := range []page{pageHome, pageTeams, pageTasks, pageSpend, pageSettings} {
+ walkTo(t, a, to)
+ line := a.navLine(a.width, a.pal)
+ for _, id := range barPages(to, false) {
+ if got := strings.Contains(line, lit(id)); got != (id == to) {
+ t.Fatalf("standing in %s, %s is lit %v", to.word(), id.word(), got)
+ }
+ if id != to && !strings.Contains(line, a.pal.muted(tabPad+id.word()+tabPad)) {
+ t.Fatalf("standing in %s, %s is not muted", to.word(), id.word())
+ }
+ }
+ }
+ // AND WITH NO COLOUR TO LIGHT IT, the place wears brackets in its pads, in
+ // the same cells.
+ a.pal = newPalette(tokens.NoColor, false)
+ walkTo(t, a, pageSpend)
+ if line := plain(a.navLine(a.width, a.pal)); !strings.Contains(line, "[spend]") {
+ t.Fatalf("a plain terminal cannot tell which place is lit: %q", line)
+ }
+}
+
+// EVERY BUTTON'S HOVER GROUND IS ITS TARGET, PADS INCLUDED. Each cell of a
+// word's button, its two pad cells too, puts that word under the pointer, the
+// cell either side of it does not, and the ground drawn covers exactly those
+// cells. On a plain terminal the pointer's word wears `·` in its leading pad.
+func TestEveryNavButtonsHoverGroundIsItsTarget(t *testing.T) {
+ a := navChat(t)
+ for _, width := range navWidths {
+ a.width = width
+ for _, plainTerm := range []bool{false, true} {
+ a.pal = newPalette(tokens.TrueColor, false)
+ if plainTerm {
+ a.pal = newPalette(tokens.NoColor, false)
+ }
+ a.navMemo = navMemo{}
+ a.navLine(width, a.pal)
+ spans := append([]placeTabSpan(nil), a.tabs...)
+ if a.navMore.span.pressable() {
+ spans = append(spans, placeTabSpan{id: pageNone, from: a.navMore.span.from, to: a.navMore.span.to})
+ }
+ for _, span := range spans {
+ for x := span.from - 1; x <= span.to; x++ {
+ a.navHover(x, navRow)
+ in := x >= span.from && x < span.to
+ under := a.tabHover == span.id && span.id != pageNone || span.id == pageNone && a.navMore.hot
+ if under != in {
+ t.Fatalf("at %d the pointer at %d is under %v %v, and the button is %d..%d", width, x, span.id.word(), under, span.from, span.to)
+ }
+ }
+ a.navHover(span.from, navRow)
+ line := a.navLine(width, a.pal)
+ word := strings.TrimSpace(plain(ansi.Cut(line, span.from, span.to)))
+ want := a.pal.background(a.pal.ink(tabPad+strings.TrimPrefix(word, "·")+tabPad), 0, a.pal.ramp.mark)
+ if plainTerm {
+ if got := plain(ansi.Cut(line, span.from, span.to)); !strings.HasPrefix(got, "·") {
+ t.Fatalf("at %d on a plain terminal %q has no pointer mark under the pointer", width, got)
+ }
+ } else if span.id != a.navLit() && !strings.Contains(line, want) {
+ t.Fatalf("at %d the ground under %q does not cover its pads", width, word)
+ }
+ a.navHover(-1, -1)
+ }
+ }
+ }
+}
+
+// A PRESS ON EACH NAV WORD OPENS THAT PLACE, from a conversation and from a
+// place, on any cell of its button; a press on the place you are already in
+// does nothing at all; and none of it moves the draft or the caret.
+func TestAPressOnEachNavWordOpensThatPlace(t *testing.T) {
+ a := navChat(t)
+ a.width = 160
+ a.input.setText("half a sentence")
+ caret := a.input.cursor
+ frame(a)
+ for _, span := range append([]placeTabSpan(nil), a.tabs...) {
+ for _, x := range []int{span.from, span.to - 1} {
+ a.showPage(pageNone)
+ frame(a)
+ drive(t, a, tea.MouseClickMsg{X: x, Y: navRow, Button: tea.MouseLeft})
+ switch {
+ case span.id == pageChats && a.pageShowing():
+ t.Fatalf("a press on `chats` inside a conversation opened %s", a.page.word())
+ case span.id != pageChats && !a.at(span.id):
+ t.Fatalf("a press at %d on %q left the router on %q", x, span.id.word(), a.page.word())
+ }
+ if span.id != pageChats {
+ // AND FROM THE PLACE, `chats` IS THE WAY BACK.
+ frame(a)
+ for _, back := range a.tabs {
+ if back.id == pageChats {
+ drive(t, a, tea.MouseClickMsg{X: back.from, Y: navRow, Button: tea.MouseLeft})
+ }
+ }
+ if a.pageShowing() {
+ t.Fatalf("`chats` on %s did not go back to the conversation", span.id.word())
+ }
+ }
+ }
+ }
+ if a.input.String() != "half a sentence" || a.input.cursor != caret {
+ t.Fatalf("the nav moved the draft or its caret: %q at %d", a.input.String(), a.input.cursor)
+ }
+}
+
+// A PRESS ON ROW 1 OF A PLACE IS THE PAGE'S. The strip is not drawn there, so
+// the row under the nav is the rule, and a click on it does not open a chat.
+// The page's own first row is the next one after the blank.
+func TestAPressOnRowOneOfAPlaceIsThePages(t *testing.T) {
+ a := navChat(t)
+ a.width = 160
+ front := a.frontTabKey()
+ for _, id := range placeOrder {
+ if id == pageChats {
+ continue
+ }
+ walkTo(t, a, id)
+ frame(a)
+ if len(a.chatTabHits) != 0 {
+ t.Fatalf("%s kept strip targets", id.word())
+ }
+ if _, ok := a.tabAt(4, tabStripRow); ok {
+ t.Fatalf("%s still has a tab on row 1", id.word())
+ }
+ rows := strings.Split(plain(frame(a)), "\n")
+ if len(rows) <= placeHeadRows || !strings.HasPrefix(rows[1], "─") {
+ t.Fatalf("%s row 1 is not the rule:\n%s", id.word(), strings.Join(rows[:placeHeadRows+1], "\n"))
+ }
+ drive(t, a, tea.MouseClickMsg{X: 4, Y: tabStripRow, Button: tea.MouseLeft})
+ if !a.at(id) || a.frontTabKey() != front {
+ t.Fatalf("a press on row 1 of %s left %q with %q in front", id.word(), a.page.word(), a.frontTabKey())
+ }
+ if line, ok := placeBodyLine(placeHeadRows, 0, 8); !ok || line != 0 {
+ t.Fatalf("%s body does not start at row %d", id.word(), placeHeadRows)
+ }
+ }
+}
+
+// THE NAV FOLDS INTO `more ▾`, AND `more ▾` IS A DOOR. A press opens a menu of
+// exactly the folded places; the pointer lights a row, a press on it goes
+// there, `↓` and `enter` do the same from the keyboard, and `esc` puts it away
+// with the page, the draft and the caret exactly where they were.
+func TestTheMoreMenuOpensTheFoldedPlaces(t *testing.T) {
+ a := navChat(t)
+ a.width = 50
+ a.input.setText("a draft")
+ frame(a)
+ more := a.navMore.span
+ folded := append([]page(nil), a.navMore.folded...)
+ if !more.pressable() || len(folded) == 0 {
+ t.Fatalf("at 50 columns nothing folded: %q", plain(a.navLine(50, a.pal)))
+ }
+ drive(t, a, tea.MouseClickMsg{X: more.from, Y: navRow, Button: tea.MouseLeft})
+ if !a.navMore.on {
+ t.Fatal("a press on `more ▾` did not open its menu")
+ }
+ text := plain(frame(a))
+ for _, id := range folded {
+ if !strings.Contains(text, id.word()) || !strings.Contains(text, placeChord(id)) {
+ t.Fatalf("the menu does not offer %q with its key:\n%s", id.word(), text)
+ }
+ }
+ drive(t, a, key("esc"))
+ if a.navMore.on || a.pageShowing() || a.input.String() != "a draft" {
+ t.Fatalf("esc did not put the menu away and leave everything else (page %q, draft %q)", a.page.word(), a.input.String())
+ }
+ // The keyboard.
+ drive(t, a, tea.MouseClickMsg{X: more.from, Y: navRow, Button: tea.MouseLeft})
+ frame(a)
+ if len(folded) > 1 {
+ drive(t, a, key("down"))
+ }
+ drive(t, a, key("enter"))
+ if want := folded[min(1, len(folded)-1)]; !a.at(want) || a.navMore.on {
+ t.Fatalf("enter in the menu landed on %q, want %q", a.page.word(), want.word())
+ }
+ // The pointer.
+ a.showPage(pageNone)
+ frame(a)
+ drive(t, a, tea.MouseClickMsg{X: a.navMore.span.from, Y: navRow, Button: tea.MouseLeft})
+ frame(a)
+ if len(a.navMore.hits) == 0 {
+ t.Fatal("the open menu drew no rows")
+ }
+ last := a.navMore.hits[len(a.navMore.hits)-1]
+ target := a.navMore.folded[last.arg]
+ drive(t, a, tea.MouseMotionMsg{X: last.x0, Y: last.y0})
+ if a.navMore.hover != last.arg {
+ t.Fatalf("the pointer on a menu row lights %v", a.navMore.hover)
+ }
+ drive(t, a, tea.MouseClickMsg{X: last.x0, Y: last.y0, Button: tea.MouseLeft})
+ if !a.at(target) {
+ t.Fatalf("a press on the menu's %q row landed on %q", target.word(), a.page.word())
+ }
+ // AND A PRESS OFF IT ONLY PUTS IT AWAY.
+ frame(a)
+ if a.navMore.span.pressable() {
+ drive(t, a, tea.MouseClickMsg{X: a.navMore.span.from, Y: navRow, Button: tea.MouseLeft})
+ was := a.page
+ drive(t, a, tea.MouseClickMsg{X: 1, Y: a.height - 1, Button: tea.MouseLeft})
+ if a.navMore.on || a.page != was {
+ t.Fatal("a press off the menu did something besides putting it away")
+ }
+ }
+}
+
+// EVERY NAV WORD SAYS WHAT IT OPENS AND ITS KEY while the pointer rests on it,
+// on the hint line of a place and of a conversation. `chats` and `▦ All` say
+// two different things: the place, and the grid of the tabs open here.
+func TestEveryNavWordSaysWhatItOpens(t *testing.T) {
+ for _, id := range placeOrder {
+ if strings.TrimSpace(placeFor(id).about()) == "" {
+ t.Fatalf("%s has nothing to say on the hint line", id.word())
+ }
+ }
+ a := navChat(t)
+ a.width = 160
+ walkTo(t, a, pageSpend)
+ frame(a)
+ for _, span := range a.tabs {
+ a.navHover(span.from, navRow)
+ hint := a.placeHintSaid()
+ if !strings.Contains(hint, a.chords.say(placeChord(span.id))) || !strings.Contains(hint, placeFor(span.id).about()) {
+ t.Fatalf("hovering %q, the place's hint line reads %q", span.id.word(), hint)
+ }
+ }
+ a.navHover(-1, -1)
+ a.showPage(pageNone)
+ frame(a)
+ for _, span := range a.tabs {
+ if span.id != pageChats {
+ continue
+ }
+ a.navHover(span.from, navRow)
+ if hint := a.footHint(a.width); !strings.Contains(hint, placeFor(pageChats).about()) {
+ t.Fatalf("hovering `chats` in a conversation, the hint reads %q", hint)
+ }
+ }
+ a.navHover(-1, -1)
+ frame(a)
+ if !a.wall.door.pressable() {
+ t.Fatal("the strip drew no `▦ All`")
+ }
+ drive(t, a, tea.MouseMotionMsg{X: a.wall.door.from, Y: tabStripRow})
+ all := a.footHint(a.width)
+ if !strings.Contains(all, "grid of your open tabs") || strings.Contains(all, placeFor(pageChats).about()) {
+ t.Fatalf("`▦ All` does not say it is the grid of open tabs: %q", all)
+ }
+}
+
+// THE FOLD IS A WORD DOOR, NEVER A COUNT. The places' bar used to end a narrow
+// row in `▸ 3`, a count nobody could press; `more ▾` is a word that opens the
+// places it stands for, and its menu's caret is the one every menu here wears.
+// Widening the window so `more ▾` is no longer drawn used to leave the hint
+// on `more · the places this row has no room for` until the pointer moved.
+// A resize drops that hover, and the nav word hover with it.
+func TestAResizeDropsTheMoreHint(t *testing.T) {
+ a := navChat(t)
+ a.width, a.height = 48, 24
+ navPlaces(a, 48, false)
+ if !a.navMore.span.pressable() {
+ t.Fatal("at 48 the nav drew no more")
+ }
+ a.navHover(a.navMore.span.from, navRow)
+ if hint := a.footHint(48); !strings.Contains(hint, "more · the places this row has no room for") {
+ t.Fatalf("hovering more, the hint reads %q", hint)
+ }
+ for _, span := range a.tabs {
+ if span.id == pageSettings {
+ a.navHover(span.from, navRow)
+ }
+ }
+ if a.tabHover != pageSettings && !a.navMore.hot {
+ t.Fatal("neither the nav word nor more is hovered")
+ }
+ a.navHover(a.navMore.span.from, navRow)
+ drive(t, a, tea.WindowSizeMsg{Width: 110, Height: 24})
+ navPlaces(a, a.width, false)
+ if a.navMore.span.pressable() {
+ t.Fatal("at 110 more is still a door")
+ }
+ if hint := a.footHint(a.width); strings.Contains(hint, "more") {
+ t.Fatalf("after the resize the hint still says more: %q", hint)
+ }
+ if a.tabHover != pageNone || a.navMore.hot {
+ t.Fatalf("resize left hover: word %q more %v", a.tabHover.word(), a.navMore.hot)
+ }
+}
+
+func TestTheNavsFoldIsAWordDoorNotACount(t *testing.T) {
+ a := placeApp(t)
+ for width := 30; width <= 200; width++ {
+ bar := navPlaces(a, width, false)
+ if strings.Contains(bar, tokens.GlyphCollapsed) || strings.Contains(bar, "+") {
+ t.Fatalf("at %d the nav's fold is a count: %q", width, bar)
+ }
+ if a.navMore.span.pressable() != strings.HasSuffix(bar, "more ▾") {
+ t.Fatalf("at %d the fold's word and its door disagree: %q", width, bar)
+ }
+ }
+}
+
+// THE COUNT THAT WANTS YOU NEVER LEAVES THE ROW. At every width from a split
+// pane to a wide screen the nav carries the number of things stopped on the
+// person, in amber: `2 want you` where it fits and `2 ?` where it does not.
+// And at eighty columns the short count costs no place: all six words stay.
+func TestTheCountThatWantsYouNeverLeavesTheNav(t *testing.T) {
+ a := navChat(t)
+ a.machine = machineFacts{wants: 2, hands: 1, spent: 1.2, ceiling: 20}
+ long := a.pal.warn("2" + pulseWantWord)
+ short := a.pal.warn("2 " + tabSignalGlyph(tabNeedsPerson, a.pal.ascii))
+ for width := 44; width <= 200; width++ {
+ a.width = width
+ a.navMemo = navMemo{}
+ line := a.navLine(width, a.pal)
+ if ansi.StringWidth(line) != width {
+ t.Fatalf("at %d the nav is %d wide", width, ansi.StringWidth(line))
+ }
+ if !strings.Contains(line, long) && !strings.Contains(line, short) {
+ t.Fatalf("at %d the nav lost the count that wants you: %q", width, plain(line))
+ }
+ }
+ a.width = 80
+ a.navMemo = navMemo{}
+ row := plain(a.navLine(80, a.pal))
+ if !placeWordsInOrder(row, "home", "teams", "chats", "sessions", "spend", "settings") || a.navMore.span.pressable() {
+ t.Fatalf("at 80 the short count cost a place: %q", row)
+ }
+ if !strings.HasSuffix(strings.TrimRight(row, " "), "2 ? · $1.20 / "+railFigure(20)) {
+ t.Fatalf("at 80 the pulse is not the short count and the allowance: %q", row)
+ }
+ // AND NOTHING WANTING YOU DRAWS NOTHING: no `0 ?`.
+ a.machine.wants = 0
+ a.navMemo = navMemo{}
+ if row := plain(a.navLine(60, a.pal)); strings.Contains(row, "?") {
+ t.Fatalf("an empty count drew a mark: %q", row)
+ }
+}
diff --git a/internal/tui3/unverified_test.go b/internal/tui3/unverified_test.go
index 5530d00a3b..3bbabb6192 100644
--- a/internal/tui3/unverified_test.go
+++ b/internal/tui3/unverified_test.go
@@ -82,7 +82,9 @@ func TestAnUnverifiedLandingIsNeitherDoneNorFailed(t *testing.T) {
t.Fatalf("an unverified node filed under %q, want %q",
railGroupWords[group], railGroupWords[railAttention])
}
- rail := plain(strings.Join(a.railRows(12), "\n"))
+ // The row, and the hint line over it, where the reason the row has no room
+ // for is said.
+ rail := plain(strings.Join(a.railRows(12), "\n")) + "\n" + railHint(a, 7)
for _, want := range []string{
// The column leads with the STATE and nothing else — the card's identity
// cell is not spent here (task.go's [app.railLead]).
diff --git a/internal/tui3/view.go b/internal/tui3/view.go
index 7cc5ac4418..ad1f5e4e14 100644
--- a/internal/tui3/view.go
+++ b/internal/tui3/view.go
@@ -273,6 +273,15 @@ func (a *app) frame() (string, int, int) {
// The strip's team switcher hangs over whatever page is drawn under it
// (teammenu.go); the frame as it was when it is down.
body = a.teamMenuOver(body)
+ // And the nav's fold menu hangs under `more ▾` on the same terms
+ // (navmore.go).
+ body = a.navMoreOver(body)
+ // The teams page's members card hangs over its pane (teamcrew.go), a
+ // team's card (teamsheet.go) over whatever is drawn under it, and the move
+ // picker (teammove.go) over both, since the card opens it.
+ body = a.teamCrewOver(body)
+ body = a.teamSheetOver(body)
+ body = a.teamMoveOver(body)
return norm.NFC.String(body), caretX, caretY
}
@@ -280,6 +289,14 @@ func (a *app) frame() (string, int, int) {
// one composition pass [app.frame] puts over the whole of it.
func (a *app) frameBody() (string, int, int) {
a.inlineWaitShowing = false
+ // AND THERE IS NO NAV UNTIL A FRAME DRAWS ONE. Every frame with a head goes
+ // through [app.headRows], which records the row it put the nav on; the
+ // frames that do not (home's phone inbox and sheet, the task record card, a
+ // conversation under the strip's floors) draw something else in those cells
+ // entirely, and a press resolved against the last nav this window happened
+ // to paint would open a place for a click on a rule (topnav.go's
+ // [app.navPress]).
+ a.tabRow = -1
width, height := a.size()
if a.pasteEdit.open {
return a.pasteEditorFrame(width, height)
@@ -291,13 +308,6 @@ func (a *app) frameBody() (string, int, int) {
if a.wall.on {
return strings.Join(a.wallFrame(width, height), "\n"), 0, 0
}
- // AND THERE IS NO TAB BAR UNTIL A FRAME DRAWS ONE. Every place goes through
- // [placeFrame], which records the row it put the bar on; the frames that do
- // not — home's phone inbox and sheet, the task record card — draw something
- // else in those cells entirely, and a press resolved against the last bar
- // this window happened to paint would open a place for a click on a rule
- // (placemouse.go's [app.placeTabPress]).
- a.tabRow = -1
// THE FIRST-RUN SETUP IS DECIDED BEFORE EVERY OTHER FULLSCREEN SURFACE,
// because it is the one that may be open before any of them exists and it
// goes away to reveal whichever of them was decided underneath (firstrun.go).
@@ -329,7 +339,7 @@ func (a *app) frameBody() (string, int, int) {
// with a refusal under a box that should never have been on the page.
//
// IT IS UNDER THE PLACES because a place is a room in the machine and this is
- // one job in one conversation; alt+1…8 leaves it, and [app.standDownRest]
+ // one job in one conversation; alt+1…9 leaves it, and [app.standDownRest]
// closes it on the way out so it cannot reappear under a place somebody has
// since walked away from.
//
@@ -443,7 +453,13 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) {
// conversation that is and which others this window can go back to
// (chattabs.go). They are two rows because they are two questions — the one
// that used to carry both carried neither well.
- tabs := a.tabsRow(width)
+ // THE STRIP IS LAID OUT ONLY WHILE A CONVERSATION IS IN FRONT. The teams
+ // page's hosted pane is drawn through here with the page set aside, and
+ // that pane wears the place's head, which has no strip (head.go).
+ tabs := ""
+ if a.stripInHead() {
+ tabs = a.tabsRow(width)
+ }
head := a.roomHeadRows(width)
// AND THE TASK STRIP IS THE ROW UNDER IT, for the same reason and at the same
// width: what is running is a fact about the SESSION, not about the
@@ -460,15 +476,17 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) {
// the whole window rather than about the transcript (task.go).
rows := make([]string, 0, height)
- if tabs != "" {
- // THE HEAD IS THE PLACES' HEAD — the pulse, the strip, the rule and the
- // blank — drawn by the one function both frames call (head.go), and a
- // room lays its trail under the blank (chattabs.go's
- // [app.headSealHeight]). The prefix is cut at exactly the count
- // [app.headHeight] charges, so the rows the frame draws and the rows the
- // scrolling subtracts are the same rows by construction.
- head := a.headRows(width, tabs, a.pal)
- rows = append(rows, head[:a.tabsHeight(width)+a.headSealHeight(width)]...)
+ if n := a.tabsHeight(width) + a.headSealHeight(width); n > 0 {
+ // THE HEAD IS DRAWN BY THE ONE FUNCTION BOTH FRAMES CALL (head.go):
+ // the nav, the strip while a conversation is in front, the rule and
+ // the blank. A room lays its trail under the blank. The prefix is cut
+ // at exactly the count [app.headHeight] charges, so the rows the frame
+ // draws and the rows the scrolling subtracts are the same rows.
+ drawn := a.headRows(width, tabs, a.pal)
+ if n > len(drawn) {
+ n = len(drawn)
+ }
+ rows = append(rows, drawn[:n]...)
}
if len(head) > 0 {
rows = append(rows, head...)
@@ -481,6 +499,12 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) {
rows = append(rows, a.roomKinRows(width)...)
}
rows = append(rows, strip...)
+ // AND THE TEAMS PAGE'S OWN ROWS, while it hosts this conversation: the team's
+ // header, members and inbox, pinned under the strip and charged in
+ // [app.topHeight] on the same terms (teamspagehost.go).
+ if a.teamsHosting() {
+ rows = append(rows, a.teamsHostTop(width)...)
+ }
// THE ROSTER TAKES THE BODY WHOLE on a frame with no columns to lend it: the
// same rows, the same folds, the same footer, laid out at the full width
// instead of squeezed into thirty columns that are not there (task.go's
@@ -501,9 +525,7 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) {
rows = append(rows, lifted...)
return a.frameLines(rows, chrome, height, caretX, caretRow, lift, liftedAt)
}
- // AND THE TRAFFIC RAIL BESIDE IT while the manager is in front, joined
- // onto the task column's rows so one join lays both (teamtraffic.go).
- rail := a.trafficBeside(a.railRows(view), view)
+ rail := a.railRows(view)
railAt := func(i int) string {
if i < len(rail) {
return rail[i]
@@ -542,14 +564,6 @@ func (a *app) chatFrameLines(width, height int) ([]string, int, int) {
// is two rows of body and thirty of pad, and a card centred in the body would
// sit at the top of an empty screen. What it is centred in is what a person
// sees, which is the region.
- // ON A NARROW FRAME THE TRAFFIC IS LAID OVER THE BODY when the person asked
- // for it (teamrail.go): a card over the lower rows, with the top of the
- // conversation still drawn above it, and a press on a row of it is a press
- // on it.
- if a.trafficOverShowing() {
- body, pad = a.trafficOverBody(body, pad, view)
- selOn = false
- }
if a.hopShowing() {
texts := make([]string, view)
for i, r := range body {
@@ -1032,6 +1046,11 @@ func (a *app) rule(width int) string {
// nothing at all.
func (a *app) size() (int, int) {
width, height := a.width, a.height
+ // THE TEAMS PAGE LENDS ITS MANAGER A RECTANGLE (teamspagehost.go): while the
+ // pane hosts the conversation, the conversation's width is the terminal's
+ // less the rail, so every layout and hit test it makes resolves against the
+ // cells it is really drawn in.
+ width -= a.teamsHostRail()
if width < 8 {
width = 8
}
@@ -1240,7 +1259,9 @@ func (a *app) startPageBody() int {
// is resolved through them — three questions that must never be able to disagree
// about where the body starts. Neither of the two may ask [app.viewHeight] back,
// which is why both answer from the terminal's size alone.
-func (a *app) topHeight() int { return a.headHeight() + a.stripHeight() }
+func (a *app) topHeight() int {
+ return a.headHeight() + a.stripHeight() + a.teamsHostTopHeight()
+}
// headHeight is what the pinned focus header costs the body region: one row
// while a room is open on a frame with the height to spare, the kin rows under
@@ -1250,11 +1271,9 @@ func (a *app) topHeight() int { return a.headHeight() + a.stripHeight() }
// through, rather than at the frame — a header the frame drew and the scrolling
// did not know about would put the room's last row under the input box.
func (a *app) headHeight() int {
- // THE PULSE AND THE TAB STRIP ARE THE FIRST OF THOSE ROWS AND ARE CHARGED
- // FOR HERE, on the strip's own two floors (chattabs.go's [app.tabsHeight]):
- // they are drawn over the conversation and over every page inside it,
- // because which conversation this is stays true wherever you have walked to
- // inside one.
+ // THE NAV, AND THE STRIP WHILE A CONVERSATION IS IN FRONT, ARE THE FIRST
+ // OF THOSE ROWS AND ARE CHARGED FOR HERE (chattabs.go's [app.tabsHeight]).
+ // On a place the strip is not drawn, so the page starts one row higher.
//
// AND THE SEAM UNDER THE STRIP — the rule and the blank that are the head's
// last two rows on every frame, a room's included — which is what closes the
@@ -1423,6 +1442,11 @@ func (a *app) resized(width, height int) tea.Cmd {
return nil
}
a.width, a.height = width, height
+ // A hover names a door the last layout drew. The new width may not draw
+ // it: `more ▾` leaves the row once the places fit, and a nav word may
+ // fold. The hint reads the hover, not the row, so leaving it would keep
+ // naming a door that is gone until the pointer moved.
+ a.dropResizeHover()
a.touch()
if a.rows == nil {
a.clampScroll()
diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go
index c949b996c4..630ac00b73 100644
--- a/internal/tui3/wall.go
+++ b/internal/tui3/wall.go
@@ -41,12 +41,12 @@ import (
// x close its view, or the picked' Close on a tile, Close views
// m its teams, or the picked' Teams on a tile, Add to…
// s new team of the picked + New team, Make team
-// e the shown team's settings a segment's dot or ⋯
+// e the shown team's card a segment's dot or ⋯
// r resume the shown team's the title's Open them
// members not open here
// tab, shift+tab next or previous team a Teams segment
// 1 to 9 that team, again for All a Teams segment
-// D delete the shown team settings, Delete
+// D close the shown team Close… on its card
// o suggest teams, on All ✦ Organize on the Teams row
// u undo the last Organize Undo, while the Teams row offers it
// / filter Filter /
@@ -231,6 +231,7 @@ func (a *app) wallFrame(width, height int) []string {
name: a.wall.name,
nameFresh: a.wall.nameFresh,
asking: a.wall.nameAsking,
+ nameIn: a.teamNameOf(a.wall.nameParent),
made: a.wall.made,
madeN: a.wall.madeN,
madeAt: a.wall.madeAt,
@@ -270,14 +271,14 @@ func (a *app) wallFrame(width, height int) []string {
}
view.org = a.wallOrganizeFrame(tiles, tabs)
view.total = len(open)
- for _, t := range a.wall.teams {
+ for _, t := range a.wallTeams() {
n := 0
for _, m := range t.Members {
if open[m.Key] {
n++
}
}
- view.teams = append(view.teams, wallTeamRow{id: t.ID, name: t.Name, hue: t.HueSpec(), count: n, members: len(t.Members)})
+ view.teams = append(view.teams, wallTeamRow{id: t.ID, name: a.wallTeamLabel(t), hue: t.HueSpec(), count: n, members: len(t.Members)})
}
if t, ok := a.teamActive(); ok {
view.away = len(a.teamAway(t, tabs))
@@ -490,18 +491,18 @@ func (a *app) wallCommand(key string, tiles []wallTile) tea.Cmd {
case "e":
// The shown team's settings, where its dot or ⋯ opens them.
if id := a.wall.activeID; id != "" {
- a.wallOpenSettings(id, a.wallAnchorTeam(wallHitChipMenu, id))
+ return a.teamSheetOpen(id, teamSheetSettings)
}
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
// A team by its place on the Teams row; the digit of the team that
// is shown goes back to All, so the same key undoes itself.
i := int(key[0] - '1')
switch {
- case i >= len(a.wall.teams):
- case a.wall.teams[i].ID == a.wall.activeID:
+ case i >= len(a.wallTeams()):
+ case a.wallTeams()[i].ID == a.wall.activeID:
a.wallSetTeam("")
default:
- a.wallSetTeam(a.wall.teams[i].ID)
+ a.wallSetTeam(a.wallTeams()[i].ID)
}
case "/":
a.wall.filterOn = true
@@ -519,10 +520,11 @@ func (a *app) wallCommand(key string, tiles []wallTile) tea.Cmd {
a.wall.cols = 0
a.wallMove(a.wall.focus, n)
case "D":
- // Delete the active team. The conversations in it are untouched: a
- // team is a view, and so is its going.
+ // CLOSE the shown team (ruling c-9): at once with Undo when nothing
+ // in it runs, and the close card when something does. A team is
+ // deleted only from the teams page's Closed fold, once it is closed.
if id := a.wall.activeID; id != "" {
- a.wallDeleteTeam(id)
+ return a.teamsCloseAsk(id)
}
case "o":
return a.wallOrganizeOpen()
@@ -643,6 +645,7 @@ func (a *app) wallStartNaming(tiles []wallTile) tea.Cmd {
a.wall.naming = true
a.wall.filterOn = false
a.wall.pop = wallPop{}
+ a.wall.nameParent = ""
a.wall.name = teamFreshName(marked, a.teamNames(), "", rand.IntN)
a.wall.nameFresh = true
// The colours offered are the farthest from every team's, best first,
@@ -783,7 +786,9 @@ func (a *app) wallMakeTeam(tiles []wallTile) tea.Cmd {
if a.wall.choice >= 0 && a.wall.choice < len(a.wall.choices) {
hue = a.wall.choices[a.wall.choice]
}
- id, err := a.teamMakeHued(name, a.wallMarkedTabs(tiles), hue)
+ parent := a.wall.nameParent
+ a.wall.nameParent = ""
+ id, err := a.teamMakeIn(name, a.wallMarkedTabs(tiles), hue, parent)
made, ok := a.teamByID(id)
if !ok {
return nil
@@ -858,11 +863,12 @@ func (a *app) wallDeleteTeam(id string) {
// and the strip show; it never switches the conversation in front, so a person
// can look through their teams without leaving the one they are in.
func (a *app) wallCycleTeam(forward bool) {
- n := len(a.wall.teams)
+ shown := a.wallTeams()
+ n := len(shown)
if n == 0 {
return
}
- at := teamIndex(a.wall.teams, a.wall.activeID) // -1 is All
+ at := teamIndex(shown, a.wall.activeID) // -1 is All
next := at + 1
if !forward {
next = at - 1
@@ -877,7 +883,7 @@ func (a *app) wallCycleTeam(forward bool) {
a.wallSetTeam("")
return
}
- a.wallSetTeam(a.wall.teams[next].ID)
+ a.wallSetTeam(shown[next].ID)
}
// ── THE POINTER ─────────────────────────────────────────────────────────────
@@ -916,9 +922,13 @@ func (a *app) wallMotion(x, y int) {
a.wall.ptrX, a.wall.ptrY, a.wall.ptrIn = x, y, y >= a.wall.headRows
if y < a.wall.headRows {
a.wallSetHover(wallHitRef{})
+ // The nav is on the head's first row here as on every page
+ // (topnav.go).
+ a.navHover(x, y)
a.setHover(x, y)
return
}
+ a.navHover(x, y)
if a.hot != (hoverAt{}) {
a.hot = hoverAt{}
a.touch()
@@ -1075,7 +1085,8 @@ func (a *app) wallDo(hit wallHit) tea.Cmd {
case wallHitChip:
a.wallSetTeam(hit.id)
case wallHitChipMenu:
- a.wallOpenSettings(hit.id, a.wallLocal(hit))
+ // The team's card (teamsheet.go), over the wall.
+ return a.teamSheetOpen(hit.id, teamSheetSettings)
case wallHitAddTeam:
return a.wallStartNaming(tiles)
case wallHitMini:
@@ -1655,3 +1666,24 @@ func dropLastRune(s string) string {
}
return string(r[:len(r)-1])
}
+
+// wallTeamLabel is a team's name on the wall's flat Teams row: `harbor › api`
+// for a team inside another (its parent's name first, so two teams called api
+// are told apart), and the bare name at the top level. The row stays one flat
+// row (ruling c-12); the tree is the teams page's and the switcher's. Each part
+// is cut on its own, so the team's own name is never the part that is lost.
+func (a *app) wallTeamLabel(t team) string {
+ name := t.Name
+ if ansi.StringWidth(name) > wallChipCap {
+ name = ansi.Truncate(name, wallChipCap, a.linearMark("…", "~"))
+ }
+ p, ok := a.teamByID(t.Parent)
+ if !ok || p.Root {
+ return name
+ }
+ parent := p.Name
+ if ansi.StringWidth(parent) > wallChipCap/2 {
+ parent = ansi.Truncate(parent, wallChipCap/2, a.linearMark("…", "~"))
+ }
+ return parent + " " + a.linearMark("›", ">") + " " + name
+}
diff --git a/internal/tui3/wallbar.go b/internal/tui3/wallbar.go
index b413757361..7275000188 100644
--- a/internal/tui3/wallbar.go
+++ b/internal/tui3/wallbar.go
@@ -238,6 +238,29 @@ func wallBarIn(pal palette, v wallView, width, inset, y int) (string, []wallHit)
return "", nil
}
}
+ // A HINT OUTRANKS THE BUTTONS IT WOULD BE CUT FOR. The status line is the
+ // one place a control says what it does and its key, and cut to fit
+ // between Back and the buttons it read `Resume the …` at 80 columns and
+ // lost the key at 110. So while it shows, the columns and then the acts
+ // step aside, never the one under the pointer and never Help, until the
+ // sentence is whole; they come back when the pointer leaves.
+ if hint != "" {
+ under := func(act wallAct) bool { return v.hover.kind == wallHitAction && v.hover.arg == int(act) }
+ need := ansi.StringWidth(hint) + 6
+ for 1+wallBarWidth(left, gap)+need+rightW()+max(inset-1, 0) > width {
+ switch {
+ case showCols && !under(wallActColsLess) && !under(wallActColsMore):
+ showCols = false
+ case showActs > 0 && !under(acts[showActs-1].act):
+ showActs--
+ default:
+ need = 0
+ }
+ if need == 0 {
+ break
+ }
+ }
+ }
s, lw, hits := wallLay(pal, left, v.hover, 1, y, gap)
row := " " + s
rw := rightW()
@@ -705,9 +728,11 @@ func wallTeamsSegments(pal palette, g wallGlyphs, v wallView, k wallKeys, y int,
}
segment(-1, "", "All", strconv.Itoa(v.total), v.team == "", true)
for i, t := range v.teams {
+ // The name is cut already, each part on its own (wall.go's
+ // [app.wallTeamLabel]): `harbor › api` is wider than one name.
name := t.name
- if ansi.StringWidth(name) > wallChipCap {
- name = ansi.Truncate(name, wallChipCap, g.more)
+ if ansi.StringWidth(name) > 2*wallChipCap {
+ name = ansi.Truncate(name, 2*wallChipCap, g.more)
}
if !segment(i, t.id, name, strconv.Itoa(t.count), t.id == v.team, true) {
whole = false
@@ -1236,7 +1261,11 @@ func wallNameCard(pal palette, g wallGlyphs, v wallView, width, height int) wall
x := (width - w) / 2
gridH := height - wallChromeRows
y := wallGridTop + max((gridH-wallNameCardRows)/2, 0)
- return wallCardBuild(pal, "New team", []wallCardLine{l1, l2, l3, l4}, x, y, w, wallCardPadX, wallCardPadY)
+ title := "New team"
+ if v.nameIn != "" {
+ title += " in " + v.nameIn
+ }
+ return wallCardBuild(pal, title, []wallCardLine{l1, l2, l3, l4}, x, y, w, wallCardPadX, wallCardPadY)
}
// ── POPOVERS ────────────────────────────────────────────────────────────────
diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go
index d4d58b5a76..9f7c613172 100644
--- a/internal/tui3/wallclick_test.go
+++ b/internal/tui3/wallclick_test.go
@@ -164,36 +164,39 @@ func TestWallClickTeamsPopoverAndSettings(t *testing.T) {
// The dot on a segment opens that team's settings.
_ = a.wallFrame(a.width, a.height)
wallClick(t, a, wallHitForTeam(t, a, wallHitChipMenu, harbor))
- if a.wall.pop.kind != wallPopSettings || a.wall.pop.team != harbor || a.wall.pop.name != "harbor" {
- t.Fatalf("settings: %+v", a.wall.pop)
+ // The team's card (teamsheet.go), over the wall.
+ if !a.tsheet.on || a.tsheet.team != harbor || a.tsheet.name.String() != "harbor" {
+ t.Fatalf("settings: %+v", a.tsheet)
}
before := a.wall.teams[0].HueSpec()
- wallClick(t, a, wallHitFor(t, a, wallHitSwatch, 2))
+ a.teamSheetDo(tsSwatch + 2)
if a.wall.teams[0].HueSpec() == before {
t.Fatal("a swatch did not recolour the team")
}
+ a.tsheet.cursor = tsName
for range "harbor" {
- a.wallKey(tea.KeyPressMsg{Code: tea.KeyBackspace})
+ a.teamSheetKey(tea.KeyPressMsg{Code: tea.KeyBackspace})
}
for _, r := range "dock" {
- a.wallKey(tea.KeyPressMsg{Code: r, Text: string(r)})
+ a.teamSheetKey(tea.KeyPressMsg{Code: r, Text: string(r)})
}
- a.wallKey(tea.KeyPressMsg{Code: tea.KeyEnter})
- if a.wall.teams[0].Name != "dock" || a.wall.pop.kind != wallPopNone {
- t.Fatalf("rename: %q, pop %+v", a.wall.teams[0].Name, a.wall.pop)
+ a.teamSheetKey(tea.KeyPressMsg{Code: tea.KeyEscape})
+ if a.wall.teams[0].Name != "dock" || a.tsheet.on {
+ t.Fatalf("rename: %q, card %+v", a.wall.teams[0].Name, a.tsheet)
}
_ = a.wallFrame(a.width, a.height)
+ // The card's `Close team…` closes it (ruling c-9: a team is deleted only
+ // once closed, from the teams page), and the wall's Teams row drops it.
wallClick(t, a, wallHitForTeam(t, a, wallHitChipMenu, harbor))
- wallClick(t, a, wallHitFor(t, a, wallHitPopRow, wallPopDelete))
- if len(a.wall.teams) != 2 || !a.wall.pop.confirm {
- t.Fatal("delete did not ask first")
+ a.teamSheetDo(tsCloseTeam)
+ if got, ok := a.teamByID(harbor); !ok || !got.Closed() {
+ t.Fatalf("Close team did not close it: %+v", a.teamNames())
}
- wallClick(t, a, wallHitFor(t, a, wallHitPopRow, wallPopConfirm))
- if len(a.wall.teams) != 1 || a.wall.teams[0].Name != "orbit" {
- t.Fatalf("after the delete: %+v", a.teamNames())
+ if shown := a.wallTeams(); len(shown) != 1 || shown[0].Name != "orbit" {
+ t.Fatalf("after the close the wall lists %d teams", len(shown))
}
if len(a.wallShown(a.now())) != len(tiles) {
- t.Fatal("deleting a team closed a conversation")
+ t.Fatal("closing a team closed a conversation on the wall")
}
}
@@ -221,7 +224,7 @@ func TestTabTeamChipNamesTheShownTeam(t *testing.T) {
t.Fatalf("a tab was drawn under the chip: %+v", hit)
}
}
- if _, took := a.tabPress(a.wall.chip.from+1, placeTabRow); !took || !a.teamMenu.on || a.wall.on {
+ if _, took := a.tabPress(a.wall.chip.from+1, tabStripRow); !took || !a.teamMenu.on || a.wall.on {
t.Fatal("the chip did not open the team switcher")
}
t.Logf("%q", row)
diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go
index f5e86e4450..948e0e6c99 100644
--- a/internal/tui3/wallcontract.go
+++ b/internal/tui3/wallcontract.go
@@ -141,6 +141,9 @@ type wallView struct {
// one is being asked for, and the card says `naming…`.
nameFresh bool
asking bool
+ // nameIn is the team the new team will be made inside, by name, "" for
+ // the top level (`+ New team in harbor` on the teams page).
+ nameIn string
// made is the team just made and how many it holds, said on the chip row
// until madeAt is wallMadeFor old.
made string
@@ -371,6 +374,9 @@ type wallState struct {
nameFresh bool
nameGen int
nameAsking bool
+ // nameParent is the team a new team is made inside, by id: set by `+ New
+ // team in harbor` and cleared by every other opening of the card.
+ nameParent string
made string
madeN int
madeAt time.Time
diff --git a/internal/tui3/walldock.go b/internal/tui3/walldock.go
index d1d1375caf..2e22d5ec12 100644
--- a/internal/tui3/walldock.go
+++ b/internal/tui3/walldock.go
@@ -17,17 +17,23 @@ import (
// small map of every conversation this window has open:
//
// › say what you want done
-// alt+k chats · / commands chats ▦ ▪▪▣▪
+// alt+k chats · / commands ▦ All ▪▪▣▪
//
-// `chats` is the quiet word in front of the cells, and `▦` beside it is the
-// wall. A press on either opens the wall. The word is there because a row of
-// squares alone did not say what it was. Each cell after them is one
-// conversation, in the strip's own order ([app.tabList]), painted by what it
-// is doing: the live hue while it works, the warning hue while it waits on a
+// `▦ All` is the wall's door, spelled exactly as the strip's own door to it is
+// spelled (chattabs.go's [tabWallWord]), and it is ONE BUTTON: the glyph and the
+// word are one target, and the pointer's ground covers both. Each cell after it
+// is one conversation, in the strip's own order ([app.tabList]), painted by what
+// it is doing: the live hue while it works, the warning hue while it waits on a
// person, dim at rest. The one in front is `▣` in ink. A press on a cell goes
// to that conversation by the strip's own door ([app.tabGo]), so a cell can
// never do something its tab would not.
//
+// ONE WORD, ONE DOOR, EVERYWHERE. The word here used to be `chats`, and the
+// nav's `chats` sits over the same frame and goes somewhere else: back to the
+// conversation in front (place_chats.go). Two presses on one word that land in
+// two places is a word a person cannot learn, so this door says what it opens,
+// in the words the strip already uses for it.
+//
// IT READS MEMORY AND NOTHING ELSE. The cells are the strip's list and the
// strip's signals ([app.tabSignalFor]), both of which are the keeper's cached
// facts, because this row is drawn on every frame and the frame may not touch
@@ -41,7 +47,7 @@ import (
// AND IT TAKES ONLY WHAT THE KEYS LEFT. The keys are how a person drives this
// frame from the keyboard, and the aliveness on a frame with no seam is the
// one fact a frame may never lose (footswap.go), so both are laid out first
-// and the dock is fitted into what remains: the word `chats` first, then
+// and the dock is fitted into what remains: the word `All` first, then
// fewer cells and a `+N` count, and then no dock at all. The telemetry is on
// the seam, a row the dock never draws on, so no number is ever given up for it.
@@ -52,19 +58,23 @@ const dockCap = 12
// dropped whole: one cell and a count is not a map of anything.
const dockFloor = 2
-// dockLabel is the quiet word in front of the cells. It is ASCII, so its
-// length is its width, and a press on it is the wall's own door.
-const dockLabel = "chats"
+// dockLabel is the word after the wall's glyph, the strip's own word for the
+// same door ([tabWallWord]). It is ASCII, so its length is its width.
+const dockLabel = tabWallWord
// dockWallWord is what the hint slot says while the pointer rests on the
-// strip's own door to the wall (`▦ All`). The dock's word and glyph say
-// [dockChatsWord] instead: they are the same door, and the sentence under
-// the box is the one a person reads while aiming at a square.
-const dockWallWord = "Conversations " + wallOpenKey
+// strip's own door to the wall (`▦ All`), and the dock's `▦ All` says
+// [dockChatsWord], which is the same sentence: they are the same door.
+//
+// IT SAYS GRID AND TABS, NOT "EVERY CONVERSATION", because the nav's `chats`
+// sits right over it on every page and is the place for every conversation
+// (topnav.go). Two doors a row apart that both said "every conversation"
+// would be one door drawn twice; this one is the tabs open here, side by side.
+const dockWallWord = "The grid of your open tabs, and your teams" + hintSegment + wallOpenKey
-// dockChatsWord is what the hint slot says while the pointer rests on `chats`
-// or on the dock's `▦`.
-const dockChatsWord = "All conversations" + hintSegment + wallOpenKey
+// dockChatsWord is what the hint slot says while the pointer rests on the
+// dock's `▦ All`.
+const dockChatsWord = dockWallWord
// dockCell is one conversation's cell as it was drawn: its column and its tab.
type dockCell struct {
@@ -75,7 +85,8 @@ type dockCell struct {
// dockMap is where the dock landed on the last frame, written by the draw and
// read by the pointer (render.go's [hudSpan] bargain): a press resolves against
// what was painted, never against a second computation of it. label is the
-// word in front, empty on a row that dropped it.
+// whole door, glyph and word, empty on a row that dropped the word; wall is
+// the glyph's own cell, which is the whole door on a row with no word.
type dockMap struct {
label hudSpan
wall hudSpan
@@ -119,7 +130,7 @@ func (a *app) dockTabs() []chatTab {
// dockLayout is which cells a dock of at most room cells draws: the first
// tab shown, how many, how many it could not spell, and whether the word
-// `chats` fits in front. The one in front is always inside the window, and
+// `All` fits after the glyph. The one in front is always inside the window, and
// the order is never changed. ok is false when no dock fits, or when there
// is nothing to map.
//
@@ -162,7 +173,7 @@ func dockLayout(tabs []chatTab, room int) (from, count, hidden int, label, ok bo
}
// dockWidth is the cells a dock of count cells and hidden more takes. label
-// is the word `chats` and the space that keeps it off the wall's glyph.
+// is the word `All` and the space between it and the wall's glyph.
func dockWidth(count, hidden int, label bool) int {
// Each cell is a full square and a space: `■ ■ ▣`, big enough to aim
// at, one cell of air so neighbours do not run into a bar.
@@ -171,7 +182,7 @@ func dockWidth(count, hidden int, label bool) int {
w += 2 + len(strconv.Itoa(hidden))
}
if label {
- w += len(dockLabel) + 1
+ w += 1 + len(dockLabel)
}
return w
}
@@ -208,29 +219,29 @@ func (a *app) dockWallGlyph() string {
// dockPaint draws the dock with its first piece at column at, and records
// where every piece of it landed. The wall's mark wears the active team's
-// colour, so the dock also says which team this window is in. The word in
-// front stays dim: it names the row, and the squares carry the state.
+// colour, so the dock also says which team this window is in. The word after
+// it stays dim: it names the door, and the squares carry the state.
+//
+// THE DOOR IS ONE BUTTON: under the pointer the ground covers the glyph, the
+// space and the word, which is exactly the span a press on it takes.
func (a *app) dockPaint(tabs []chatTab, from, count, hidden int, label bool, at int) string {
var b strings.Builder
cursor := at
+ a.dock.wall = hudSpan{from: cursor, to: cursor + 1}
+ a.dock.label = hudSpan{}
+ door := 1
if label {
- a.dock.label = hudSpan{from: cursor, to: cursor + len(dockLabel)}
- word := dockLabel
- if a.hot.kind == hoverDockLabel {
- b.WriteString(a.pal.cursor(a.pal.ink(word), 0))
- } else {
- b.WriteString(a.pal.dim(word))
- }
- b.WriteString(" ")
- cursor += len(dockLabel) + 1
- } else {
- a.dock.label = hudSpan{}
+ door += 1 + len(dockLabel)
+ a.dock.label = hudSpan{from: cursor, to: cursor + door}
}
- a.dock.wall = hudSpan{from: cursor, to: cursor + 1}
wall := a.dockWallGlyph()
switch {
- case a.hot.kind == hoverDockWall:
- b.WriteString(a.pal.cursor(a.pal.ink(wall), 0))
+ case a.hot.kind == hoverDockWall || a.hot.kind == hoverDockLabel:
+ word := wall
+ if label {
+ word += " " + dockLabel
+ }
+ b.WriteString(a.pal.cursor(a.pal.ink(word), 0))
default:
ink := a.pal.dim
if sp, ok := a.teamActive(); ok {
@@ -239,7 +250,11 @@ func (a *app) dockPaint(tabs []chatTab, from, count, hidden int, label bool, at
}
}
b.WriteString(ink(wall))
+ if label {
+ b.WriteString(a.pal.dim(" " + dockLabel))
+ }
}
+ cursor += door - 1
b.WriteString(" ")
cells := a.dock.cells[:0]
for i, tab := range tabs[from : from+count] {
@@ -287,8 +302,8 @@ func dockCellHint(tab chatTab) string {
}
// dockHoverWords is what the hint slot says while the pointer rests on the
-// dock, and "" when it rests anywhere else: `All conversations · alt+v` over
-// the word and over `▦`, and [dockCellHint] over a cell. The strip's own door
+// dock, and "" when it rests anywhere else: `The grid of your open tabs, and
+// your teams · alt+v` over `▦ All`, and [dockCellHint] over a cell. The strip's own door
// to the wall (chattabs.go) is explained here too, in its own sentence.
func (a *app) dockHoverWords() string {
// THE TEAM'S OWN DOORS EXPLAIN THEMSELVES HERE TOO: the manager's place on
@@ -302,6 +317,9 @@ func (a *app) dockHoverWords() string {
if a.wall.door.pressable() && a.hot.index == a.wall.door.from {
return dockWallWord
}
+ if words := a.stripHoverWords(); words != "" {
+ return words
+ }
case hoverDockLabel, hoverDockWall:
if (a.hot.kind == hoverDockLabel && a.dock.label.pressable()) || (a.hot.kind == hoverDockWall && a.dock.wall.pressable()) {
return dockChatsWord
@@ -314,14 +332,47 @@ func (a *app) dockHoverWords() string {
return dockCellHint(cell.tab)
}
}
- // THE MANAGER'S TASKS' HEADER IS THE WAY BACK TO THE TRAFFIC (teamrail.go).
- if a.hot.kind == hoverRailDoor && a.trafficOn() {
- return "Back to the traffic" + hintSegment + railStowKey
- }
// AND A JUMP THAT FOUND NOTHING SAYS SO, for a moment (teamjump.go).
return a.trafficJumpWords()
}
+// stripHoverWords is what the hint slot says while the pointer rests on a
+// piece of the strip that has no sentence of its own elsewhere: a tab, its
+// `×`, the new-chat `+` and the scroll arrows. EVERY DOOR ON THE ROW SAYS WHAT
+// IT DOES, so none of them is a mark a person has to press to learn about.
+// A tab says what its square in the dock says ([dockCellHint]), because they
+// are one conversation and one door.
+func (a *app) stripHoverWords() string {
+ hit, ok := a.hotTab()
+ if !ok {
+ return ""
+ }
+ name := hit.tab.full
+ if name == "" {
+ name = hit.tab.word
+ }
+ switch hit.kind {
+ case tabHere, tabOther:
+ if hit.kind == tabHere && (a.roomOpen() || a.startingChat()) {
+ return "Back to " + name + hintSegment + "click"
+ }
+ return dockCellHint(hit.tab)
+ case tabClose:
+ words := "Close this tab" + hintSegment + "the work keeps running"
+ if hit.tab.here {
+ return words + hintSegment + a.chords.say(closeTabChord)
+ }
+ return words + hintSegment + "click"
+ case tabNew:
+ return "New chat" + hintSegment + a.chords.say(newChatChord)
+ case tabScrollLeft:
+ return "More tabs to the left" + hintSegment + "click"
+ case tabScrollRight:
+ return "More tabs to the right" + hintSegment + "click"
+ }
+ return ""
+}
+
// dockAt is the dock's piece under column x on the keys row, as a hover.
func (a *app) dockAt(x int) (hoverAt, bool) {
if a.dock.label.holds(x) {
@@ -339,7 +390,7 @@ func (a *app) dockAt(x int) (hoverAt, bool) {
}
// dockPress is a press on the keys row, and it takes only the dock's own
-// cells: `chats` and `▦` open the wall, a cell goes to its conversation, and
+// cells: `▦ All` opens the wall, a cell goes to its conversation, and
// the one in front is already where the press would go.
func (a *app) dockPress(x, y int) (tea.Cmd, bool) {
if a.wall.on || a.copy.on || a.rew.on {
diff --git a/internal/tui3/walldock_test.go b/internal/tui3/walldock_test.go
index d915b5587c..5c47b49cab 100644
--- a/internal/tui3/walldock_test.go
+++ b/internal/tui3/walldock_test.go
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -107,14 +108,16 @@ func TestDockDrawsEveryConversationInStripOrder(t *testing.T) {
if got := ansi.Cut(row, a.dock.wall.from, a.dock.wall.to); got != "▦" {
t.Fatalf("the wall's mark is not where it was recorded: %q", got)
}
- if !a.dock.label.pressable() || ansi.Cut(row, a.dock.label.from, a.dock.label.to) != dockLabel {
- t.Fatalf("the word is not where it was recorded: %q %+v", row, a.dock.label)
+ // THE DOOR IS `▦ All`, SPELLED AS THE STRIP SPELLS IT, AND ONE BUTTON: the
+ // recorded span holds the glyph and the word, and the glyph is its first cell.
+ if !a.dock.label.pressable() || ansi.Cut(row, a.dock.label.from, a.dock.label.to) != "▦ "+tabWallWord {
+ t.Fatalf("the door is not where it was recorded: %q %+v", row, a.dock.label)
}
- if !strings.Contains(painted, a.pal.dim(dockLabel)) {
+ if !strings.Contains(painted, a.pal.dim(" "+dockLabel)) {
t.Fatalf("the word is not dim: %q", painted)
}
- if a.dock.label.to != a.dock.wall.from-1 {
- t.Fatalf("the word does not stand in front of the glyph: label %+v wall %+v", a.dock.label, a.dock.wall)
+ if a.dock.label.from != a.dock.wall.from {
+ t.Fatalf("the glyph does not lead the door: label %+v wall %+v", a.dock.label, a.dock.wall)
}
// The ASCII floor spells the state in the character.
a.linear = true
@@ -289,7 +292,7 @@ func TestDockHoverHintPerCellKind(t *testing.T) {
if got := dockCellHint(here); got != "Shipping the parser"+hintSegment+"you are here" {
t.Fatalf("here: %q", got)
}
- if dockChatsWord != "All conversations"+hintSegment+wallOpenKey {
+ if dockChatsWord != dockWallWord {
t.Fatalf("the word and the glyph say %q", dockChatsWord)
}
}
@@ -327,13 +330,57 @@ func TestDockHoverWearsTheWallsHoverGround(t *testing.T) {
if !strings.Contains(lines[y], ground) {
t.Fatalf("the hovered cell is not on the cursor ground: %q", lines[y])
}
- a.setHover(a.dock.label.from, y)
- lines = strings.Split(frame(a), "\n")
- word := a.pal.cursor(a.pal.ink(dockLabel), 0)
- if !strings.Contains(lines[y], word) {
- t.Fatalf("the hovered word is not on the cursor ground: %q", lines[y])
+ // THE DOOR IS ONE GROUND FROM ANY CELL OF IT: the glyph, the space and the
+ // word light together, because a press on any of them opens the wall.
+ door := a.dock.label
+ word := a.pal.cursor(a.pal.ink(a.dockWallGlyph()+" "+dockLabel), 0)
+ for x := door.from; x < door.to; x++ {
+ a.setHover(x, y)
+ lines = strings.Split(frame(a), "\n")
+ if !strings.Contains(lines[y], word) {
+ t.Fatalf("the pointer at %d of the door %+v does not light all of it: %q", x, door, lines[y])
+ }
}
if wall := wallButtonPaint(a.pal, wallButton{label: "x"}, true); !strings.Contains(wall, a.pal.cursor(" ", 0)[:strings.Index(a.pal.cursor(" ", 0), " ")]) {
t.Fatalf("the wall's hover is not the cursor ground: %q", wall)
}
}
+
+// ONE WORD, ONE DOOR. The dock's door to the wall is spelled as the strip's
+// door to it is, `▦ All`, and both open the wall; the nav's `chats` over
+// them is the way back to the conversations and never the wall. The dock
+// used to say `chats` too, so one word on one frame led two places.
+func TestTheDocksDoorIsSpelledAsTheStripsDoor(t *testing.T) {
+ a, _, _ := tabApp(t)
+ a.width, a.height = 120, 30
+ rows := screenLines(a)
+ y := dockRowY(t, a)
+ strip := strings.TrimSpace(ansi.Cut(rows[tabStripRow], a.wall.door.from, a.wall.door.to))
+ dock := ansi.Cut(rows[y], a.dock.label.from, a.dock.label.to)
+ if strip != "▦ "+tabWallWord || dock != strip {
+ t.Fatalf("the strip's door says %q and the dock's says %q", strip, dock)
+ }
+ if strings.Contains(ansi.Cut(rows[y], a.dock.label.from-8, a.width), "chats") {
+ t.Fatalf("the dock still carries the nav's word: %q", rows[y])
+ }
+ for _, press := range []func() (tea.Cmd, bool){
+ func() (tea.Cmd, bool) { return a.dockPress(a.dock.label.to-1, y) },
+ func() (tea.Cmd, bool) { return a.tabPress(a.wall.door.from+1, tabStripRow) },
+ } {
+ if _, took := press(); !took || !a.wall.on {
+ t.Fatal("a press on `▦ All` did not open the wall")
+ }
+ a.closeWall()
+ rows = screenLines(a)
+ y = dockRowY(t, a)
+ }
+ for _, span := range a.tabs {
+ if span.id != pageChats {
+ continue
+ }
+ drive(t, a, tea.MouseClickMsg{X: span.from + 1, Y: navRow, Button: tea.MouseLeft})
+ if a.wall.on {
+ t.Fatal("the nav's `chats` opened the wall")
+ }
+ }
+}
diff --git a/internal/tui3/walldoor_test.go b/internal/tui3/walldoor_test.go
index b3e753d346..6962471d8f 100644
--- a/internal/tui3/walldoor_test.go
+++ b/internal/tui3/walldoor_test.go
@@ -42,15 +42,16 @@ func TestTabWallDoorStandsAfterTheNewChat(t *testing.T) {
plus = hit
}
}
- if !plus.span.pressable() || plus.span.to != door.from {
- t.Fatalf("the door does not touch the +: + %+v door %+v\n%q", plus.span, door, plain(row))
+ // ONE GAP AFTER THE `+`, the gap between any two pieces of the strip.
+ if !plus.span.pressable() || plus.span.to+1 != door.from || plain(ansi.Cut(row, plus.span.to, door.from)) != " " {
+ t.Fatalf("the door does not stand one gap after the +: + %+v door %+v\n%q", plus.span, door, plain(row))
}
// The same blank either side as the +.
pw := plain(ansi.Cut(row, plus.span.from, plus.span.to))
if !strings.HasPrefix(pw, " ") || !strings.HasSuffix(pw, " ") || len(pw) != 3 {
t.Fatalf("the + is %q", pw)
}
- if hit, ok := a.tabAt(door.from, placeTabRow); !ok || hit.kind != tabWall {
+ if hit, ok := a.tabAt(door.from, tabStripRow); !ok || hit.kind != tabWall {
t.Fatalf("the strip does not answer for the door: %+v", hit)
}
for _, w := range tabWords(a) {
@@ -66,7 +67,7 @@ func TestTabWallDoorHover(t *testing.T) {
a := wallDoorApp(t, 120)
rest := a.tabsRow(120)
door := a.wall.door
- hover, ok := a.tabHoverAt(door.from+2, placeTabRow)
+ hover, ok := a.tabHoverAt(door.from+2, tabStripRow)
if !ok {
t.Fatal("the door does not take the pointer")
}
@@ -79,7 +80,7 @@ func TestTabWallDoorHover(t *testing.T) {
if !strings.Contains(cell, "\x1b[48;") || cell == ansi.Cut(rest, door.from, door.to) {
t.Fatalf("the hovered door wears no ground: %q", cell)
}
- if got := a.dockHoverWords(); got != "Conversations "+wallOpenKey {
+ if got := a.dockHoverWords(); got != dockWallWord || !strings.Contains(got, "teams") || !strings.HasSuffix(got, wallOpenKey) {
t.Fatalf("the hint slot says %q", got)
}
t.Logf("120 columns at rest:\n%q\nhovered:\n%q", plain(rest), plain(lit))
@@ -101,7 +102,7 @@ func TestTabWallDoorOpensAndCloses(t *testing.T) {
t.Fatal("the door is not lit while the view is up")
}
t.Logf("120 columns with the view up:\n%q", plain(open))
- if _, took := a.wallPress(door.from+1, placeTabRow); !took || a.wall.on {
+ if _, took := a.wallPress(door.from+1, tabStripRow); !took || a.wall.on {
t.Fatalf("a press on the door from inside the view: took %v on %v", took, a.wall.on)
}
// And the tab strip's own press closes it too.
@@ -112,9 +113,9 @@ func TestTabWallDoorOpensAndCloses(t *testing.T) {
}
}
-// THE DOOR NARROWS AND GOES WITH THE ROW: the word first, then the glyph, and
-// the count of hidden tabs stays the last thing on it. The row is exactly the
-// frame's width.
+// THE DOOR GOES WITH THE ROW, WORD AND GLYPH TOGETHER: ` ▦ All ` wherever it
+// is drawn and nothing under [tabWallFrom], never a lone glyph; the count of
+// hidden tabs stays the last thing on it. The row is exactly the frame's width.
func TestTabWallDoorAtEveryWidth(t *testing.T) {
for _, width := range []int{40, 60, 80, 120, 160} {
a := wallDoorApp(t, width)
diff --git a/internal/tui3/wallhelp.go b/internal/tui3/wallhelp.go
index 4cc128494a..09f8ad6b41 100644
--- a/internal/tui3/wallhelp.go
+++ b/internal/tui3/wallhelp.go
@@ -71,7 +71,8 @@ func wallHelpList(ascii bool) []wallHelpGroup {
row("Organize", "o", "o", "Suggest teams for your conversations"),
row("Add to teams", "m", "m", "Add the focused conversation to teams"),
row("Make manager", "m", "m", "In the Teams list: make the focused conversation the shown team's manager"),
- row("Team settings", "e", "e", "Rename, recolour or delete the shown team"),
+ row("Team settings", "e", "e", "Rename, recolour or change the shown team's settings"),
+ row("Close team", "D", "D", "Close the shown team; reopen it from Closed on the teams page"),
row("Switch team", "tab / "+span, "tab", "Show the next team"),
row("Close view", "x", "x", "Close the focused view; the work keeps running"),
}},
diff --git a/internal/tui3/wallmotion_test.go b/internal/tui3/wallmotion_test.go
index 557a67bef5..2ba6ea81d2 100644
--- a/internal/tui3/wallmotion_test.go
+++ b/internal/tui3/wallmotion_test.go
@@ -431,14 +431,14 @@ func TestWallKeysDoWhatTheButtonsDo(t *testing.T) {
t.Fatal(err)
}
wallKeyPress(a, "e")
- if a.wall.pop.kind != wallPopNone {
+ if a.wall.pop.kind != wallPopNone || a.tsheet.on {
t.Fatal("e with no team shown opened something")
}
wallKeyPress(a, "1")
_ = a.wallFrame(a.width, a.height)
wallKeyPress(a, "e")
- if a.wall.pop.kind != wallPopSettings || a.wall.pop.team != harbor {
- t.Fatalf("e: %+v", a.wall.pop)
+ if !a.tsheet.on || a.tsheet.team != harbor {
+ t.Fatalf("e: %+v", a.tsheet)
}
}
diff --git a/internal/tui3/wallpop.go b/internal/tui3/wallpop.go
index eecc182df0..ff107a3c91 100644
--- a/internal/tui3/wallpop.go
+++ b/internal/tui3/wallpop.go
@@ -221,10 +221,10 @@ func (a *app) wallPopPress(hit wallHit, tiles []wallTile) tea.Cmd {
case p.kind == wallPopMembers && hit.arg == wallPopNew:
return a.wallPopNewTeam(tiles)
case p.kind == wallPopMembers && hit.arg == wallPopManager:
- p.cursor = len(a.wall.teams) + 1
+ p.cursor = len(a.wallTeams()) + 1
a.wallPopToggleManager(tiles)
case p.kind == wallPopMembers:
- if i := teamIndex(a.wall.teams, hit.id); i >= 0 {
+ if i := teamIndex(a.wallTeams(), hit.id); i >= 0 {
p.cursor = i
}
a.wallToggleTeam(hit.id, tiles)
@@ -258,7 +258,7 @@ func (a *app) wallPopKey(msg tea.KeyPressMsg, tiles []wallTile) tea.Cmd {
}
switch p.kind {
case wallPopMembers:
- last := len(a.wall.teams) // the + New team row
+ last := len(a.wallTeams()) // the + New team row
end := last
if a.wallPopManagerRow() != 0 {
end = last + 1 // the manager row under it
@@ -277,7 +277,7 @@ func (a *app) wallPopKey(msg tea.KeyPressMsg, tiles []wallTile) tea.Cmd {
return a.wallPopNewTeam(tiles)
}
if p.cursor >= 0 {
- a.wallToggleTeam(a.wall.teams[p.cursor].ID, tiles)
+ a.wallToggleTeam(a.wallTeams()[p.cursor].ID, tiles)
}
}
case wallPopSettings:
diff --git a/internal/tui3/wallview_test.go b/internal/tui3/wallview_test.go
index 6b3bd06e60..036de9df96 100644
--- a/internal/tui3/wallview_test.go
+++ b/internal/tui3/wallview_test.go
@@ -536,6 +536,38 @@ func TestWallBarDropsWholeButtons(t *testing.T) {
}
}
+// A HINT IS SAID WHOLE, KEY AND ALL. While the pointer rests on a control
+// the status line outranks the columns and the acts, which step aside for it
+// (the one under the pointer and Help never do); at 80 it read `Resume the …`.
+func TestWallBarHintOutranksTheButtons(t *testing.T) {
+ pal := newPalette(tokens.TrueColor, false)
+ v := wallUnmarked(wallFixture(6))
+ v.away = 2
+ v.hover = wallHitRef{kind: wallHitAction, arg: int(wallActResume)}
+ want := wallHint(v, pal.ascii)
+ if !strings.HasSuffix(want, "· r") {
+ t.Fatalf("the fixture's hint is %q", want)
+ }
+ for _, w := range []int{80, 110, 160} {
+ row, _ := wallBar(pal, v, w, 0)
+ plainRow := ansi.Strip(row)
+ if ansi.StringWidth(row) > w || !strings.Contains(plainRow, want) || !strings.Contains(plainRow, "Help ?") {
+ t.Fatalf("width %d: the hint is not whole beside Help: %q", w, plainRow)
+ }
+ }
+ // The button under the pointer keeps its place while its own hint shows.
+ v.hover = wallHitRef{kind: wallHitAction, arg: int(wallActFilter)}
+ row, _ := wallBar(pal, v, 80, 0)
+ if !strings.Contains(ansi.Strip(row), "Filter /") {
+ t.Fatalf("the hovered button stepped aside: %q", ansi.Strip(row))
+ }
+ // And with the pointer gone every button is back.
+ v.hover = wallHitRef{}
+ if row, _ := wallBar(pal, v, 80, 0); !strings.Contains(ansi.Strip(row), "Columns") {
+ t.Fatalf("the columns did not come back: %q", ansi.Strip(row))
+ }
+}
+
// A TILE'S ACTIONS ARE WORDS ON ITS BOTTOM BORDER, drawn under the pointer or
// the focus and nowhere else, into cells that depend on the width alone:
// hovering a tile moves nothing in it, and its top border carries no control.