From d208ee7c508f24243ff2d3025db18ca26216636e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:34:06 -0400 Subject: [PATCH 1/7] tui3: /helpers makes this conversation a new team's manager and asks it for helpers A person deep in a piece of work can now turn it into a team effort with the conversation that knows the work in charge. `/helpers` (or `+ Make team` beside Tasks in the right-hand column of a chat in no team) makes a new top-level team with the conversation in front as its manager in one store edit, narrows the strip to it, and once the store has taken the edit sends the conversation one turn: propose two or three helpers, ask, start nobody until the person agrees. The person answers in words. Yes is team_start under the usual approval posture; no is the new team_dissolve, which forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic). It is on the belt only while that is true, for every team however it was made, and the store checks again under its lock. Every rule a team made on the Teams page meets is met here: a fresh name, the next colour, one conversation managing one team and its subteams, and the store's refusal taking the window back. The manual's team manager page has the command, when it is not offered, and cancelling. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3.go | 6 + cmd/codeaf/chatv3_approvalfloor_test.go | 5 +- internal/manual/chat/commands.md | 1 + .../manual/chat/conversations-and-teams.md | 4 +- internal/manual/chat/home.md | 2 +- internal/manual/chat/screen.md | 7 +- internal/manual/chat/team-manager.md | 63 +++- internal/manual/chat/teams-page.md | 3 +- internal/manual/chat_test.go | 29 ++ internal/session/actioncategory.go | 2 +- internal/session/team.go | 16 +- internal/session/team_dissolve.go | 73 +++++ internal/session/team_dissolve_test.go | 149 +++++++++ internal/session/tools_team.go | 2 +- internal/teams/dissolve.go | 103 ++++++ internal/teams/dissolve_test.go | 117 +++++++ internal/tui3/app.go | 7 + internal/tui3/caption.go | 47 +-- internal/tui3/commands.go | 6 + internal/tui3/helpers.go | 209 ++++++++++++ internal/tui3/helpers_test.go | 301 ++++++++++++++++++ internal/tui3/homeslash.go | 8 + internal/tui3/sidecol.go | 26 ++ internal/tui3/teams.go | 16 + internal/tui3/teamseam.go | 4 +- internal/tui3/teamwritesaid.go | 5 + 26 files changed, 1176 insertions(+), 35 deletions(-) create mode 100644 internal/session/team_dissolve.go create mode 100644 internal/session/team_dissolve_test.go create mode 100644 internal/teams/dissolve.go create mode 100644 internal/teams/dissolve_test.go create mode 100644 internal/tui3/helpers.go create mode 100644 internal/tui3/helpers_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a7fc5a69ad..da53035360 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -2108,6 +2108,12 @@ func v3BuiltinApprovals() map[string]any { // A conflict raised to the manager above the parties: a packet and a // line of Traffic, and nothing spent or started. "team_raise": "allow", + // A manager taking back a team that never held anyone, which the + // person declined in its own conversation: one record nobody used is + // forgotten, and nothing is started, stopped or spent. A team that + // was used is refused by the store ([teams.Dissolve]), whatever this + // row says. + "team_dissolve": "allow", } } diff --git a/cmd/codeaf/chatv3_approvalfloor_test.go b/cmd/codeaf/chatv3_approvalfloor_test.go index abcfc4dced..c23dd0f19f 100644 --- a/cmd/codeaf/chatv3_approvalfloor_test.go +++ b/cmd/codeaf/chatv3_approvalfloor_test.go @@ -209,13 +209,14 @@ func TestTheFloorDoesNotWidenAnySafetyFloor(t *testing.T) { // THE TEAM VERBS SPLIT READ AND MESSAGE FROM START (internal/session's // tools_team.go). A manager looking at its team, sending it a line, ending a // member's turn and a member posting to its room stay inside the team the person -// made and are logged in its traffic, so they sit on the floor; a new member is a +// made and are logged in its traffic, so they sit on the floor, and so does a +// manager taking back a team that never held anyone; a new member is a // new conversation that spends money, so team_start is the blanket mode's and // asks under the shipped default. 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", "team_decide", "team_escalate", "team_close_report", "team_raise"} { + for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise", "team_dissolve"} { 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/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index fa98ec23ff..dba355616d 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,6 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | +| `/helpers` | | `` | makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel (also `+ Make team` beside `Tasks` in the right-hand column); on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 1df8c532ae..278373f14d 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -239,7 +239,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`/helpers`** (or `+ Make +team` in the right-hand column) makes a new team with that conversation as its manager, +which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3c4a572a26..efd3d20d93 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1342,7 +1342,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | | **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | -| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | +| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · `/helpers` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. `/helpers` makes a team of the conversation you are working in, and home has none in front, so it says `/helpers makes a team of the conversation you are in · open one first`. | | **`opens the crew panel`** | `/crew` and every `/crew` shortcut | Home steps aside and the crew panel opens over the conversation behind it; `esc` on the panel brings you back to home. A shortcut opens it with a tick on the row it changed. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` · `/delete` | `/delete` asks to permanently delete the conversation behind Home; it opens its confirmation here and never starts a new conversation. The other commands act on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 2c466a1803..b7b7d29af7 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -31,7 +31,8 @@ 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 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 +chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team; a chat in +no team also has `+ Make team` there (`/helpers`, on the team manager page). 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 task is already here. Under the @@ -3327,7 +3328,9 @@ Sections are one blank line apart; the band's rule is the one rule in the column The top of the right-hand column, above the sections under its list: -- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. The word +- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. In a chat + in no team, where it fits, `Tasks N · + Make team`: pressing it is `/helpers`, which makes + this conversation a new team's manager and asks it to propose helpers. 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 diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index f7ef7c74d6..1489a1281e 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,6 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. +- **`/helpers`**, or **`+ Make team`** in the right-hand column, in the conversation you are + working in: a new team is made with that conversation as its manager, and it proposes + helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -66,6 +69,60 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. +## Turn this conversation into a team — /helpers, the + Make team button, convert a chat, bring in helpers + +You are working in a conversation, the work has grown, and you want helpers with this +conversation in charge. Type `/helpers`, or `/helpers `, or press +**`+ Make team`** beside `Tasks` at the top of the right-hand column. Two things happen: + +1. A new top-level team is made with **this conversation as its manager** and only member, + in one save. It is named after the conversation's folder, or a word such as `harbor` + when a team already has that name, and takes the next free colour. The strip narrows to + it, the tab reads `◆ Manager`, and the column turns to the team's Traffic. The rules of + every team apply: a name no other team has, and one conversation manages one team and + its subteams. Rename it or change its colour in Teams, under `Settings`. +2. Once the team is saved, the conversation is sent one message, shown as `/helpers`: it + proposes two or three helpers — a handle, the part of the work, and the brief — and asks + you to accept them, change the list, or cancel. It starts nobody until you agree. + +The receipt reads `made the team lab · this conversation is its manager`. Reply in your own +words; there is no card. Yes, and it starts each helper with `team_start`, under your +approval posture like any start. A different list, and it proposes again. Cancel, and it +takes the team back with `team_dissolve` (see **Cancel a team nobody joined**). Nothing is +copied: the manager keeps this conversation, its history and its tasks. + +## When /helpers is not offered, and what it says + +`+ Make team` is drawn only in a chat in no team, where it can work and where it fits; a +narrow column drops it first. `/helpers` always answers: + +- On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. +- On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. +- In a conversation that already manages a team: `this conversation already manages harbor · ask it for helpers`, and the strip shows that team. +- On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. +- Over `--host` to an engine without teams: `managers are not available over --host yet`. +- While the first one is still being saved: `the team is still being saved`. + +If the save is refused — another window made this conversation another team's manager — +the strip goes back, the reason is said, and nothing is sent. If you leave the +conversation before the save finishes, the team stands and the message is not sent: +`made the team lab · its manager is the conversation you left; ask it for helpers there`. + +## Cancel a team nobody joined — team_dissolve + +Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The +team is forgotten as if it had never been made: no row under `Show closed`, nothing +stopped, and the conversation, its history and its tasks stay. The strip goes back to all +your chats and the column to `Tasks`, with `+ Make team` again. + +A manager has `team_dissolve` only while its team has never held anyone but it: no +member, no former member, no subteam, and nothing in its Traffic. The rule is the same for +every team, however it was made. It does not ask first, because it can only remove a team +nothing happened in. Once anyone joins, it leaves at the manager's next step, and a +remembered call says `team_dissolve is no longer one of your tools: the team has held +members or exchanged messages, so only the person can disband it, from Teams.` A team +that was used ends with `Disband` on Teams, which keeps its history. + ## The manager's screen While the manager is in front, the message box says `to ◆ manager of harbor`, using the @@ -283,6 +340,7 @@ on one waits for you. | `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_dissolve` | takes back a team that has never held anyone but its manager, when you decline it; the team is forgotten and the conversation goes on alone. Only offered while the team is that empty (see **Cancel a team nobody joined**) | no | | `team_raise` | raises a conflict to the manager above every party (see **Conflicts between members and teams**); members have it too | no | ## Adding existing sessions and removing members through the manager @@ -312,8 +370,9 @@ Like any tool, each can be set to ask or allow in `/settings` under the tool app ## What happens when a conversation stops being the manager At its next step, a conversation removed as manager loses `team_status`, `team_read`, -`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate` and -`team_close_report`. If it is still a member of a managed team, it keeps `team_post` and +`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate`, +`team_close_report`, and `team_dissolve` if it had it. A team it dissolved with +`team_dissolve` takes them all the same way. If it is still a member of a managed team, it keeps `team_post` and `team_raise`; outside a team it loses those too. A remembered call to a removed manager tool says `team_send is no longer one of your tools: this conversation no longer manages a team.` A removed member tool says `team_post is no longer one of your tools: this diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 5ba1058986..3d2741825d 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -438,7 +438,8 @@ changes fields, arrows move, `space` selects a highlighted member, `enter` creat cancels. No internal IDs are shown. A duplicate name or invalid parent is refused. You may create an empty team and add members afterward. Creation never assigns a manager or -opens its members. Both routes land on the new team overview. A failed save is reported there. +opens its members. To make a team from the conversation you are working in, with that +conversation as its manager, use `/helpers` there (the team manager page has it). Both routes land on the new team overview. A failed save is reported there. Chats also offers `+ New AI team` in its full saved-conversation grid: select cards, then name and create. The full conversation grid itself lives only in Chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c0524e43d1..d99e5ab4a3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4116,3 +4116,32 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { t.Errorf("%q does not reach what-i-remember · %q; it reached %v", question, title, where) } } + +// /helpers: the questions a person in the middle of some work asks when they +// want it to become a team, and the ones they ask about saying no, reach the +// sections that answer them by name, not just the manager's page. +func TestHelpersQuestionsReachTheirSections(t *testing.T) { + for _, probe := range []struct{ question, title string }{ + {"how do I turn this conversation into a team", "Turn this conversation into a team"}, + {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, + {"bring in helpers for what I am working on", "Turn this conversation into a team"}, + {"what does the + Make team button do", "Turn this conversation into a team"}, + {"why is /helpers not offered here", "When /helpers is not offered"}, + {"/helpers says open one first", "When /helpers is not offered"}, + {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, + {"what does team_dissolve do", "Cancel a team nobody joined"}, + } { + found := Chat().Search(probe.question, DefaultResults) + reached := false + where := make([]string, 0, len(found)) + for _, section := range found { + where = append(where, section.Page+" · "+section.Title) + if section.Page == "team-manager" && strings.Contains(section.Title, probe.title) { + reached = true + } + } + if !reached { + t.Errorf("%q does not reach team-manager · %q; it reached %v", probe.question, probe.title, where) + } + } +} diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index bbc3242e84..2b9a388ac0 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -260,7 +260,7 @@ func ActionCategoryForTool(tool string) ActionCategory { case "propose_task", "quick_task", "divide_work", "workspace_fork", "automation", // A manager starting a member or ending its turn, or deciding what a // member asked it. - "team_start", "team_stop", "team_decide", "team_add", "team_remove": + "team_start", "team_stop", "team_decide", "team_add", "team_remove", "team_dissolve": return ActionCoordinate // Keeping the account of the work rather than doing it. `items` is a quick diff --git a/internal/session/team.go b/internal/session/team.go index 9ebde11827..f183a90449 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -103,6 +103,10 @@ type teamRole struct { // manager is the global manager, and its other members are the top-level // teams' managers. root bool + // dissolvable says this conversation manages the team and the team has + // never held anyone but it ([teams.File.Dissolvable]), which is when its + // manager is given team_dissolve (team_dissolve.go). + dissolvable 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]). @@ -402,6 +406,7 @@ func rolesFor(file *teams.File, keys []string, d teams.Defaults) []teamRole { derived: member.HandleDerived(), joinedAt: member.JoinedAt, } + role.dissolvable = role.manager && file.Dissolvable(team.ID) if hasHome { role.questionsUp = file.Effective(home.Team, d).QuestionsUp } @@ -511,16 +516,20 @@ func (a *Agent) teamBoundary() string { // armTeamTools puts the verbs these roles give onto the belt. func (a *Agent) armTeamTools(roles []teamRole) { var arriving []bare.Tool - manager, member := false, false + manager, member, dissolvable := false, false, false for _, role := range roles { manager = manager || role.manager member = member || (!role.manager && role.managed) + dissolvable = dissolvable || role.dissolvable } if manager { arriving = append(arriving, a.managerTools()...) arriving = append(arriving, a.packetTools()...) arriving = append(arriving, a.wrapUpTools()...) } + if dissolvable { + arriving = append(arriving, a.dissolveTools()...) + } if member { arriving = append(arriving, a.memberTools()...) } @@ -581,6 +590,11 @@ func (a *Agent) retireTeamTools(allowed map[string]bool) { if teamManagerToolName(tool.Name) { a.teamRetired[tool.Name] = teamRetiredManager } + // team_dissolve leaves a conversation that still manages its team + // when the team was used, and that is a different sentence. + if tool.Name == teamDissolveToolName && allowed[teamStatusToolName] { + a.teamRetired[tool.Name] = teamRetiredDissolve + } } a.tools = kept a.definitions, _ = toolDefinitions(kept) diff --git a/internal/session/team_dissolve.go b/internal/session/team_dissolve.go new file mode 100644 index 0000000000..3b9d218935 --- /dev/null +++ b/internal/session/team_dissolve.go @@ -0,0 +1,73 @@ +package session + +// team_dissolve: a manager taking back a team that never held anyone. +// +// A conversation the person turns into a team (the chat's `/helpers`) is its +// manager from that moment, with nobody under it, and its first job is to +// propose helpers. The person answers in their own words, and one answer is +// "no, cancel it". The manager has every verb for growing a team and, until +// this one, none for taking the team back, so a declined proposal left an empty +// team on the Teams page that only the person could disband — and disbanding +// keeps a read-only history row for a team that never did anything. +// +// THE VERB IS ON THE BELT ONLY WHILE IT CAN WORK. It is armed at a boundary that +// finds this conversation managing a team that has never held anyone but it +// ([teams.File.Dissolvable]), and it leaves at the first boundary after anyone +// else joins, with the reason said to a model that calls it from memory. A +// capability that cannot work is absent, not broken. The call itself checks +// again, under the store's lock, and also refuses a team whose Traffic has +// anything in it ([teams.Dissolve]), because a start already written there is a +// member on its way. +// +// IT IS ONE RULE FOR EVERY TEAM. Nothing here asks which door made the team: a +// team made on the Teams page and given a manager there is dissolvable by the +// same rule while it is just as empty. Disbanding a team that was used stays the +// person's, on Teams. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/teams" +) + +const teamDissolveToolName = "team_dissolve" + +const teamDissolveDescription = "Take back the team you manage while it has never held anyone but you: it is forgotten as if it had never been made, nobody is stopped, and this conversation goes on as an ordinary one without your team verbs. " + + "Use it only when the person declines the team, for example by saying no to the helpers you proposed and asking to cancel. " + + "It is offered only until anyone else joins the team or anything is said in its traffic; after that the person disbands the team from Teams." + +const teamDissolveSchema = `{"type":"object","properties":{` + teamArgSchema + `},"additionalProperties":false}` + +// teamRetiredDissolve is why team_dissolve left the belt of a conversation that +// still manages a team: the team was used, so it is not the manager's to undo. +const teamRetiredDissolve = "the team has held members or exchanged messages, so only the person can disband it, from Teams" + +// dissolveTools is the manager's one verb for an untouched team. +func (a *Agent) dissolveTools() []bare.Tool { + return []bare.Tool{ + {Name: teamDissolveToolName, Description: teamDissolveDescription, Schema: json.RawMessage(teamDissolveSchema), Execute: a.teamDissolveTool}, + } +} + +func (a *Agent) teamDissolveTool(ctx context.Context, args json.RawMessage) (string, bool, error) { + var p struct{ Team string } + if err := decodeToolArguments(args, &p); err != nil { + return invalidArgumentsPrefix + err.Error(), true, nil + } + t, role, refusal := a.teamTarget(p.Team, true) + if refusal != "" { + return refusal, true, nil + } + err := teams.Dissolve(a.config.teamProfile(), t.ID, role.key) + if errors.Is(err, teams.ErrNotDissolvable) || errors.Is(err, teams.ErrRoot) { + return fmt.Sprintf("%q has already held members or exchanged messages, so it is not yours to take back. Nothing was changed; the person can disband it from Teams.", t.Name), true, nil + } + if err != nil { + return "The team could not be dissolved: " + err.Error(), true, nil + } + return fmt.Sprintf("Dissolved %q. Nobody was started or stopped, and this conversation goes on as an ordinary one; your team verbs leave at your next step.", t.Name), false, nil +} diff --git a/internal/session/team_dissolve_test.go b/internal/session/team_dissolve_test.go new file mode 100644 index 0000000000..144c081d12 --- /dev/null +++ b/internal/session/team_dissolve_test.go @@ -0,0 +1,149 @@ +package session + +// team_dissolve, as tests: it is on the belt exactly while the team it would +// take back has never held anyone but its manager, it leaves with a reason the +// moment anybody joins, and a call it answers forgets the team and nothing else. +// +// Like the rest of the team tests, every fixture is a real teams.json and a +// real Traffic log, written through internal/teams. + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// newHelpersFixture is the team the chat's `/helpers` makes: the fixture's +// manager conversation as manager and only member, and the web and parser +// transcripts beside it, in no team, to be added by a test. +func newHelpersFixture(t *testing.T) teamFixture { + t.Helper() + fixture := newTeamFixture(t, true) + if err := teams.Update(fixture.profile, func(f *teams.File) error { + off := false + team := teams.Team{ID: fixture.teamID, Name: "harbor"} + team.Settings.Wake = &off + f.Teams = []teams.Team{team} + if err := f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.manager), File: fixture.manager, Word: "Harbor manager", Handle: "boss"}); err != nil { + return err + } + return f.SetManager(fixture.teamID, convKeyOf(t, fixture.manager)) + }); err != nil { + t.Fatal(err) + } + return fixture +} + +func TestADissolvableTeamsManagerIsOfferedTeamDissolveAndAUsedOneIsNot(t *testing.T) { + fresh := newHelpersFixture(t) + manager := teamAgent(t, fresh, fresh.manager, nil, nil) + manager.teamBoundary() + if !holds(manager, teamDissolveToolName) { + t.Fatal("the manager of a team that never held anyone was not offered team_dissolve") + } + for _, name := range []string{teamStatusToolName, teamSendToolName, teamStartToolName, teamCloseReportToolName} { + if !holds(manager, name) { + t.Errorf("a dissolvable team's manager lacks %s, which every manager has", name) + } + } + + used := newTeamFixture(t, true) + busy := teamAgent(t, used, used.manager, nil, nil) + busy.teamBoundary() + if holds(busy, teamDissolveToolName) { + t.Fatal("the manager of a team with members was offered team_dissolve") + } + web := teamAgent(t, used, used.web, nil, nil) + web.teamBoundary() + if holds(web, teamDissolveToolName) { + t.Fatal("a member was offered team_dissolve") + } +} + +// THE VERB LEAVES WHEN ANYONE JOINS, BY ANY ROUTE, and a call from memory is +// told why. The other manager verbs stay: the conversation still manages. +func TestTeamDissolveLeavesWhenAMemberJoinsAndSaysWhy(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + if err := teams.Update(fixture.profile, func(f *teams.File) error { + return f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.web), File: fixture.web, Word: "web frontend", Handle: "web"}) + }); err != nil { + t.Fatal(err) + } + manager.teamBoundary() + if holds(manager, teamDissolveToolName) { + t.Fatal("team_dissolve stayed after a member joined") + } + if !holds(manager, teamSendToolName) || !holds(manager, teamStartToolName) { + t.Fatal("a member joining took the manager's other verbs") + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c1", teamDissolveToolName, `{}`), "") + if want := "team_dissolve is no longer one of your tools: " + teamRetiredDissolve + "."; result.text != want { + t.Fatalf("a retired team_dissolve answered %q, want %q", result.text, want) + } +} + +// A CALL FORGETS THE TEAM, and only the team: the transcript is where it was, +// and at the next boundary every team verb leaves because there is no team. +func TestTeamDissolveForgetsTheTeamAndTheVerbsLeaveWithIt(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + said, failed, err := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if failed || err != nil { + t.Fatalf("dissolve: %s %v", said, err) + } + if !strings.Contains(said, `Dissolved "harbor"`) { + t.Fatalf("dissolve said %q", said) + } + file, err := teams.Load(fixture.profile) + if err != nil { + t.Fatal(err) + } + if _, ok := file.Team(fixture.teamID); ok || len(file.ClosedTeams()) != 0 { + t.Fatal("the team is still in the file, open or as history") + } + if _, err := os.Stat(fixture.manager); err != nil { + t.Fatal("dissolving touched the manager's transcript") + } + manager.teamBoundary() + for _, name := range teamToolNames { + if holds(manager, name) { + t.Errorf("%s stayed on the belt of a conversation whose team was dissolved", name) + } + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c2", teamSendToolName, `{}`), "") + if want := "team_send is no longer one of your tools: " + teamRetiredManager + "."; result.text != want { + t.Fatalf("team_send after dissolving answered %q, want %q", result.text, want) + } +} + +// A START ALREADY IN THE TRAFFIC IS A MEMBER ON ITS WAY. A call made after the +// boundary armed the verb, with a team_start written since, is refused and +// changes nothing. +func TestTeamDissolveRefusesATeamWithTrafficAndWritesNothing(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "docs", Text: "write the README"}) + before, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + said, failed, _ := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if !failed || !strings.Contains(said, "not yours to take back") { + t.Fatalf("dissolving a team with traffic said %q (failed %v)", said, failed) + } + after, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } +} diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 5ae9424eb7..f892a033d5 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -68,7 +68,7 @@ const teamGroup = "team" var teamToolNames = []string{ teamStatusToolName, teamReadToolName, teamSendToolName, teamStopToolName, teamStartToolName, - teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, + teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, teamDissolveToolName, teamPostToolName, teamRaiseToolName, teamAddToolName, teamRemoveToolName, } diff --git a/internal/teams/dissolve.go b/internal/teams/dissolve.go new file mode 100644 index 0000000000..7197c2dc40 --- /dev/null +++ b/internal/teams/dissolve.go @@ -0,0 +1,103 @@ +package teams + +import ( + "errors" + "fmt" + "os" +) + +// ── DISSOLVING A TEAM THAT NEVER HELD ANYONE ───────────────────────────────── +// +// A conversation the person turns into a team (the chat's `/helpers`) is made +// its manager at once, with nobody under it, and is asked to propose helpers. +// The person may decline them all. What they are declining is the team itself, +// so the manager is given one way to take it back while there is still nothing +// to take back: a team that has never held anyone but its manager, has no team +// under it and has said nothing in its Traffic is forgotten whole, as if it had +// never been made. The conversation is untouched and goes on as an ordinary one. +// +// IT IS NOT DISBANDING. Disbanding (lifecycle.go) is the person's, from Teams, +// and keeps the team as read-only history, because a team that ran has a record +// worth keeping. A team that never ran has none, so there is nothing to keep and +// no history row to leave behind. The moment the team has held a second +// conversation, under any route, this door is shut for good, and the team ends +// the way every other team does. +// +// NOTHING HERE KNOWS WHICH DOOR MADE THE TEAM. A team made on the Teams page +// and given a manager there, and then never used, is dissolvable by the same +// rule: one rule for every team, whichever way it was made. + +// ErrNotDissolvable is [Dissolve] asked to forget a team that has held more than +// its manager, has a team under it, or has Traffic. +var ErrNotDissolvable = errors.New("teams: this team has already held members or exchanged messages; the person disbands it from Teams") + +// Dissolvable reports whether team id has never held anyone but its manager: it +// is open, it is not the root, it has a manager, that manager is its one member, +// it has no former member or former manager, and no team sits under it. It reads +// the file only; [Dissolve] also asks the Traffic. +func (f *File) Dissolvable(id string) bool { + t, ok := f.Team(id) + if !ok || t.Closed() || t.Root || t.Manager == "" { + return false + } + if len(t.Members) != 1 || t.Members[0].Key != t.Manager { + return false + } + if len(t.FormerMembers) > 0 || t.FormerManager != "" { + return false + } + return len(f.Children(id)) == 0 +} + +// Dissolve forgets team id when it is [File.Dissolvable], manager is still its +// manager, and its Traffic is empty. It is checked and written under the +// store's lock, so a member added since the caller looked is seen. The team's +// directory goes with it. No conversation is touched. +func Dissolve(profileDir, id, manager string) error { + err := Update(profileDir, func(f *File) error { + t, ok := f.Team(id) + if !ok { + return fmt.Errorf("no team %s", id) + } + if t.Manager != manager { + return errors.New("this conversation no longer manages that team") + } + if !f.Dissolvable(id) || trafficSaid(profileDir, id) { + return ErrNotDissolvable + } + kept := f.Teams[:0:0] + for _, other := range f.Teams { + if other.ID != id { + kept = append(kept, other) + } + } + f.Teams = kept + return nil + }) + if err != nil { + return err + } + if safeTeamID(id) == nil { + if err := os.RemoveAll(TeamDir(profileDir, id)); err != nil { + return err + } + } + forgetPackets(profileDir) + return nil +} + +// trafficSaid reports whether team id's Traffic has anything in it, either +// file of it. An unreadable log counts as said, so a doubt keeps the team. +func trafficSaid(profileDir, id string) bool { + path := TrafficPath(profileDir, id) + for _, file := range []string{path, trafficRotated(path)} { + info, err := os.Stat(file) + if err == nil && info.Size() > 0 { + return true + } + if err != nil && !os.IsNotExist(err) { + return true + } + } + return false +} diff --git a/internal/teams/dissolve_test.go b/internal/teams/dissolve_test.go new file mode 100644 index 0000000000..b3aaffec47 --- /dev/null +++ b/internal/teams/dissolve_test.go @@ -0,0 +1,117 @@ +package teams + +import ( + "errors" + "os" + "testing" + "time" +) + +// leadOnly is a profile holding one team, `solo`, whose one member is its +// manager, beside a second team that has nothing to do with it. +func leadOnly(t *testing.T) string { + t.Helper() + dir := t.TempDir() + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "aaaaaaaaaaaa", Name: "solo"}, Team{ID: "cccccccccccc", Name: "yard"}) + must(t, f.AddMember("aaaaaaaaaaaa", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "y1", File: "y1", Word: "yard work"})) + return f.SetManager("aaaaaaaaaaaa", "lead") + })) + return dir +} + +// A TEAM THAT NEVER HELD ANYONE BUT ITS MANAGER IS FORGOTTEN WHOLE: its record +// and its directory go, and the manager's other memberships stay. +func TestDissolveForgetsATeamThatOnlyEverHeldItsManager(t *testing.T) { + dir := leadOnly(t) + must(t, os.MkdirAll(TeamDir(dir, "aaaaaaaaaaaa"), 0o700)) + must(t, Dissolve(dir, "aaaaaaaaaaaa", "lead")) + f, err := Load(dir) + must(t, err) + if _, ok := f.Team("aaaaaaaaaaaa"); ok { + t.Fatal("the dissolved team is still in the file") + } + if len(f.ClosedTeams()) != 0 { + t.Fatal("dissolving left a disbanded row behind") + } + yard, ok := f.Team("cccccccccccc") + if !ok || !yard.Holds("lead") { + t.Fatal("dissolving touched the manager's other membership") + } + if _, err := os.Stat(TeamDir(dir, "aaaaaaaaaaaa")); !os.IsNotExist(err) { + t.Fatalf("the team's directory survived: %v", err) + } +} + +// EVERY SIGN THAT THE TEAM WAS USED SHUTS THE DOOR, and a refusal writes +// nothing: a second member, a removed member, a former manager, a team under +// it, a line of Traffic, a disband, and a caller that is not its manager. +func TestDissolveRefusesATeamThatWasUsed(t *testing.T) { + cases := []struct { + name string + used func(t *testing.T, dir string) + manager string + }{ + {"a second member", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + }, "lead"}, + {"a member since removed", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + must(t, Update(dir, func(f *File) error { return f.RemoveMember("aaaaaaaaaaaa", "web") })) + }, "lead"}, + {"a team under it", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "bbbbbbbbbbbb", Name: "under", Parent: "aaaaaaaaaaaa"}) + return nil + })) + }, "lead"}, + {"a line of traffic", func(t *testing.T, dir string) { + must(t, AppendTraffic(dir, "aaaaaaaaaaaa", Entry{Kind: KindNote, From: FromManager, To: ToEveryone, Text: "hello"})) + }, "lead"}, + {"a disband", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.Disband("aaaaaaaaaaaa", time.Time{}, "") })) + }, "lead"}, + {"another conversation asking", func(t *testing.T, dir string) {}, "y1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := leadOnly(t) + c.used(t, dir) + before, err := os.ReadFile(Path(dir)) + must(t, err) + if err := Dissolve(dir, "aaaaaaaaaaaa", c.manager); err == nil { + t.Fatal("a used team was dissolved") + } + after, err := os.ReadFile(Path(dir)) + must(t, err) + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } + }) + } +} + +// THE ROOT IS NEVER DISSOLVABLE, even holding only its global manager. +func TestTheRootIsNotDissolvable(t *testing.T) { + f := &File{Teams: []Team{{ID: "rrrrrrrrrrrr", Name: "All teams", Root: true, Manager: "g", Members: []Member{{Key: "g"}}}}} + if f.Dissolvable("rrrrrrrrrrrr") { + t.Fatal("the root reads as dissolvable") + } +} + +// A TEAM ANOTHER WRITER FILLED SINCE THE CALLER LOOKED is refused under the +// store's lock, with the error a caller can name. +func TestDissolveSeesAMemberAddedSinceTheCallerLooked(t *testing.T) { + dir := leadOnly(t) + f, err := Load(dir) + must(t, err) + if !f.Dissolvable("aaaaaaaaaaaa") { + t.Fatal("the fixture is not dissolvable") + } + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + if err := Dissolve(dir, "aaaaaaaaaaaa", "lead"); !errors.Is(err, ErrNotDissolvable) { + t.Fatalf("dissolve answered %v, want ErrNotDissolvable", err) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3384e04d88..2bbd96483c 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1462,6 +1462,9 @@ type app struct { deletedSessionRows map[tasksKey]bool deleteTask func(file, id string) error tmemberStart teamMemberStart + // helpers is a `/helpers` waiting for the store to take its team before + // the conversation is asked for its helpers (helpers.go). + helpers helpersPending // tp is the teams page's own state: its selection, its reading of the // store and the targets it drew (teamspage.go). tp teamsPage @@ -7972,6 +7975,10 @@ func (a *app) slash(line string) tea.Cmd { // THE TEAM-LEVEL VIEW, which is a place (place_teams.go). return a.showPage(pageTeams) + case "helpers": + // THIS CONVERSATION AS A TEAM'S MANAGER, asked for helpers (helpers.go). + return a.runHelpersCommand(rest) + 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/caption.go b/internal/tui3/caption.go index f0be25c4e7..f411b1b435 100644 --- a/internal/tui3/caption.go +++ b/internal/tui3/caption.go @@ -378,6 +378,8 @@ func teamCaptionGloss(e entry) string { return "passing a decision up" case "team_close_report": return "writing the closing report" + case "team_dissolve": + return "dissolving the team" } return "" } @@ -478,7 +480,7 @@ func captionVerb(tool string) string { 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": + case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report", "team_dissolve": return "managing" default: return firstNonEmpty(tool, "working") @@ -494,27 +496,28 @@ func captionVerb(tool string) string { // change, and guessing one would garble the only part of the row that is quoted // rather than composed. 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", + "asking": "asked", + "building": "built", + "checking": "checked", + "deciding": "decided", + "dissolving": "dissolved", + "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/commands.go b/internal/tui3/commands.go index 2f2d97b076..4b6244df53 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -155,6 +155,12 @@ var commands = []command{ // 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)}, + // AND THE ONE DOOR THAT STARTS FROM THE CONVERSATION RATHER THAN FROM THE + // TEAM, under the page it fills (helpers.go): this conversation becomes a + // new team's manager and proposes helpers. Two rows, the way /manual has + // two, because the words after it go to the manager as what to help with. + {name: "helpers", desc: "makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel"}, + {name: "helpers", args: "", desc: "…the same, with what you want help with"}, {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 diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go new file mode 100644 index 0000000000..3937c18d17 --- /dev/null +++ b/internal/tui3/helpers.go @@ -0,0 +1,209 @@ +package tui3 + +// /helpers — THIS CONVERSATION BECOMES A TEAM'S MANAGER, AND IS ASKED FOR HELPERS. +// +// Every other door onto a team starts from the team: the Teams page's `+ New AI +// team`, the conversation grid's `+ New team`, Organize, and a manager's own +// sub-team start. Each makes a team first and a manager after, if at all, and +// the manager `+ AI manager` makes is a NEW conversation that knows nothing yet. +// This door starts from the other end. The person is already deep in a piece of +// work, it has grown, and they want it to become a team effort with the +// conversation that knows the work running it. +// +// SO IT IS TWO THINGS, IN THIS ORDER, AND NOTHING MORE. +// +// 1. A new top-level team is made with the conversation in front as its +// manager and only member, in ONE store edit ([app.teamCreateLed]): the +// same edit the Teams page's dialog makes, with the manager line added, so +// every rule a team made there meets is met here — a fresh name, a colour, +// one conversation managing one team and its descendants, no deleted +// conversation readmitted. The strip narrows to it, so the person sees +// `◆ Manager` and the column turns to the team's Traffic. +// 2. Once the store has TAKEN that edit, and only then, the conversation is +// sent one turn, shown in the transcript as `/helpers` and carrying the +// words below ([helpersAskOf]): propose two or three helpers, ask the +// person, start nobody until they agree. Waiting for the store is what +// makes the turn's first step find the conversation already a manager, with +// its verbs on the belt ([teamsWrote], [app.helpersAsked]). +// +// THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the +// manager acts on with `team_start`, which runs under the approval posture every +// other start does; a different list is a reply it proposes again from; and no +// is a reply it acts on with `team_dissolve`, which takes back a team that has +// never held anyone but its manager and is on the belt only while that is true +// (internal/session's team_dissolve.go). After the first helper joins, the team +// ends the way every other team does: disbanded by the person, on Teams. +// +// IT IS ABSENT WHERE IT CANNOT WORK, as the column's door says by not being +// drawn ([app.helpersOffered]): over --host to an engine without the teams +// doors, on a connection that holds one conversation at a time (a helper opens +// beside the manager), on the welcome screen before a conversation exists, and +// for a conversation that already manages a team. + +import ( + "fmt" + "math/rand/v2" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// The words this door says. Each is quoted in the manual exactly as spelled. +const ( + // helpersDoorWord is the side column's door in a chat in no team, and + // helpersDoorHint what the hint line says over it. + helpersDoorWord = "+ Make team" + helpersDoorHint = "Make this conversation the manager of a new team, and ask it to propose helpers" + // helpersPlaceWord is /helpers typed where no conversation is in front. + helpersPlaceWord = "/helpers makes a team of the conversation you are in · open one first" + // helpersFreshWord is /helpers on the welcome screen, before there is a + // conversation to make a team of. + helpersFreshWord = "/helpers makes a team of a conversation you have started · say what you are working on first" + // helpersManagesWord is /helpers in a conversation that already manages a + // team; the strip is narrowed to that team. + helpersManagesWord = "this conversation already manages %s · ask it for helpers" + // helpersPendingWord is a second /helpers while the first is being saved. + helpersPendingWord = "the team is still being saved" + // helpersMadeWord is the receipt, once the store has the team. + helpersMadeWord = "made the team %s · this conversation is its manager" + // helpersAwayWord is the receipt when the person left the conversation + // before the store answered: the team is made and the ask is not sent. + helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it for helpers there" +) + +// helpersShared is the refusal on a connection that holds one conversation at +// a time: a helper is a conversation that opens beside its manager. +var helpersShared = "helpers open beside this conversation, and " + oneConversationWord + +// helpersAskOf is the turn the conversation is sent, for team name, with the +// person's own words after /helpers, if any, at the end. It is written as the +// person speaking, because it is their message: the transcript shows what they +// typed and the model reads this behind it, the way /manual works +// (manualcmd.go). It names both verbs, so the yes and the no each have one. +func helpersAskOf(name, rest string) string { + ask := fmt.Sprintf("I have just turned this conversation into a team called %q, and you are its manager: you keep this conversation and everything in it, and nobody else is in the team yet. "+ + "Propose two or three helpers for the work we are doing here. For each, give a handle (2 to 12 lowercase letters or digits), the part of the work it would take, and the brief you would give it. "+ + "Then ask me to accept them, change the list, or cancel. Start nobody until I agree. "+ + "When I agree, start each one with team_start. If I cancel, take the team back with team_dissolve.", name) + if rest = strings.TrimSpace(rest); rest != "" { + ask += " What I want help with: " + rest + } + return ask +} + +// helpersPending is one /helpers between its edit and the store's answer: the +// edit's notice tie, the conversation it made a manager, the team's name, and +// the turn to send once the store has taken it. due says the answer is in and +// [app.helpersAsked] has not acted on it yet. +type helpersPending struct { + said teamWriteSaid + key string + name string + ask string + shown string + due bool +} + +// helpersTab is the conversation in front as a strip tab, and false when there +// is none to make a team of. +func (a *app) helpersTab() (chatTab, bool) { + if a.agent == nil || a.startingChat() { + return chatTab{}, false + } + key := a.frontTabKey() + if key == "" { + return chatTab{}, false + } + return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true +} + +// helpersOffered reports whether the side column draws its door: the same +// refusals [app.runHelpersCommand] would give, asked of memory only, because a +// frame asks it. +func (a *app) helpersOffered() bool { + if a.teamsOff() || a.shared || !a.wall.loaded || a.helpers.said.pending { + return false + } + if a.agent == nil || a.startingChat() || a.pageShowing() { + return false + } + key := a.frontTabKey() + if key == "" { + return false + } + _, manages := a.teamManaged(key) + return !manages +} + +// runHelpersCommand is /helpers, and the column's door: the team made, and the +// ask queued behind the store's answer. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + if a.pageShowing() { + a.note(helpersPlaceWord) + return nil + } + if a.teamsOff() { + a.note(teamHostedWord) + return nil + } + if a.shared { + a.note(helpersShared) + return nil + } + tab, ok := a.helpersTab() + if !ok { + a.note(helpersFreshWord) + return nil + } + if a.helpers.said.pending { + a.note(helpersPendingWord) + return nil + } + a.teamsEnsure() + if t, manages := a.teamManaged(tab.key); manages { + a.note(fmt.Sprintf(helpersManagesWord, t.Name)) + return a.teamActivate(t.ID) + } + name := teamFreshName([]chatTab{tab}, a.teamNames(), "", rand.IntN) + hue := nextTeamHue(a.teamHues(""), teamReservedHues(a.pal)) + id, err := a.teamCreateLed(name, []chatTab{tab}, hue, tab.key) + if err != nil { + // THE EDIT REFUSED WHAT THIS WINDOW HOLDS, so nothing was queued and + // nothing changed: the store's own rules, in the store's own words. + a.note("could not make a team of this conversation: " + err.Error()) + return nil + } + // A REFUSAL FROM THE STORE PUTS THE WINDOW BACK the way a refused `+ AI + // manager` does ([app.teamsTake]): the overlay closes and the store's + // teams are taken, so a team the store never had is not drawn. + a.teamsWatchManagerStart(tab.key) + shown := "/helpers" + if rest = strings.TrimSpace(rest); rest != "" { + shown += " " + rest + } + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: helpersAskOf(name, rest), shown: shown} + return a.teamActivate(id) +} + +// helpersAsked is a /helpers whose write has come back: the receipt and the +// turn when the store took the team, and nothing when it refused it, because +// [app.teamsTake] has already said why. +func (a *app) helpersAsked() tea.Cmd { + p := a.helpers + if !p.due { + return nil + } + a.helpers = helpersPending{} + if p.said.why != "" { + return nil + } + // THE PERSON MAY HAVE LEFT. The turn goes to the conversation in front, + // and a manager's first words sent to another conversation would be the + // wrong conversation's; so the team stands and the ask is left to them. + if a.frontTabKey() != p.key { + a.note(fmt.Sprintf(helpersAwayWord, p.name)) + return nil + } + a.note(fmt.Sprintf(helpersMadeWord, p.name)) + return a.submitShown(p.ask, p.shown, nil) +} diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go new file mode 100644 index 0000000000..4db102d161 --- /dev/null +++ b/internal/tui3/helpers_test.go @@ -0,0 +1,301 @@ +package tui3 + +import ( + "fmt" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// helpersApp is [tabApp] with its teams on a profile of the test's own and no +// team yet: a person three conversations into their work, in front of +// "Shipping the parser" in /tmp/lab. +func helpersApp(t *testing.T) (*app, *fakeAgent) { + t.Helper() + a, _, _ := tabApp(t) + a.profileDir = t.TempDir() + a.teamsEnsure() + fake, _ := a.agent.(*fakeAgent) + if fake == nil { + t.Fatal("the fixture's agent is not the fake") + } + return a, fake +} + +// helpersDisk is the teams this window's profile holds on disk. +func helpersDisk(t *testing.T, a *app) []team { + t.Helper() + disk, err := loadTeams(a.profileDir, nil) + if err != nil { + t.Fatal(err) + } + return disk +} + +// lastPersonLine is the newest line of the person's in the transcript. +func lastPersonLine(t *testing.T, a *app) string { + t.Helper() + for i := len(a.entries) - 1; i >= 0; i-- { + if a.entries[i].kind == entryUser { + return a.entries[i].text + } + } + t.Fatal("no line of the person's in the transcript") + return "" +} + +// transcriptSays reports whether any note in the transcript holds want. +func transcriptSays(a *app, want string) bool { + for _, e := range a.entries { + if e.kind == entryNote && strings.Contains(e.text, want) { + return true + } + } + return false +} + +// /helpers MAKES A TEAM WITH THIS CONVERSATION AS ITS MANAGER, and only then +// asks it for helpers. The team is on disk with the conversation as manager and +// only member; the strip is narrowed to it; the column is the manager's; the +// transcript says `/helpers …` and the model was handed the ask, naming both the +// verb that grows the team and the one that takes it back. +func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + before := len(fake.sent) + typeLine(t, a, "/helpers the flaky tests") + teamsFlush(t, a) + + disk := helpersDisk(t, a) + if len(disk) != 1 { + t.Fatalf("/helpers left %d teams on disk: %+v", len(disk), disk) + } + made := disk[0] + if made.Manager != front || len(made.Members) != 1 || made.Members[0].Key != front { + t.Fatalf("the team is not this conversation alone, as manager: %+v", made) + } + if made.Name != "lab" || made.Parent != "" { + t.Fatalf("the team was named %q under %q, want the folder's name at the top level", made.Name, made.Parent) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the strip was not narrowed to the new team") + } + if a.sideKind() != sideKindManager { + t.Fatal("the column does not read this conversation as the team's manager") + } + if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { + t.Fatalf("the composer says %q, not that it talks to the manager", hint) + } + + if len(fake.sent) != before+1 { + t.Fatalf("/helpers sent %d messages, want one", len(fake.sent)-before) + } + sent := fake.sent[before] + if sent != helpersAskOf("lab", "the flaky tests") { + t.Fatalf("the model was handed %q", sent) + } + for _, want := range []string{"team_start", "team_dissolve", "Start nobody until I agree", "the flaky tests"} { + if !strings.Contains(sent, want) { + t.Fatalf("the ask does not say %q: %q", want, sent) + } + } + if shown := lastPersonLine(t, a); shown != "/helpers the flaky tests" { + t.Fatalf("the transcript says %q, not what was typed", shown) + } + if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { + t.Fatal("no receipt says the team was made") + } +} + +// THE ASK WAITS FOR THE STORE. Until the write that carries the team is back +// the model is sent nothing, so its first step cannot run as anything but the +// manager; a second /helpers meanwhile makes no second team. +func TestHelpersAsksOnlyOnceTheStoreHasTheTeam(t *testing.T) { + a, fake := helpersApp(t) + before := len(fake.sent) + spend(t, a, a.runHelpersCommand("")) + if len(fake.sent) != before { + t.Fatal("the ask went out before the store took the team") + } + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersPendingWord) { + t.Fatal("a second /helpers while the first was saving did not say so") + } + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 { + t.Fatalf("two /helpers made %d teams", len(helpersDisk(t, a))) + } + if len(fake.sent) != before+1 { + t.Fatalf("the store's answer sent %d asks, want one", len(fake.sent)-before) + } +} + +// A TEAM MADE THIS WAY OBEYS THE RULES OF EVERY TEAM. A name already taken is +// not reused; a conversation that already manages a team is not made a second +// team's manager — it is shown its team, and nothing is sent. +func TestHelpersKeepsTheRulesEveryTeamKeeps(t *testing.T) { + a, fake := helpersApp(t) + if _, err := a.teamMake("lab", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + var made team + for _, t := range helpersDisk(t, a) { + if t.Manager != "" { + made = t + } + } + if made.ID == "" || strings.EqualFold(made.Name, "lab") { + t.Fatalf("the new team took a name another team has: %+v", helpersDisk(t, a)) + } + + sent := len(fake.sent) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if n := len(helpersDisk(t, a)); n != 2 { + t.Fatalf("a manager's /helpers made another team: %d teams", n) + } + if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { + t.Fatal("a manager's /helpers did not say which team it manages") + } + if len(fake.sent) != sent { + t.Fatal("a manager's /helpers sent the ask again") + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("a manager's /helpers did not show the team it manages") + } +} + +// THE STORE'S REFUSAL IS FINAL. Another window made this conversation another +// team's manager after this one loaded: the window's own check passes, the +// store refuses with the one-manager rule, the window takes the store's teams +// back, closes the overlay, and the model is asked nothing. +func TestHelpersRefusedByTheStoreAsksNothing(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + elsewhere := teamstore.NewID() + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: elsewhere, Name: "orbit"}) + if err := f.AddMember(elsewhere, teamstore.Member{Key: front, File: a.file, Where: a.workspace}); err != nil { + return err + } + return f.SetManager(elsewhere, front) + }); err != nil { + t.Fatal(err) + } + before := len(fake.sent) + spend(t, a, a.runHelpersCommand("")) + teamsFlush(t, a) + if len(fake.sent) != before { + t.Fatal("a refused team still asked the model for helpers") + } + disk := helpersDisk(t, a) + if len(disk) != 1 || disk[0].ID != elsewhere { + t.Fatalf("the store holds %+v, want only the other window's team", disk) + } + if _, ok := a.teamActive(); ok { + t.Fatal("the overlay stayed on a team the store refused") + } + if !transcriptSays(a, "a conversation can manage one team and its descendants") { + t.Fatal("the refusal was not said in the store's words") + } +} + +// WHERE IT CANNOT WORK IT SAYS SO AND MAKES NOTHING: home, which has no +// conversation in front; a connection that holds one conversation at a time, +// where a helper could not open beside its manager; and the welcome screen. +func TestHelpersRefusesWhereItCannotWork(t *testing.T) { + if got := homeFate("helpers", ""); got != fateAnswers { + t.Fatalf("the drop-up says /helpers %q on home", got) + } + + a, fake := helpersApp(t) + a.shared = true + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersShared) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("a one-conversation connection made a team") + } + + a, fake = helpersApp(t) + a.welcome.open, a.welcome.start = true, true + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersFreshWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("the welcome screen made a team") + } +} + +// THE COLUMN'S DOOR IS /helpers PRESSED. A chat in no team has `+ Make team` +// beside its Tasks word; pressing it makes the team and asks, as typing does; +// and the chat, now a manager, has the Traffic word there instead. +func TestTheColumnsMakeTeamDoorIsHelpers(t *testing.T) { + a, fake := helpersApp(t) + line, row := a.sideHeadRow(40) + if !strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("a chat in no team has no %q in its column's header: %q", helpersDoorWord, plain(line)) + } + var door *sideDoor + for i := range row.doors { + if row.doors[i].act.kind == sideActHelpers { + door = &row.doors[i] + } + } + if door == nil || !strings.HasPrefix(door.hint, helpersDoorHint) { + t.Fatalf("the header's doors are %+v", row.doors) + } + before := len(fake.sent) + cmd, _ := a.sidePress(row, door.span.from) + spend(t, a, cmd) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 || len(fake.sent) != before+1 || lastPersonLine(t, a) != "/helpers" { + t.Fatal("pressing the door did not do what /helpers does") + } + line, _ = a.sideHeadRow(40) + if strings.Contains(plain(line), helpersDoorWord) || !strings.Contains(plain(line), sideTrafficWord) { + t.Fatalf("the manager's header is %q", plain(line)) + } +} + +// THE MANAGER'S NO PUTS THE CHAT BACK AS IT WAS. The manager's team_dissolve +// writes the store from the engine (internal/teams' Dissolve); the window's +// Traffic read finds the team gone, closes its overlay, and the column is a +// plain chat's again, with its door. Nothing else in the window moved. +func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { + a, _ := helpersApp(t) + front := a.frontTabKey() + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + tabs := len(a.tabList()) + if err := teamstore.Dissolve(a.profileDir, made.ID, front); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + if _, ok := a.teamByID(made.ID); ok { + t.Fatal("the window still holds the dissolved team") + } + if _, ok := a.teamActive(); ok { + t.Fatal("the strip is still narrowed to the dissolved team") + } + if a.sideKind() != sideKindPlain || a.frontTabKey() != front { + t.Fatal("the chat in front is not the same plain chat") + } + if got := len(a.tabList()); got != tabs { + t.Fatalf("dissolving changed the strip from %d tabs to %d", tabs, got) + } + line, _ := a.sideHeadRow(40) + if !strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("the plain chat's door did not come back: %q", plain(line)) + } +} + +// A NARROW COLUMN LOSES THE DOOR, and keeps the key that hides it. +func TestANarrowColumnLosesTheMakeTeamDoorFirst(t *testing.T) { + a, _ := helpersApp(t) + line, _ := a.sideHeadRow(sideColsMin - 6) + if strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("the door was squeezed into %q", plain(line)) + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index e0bcbe94cd..ff397df8e2 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -273,6 +273,14 @@ func homeFate(word, rest string) string { // BOTH FORMS ANSWER HERE, and home is where a rule is the person's // everywhere rather than one project's (memory.go's [app.runAlways]). return fateAnswers + case "helpers": + // IT ANSWERS HERE, AND DOES NOTHING ELSE. /helpers makes a team of the + // conversation a person has been working in (helpers.go), and home has + // none in front: opening a fresh one would make a manager of a + // conversation that knows nothing, which is `+ AI manager`'s job, and + // acting on the one behind home would send its ask where nobody can + // read it. So it says where it works. + return fateAnswers } return "" } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index c497c59422..7ad00a3b48 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -154,6 +154,9 @@ const ( sideActHide // sideActTeams opens the teams page, where a packet is answered. sideActTeams + // sideActHelpers makes the chat in front a new team's manager and asks + // it for helpers: /helpers, pressed (helpers.go). + sideActHelpers ) // sideAct is what a press does. @@ -370,6 +373,26 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { trafficAct = sideAct{kind: sideActView, view: sideTraffic} } word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click") + // A CHAT IN NO TEAM IS OFFERED ONE where the Traffic word would stand: the + // door /helpers is (helpers.go), drawn dim like the other word, and only + // where it can work and where it fits beside the key that hides the + // column, which keeps its cell of air. A narrow column loses it first; + // /helpers is always there. + if !team && a.helpersOffered() { + need := ansi.StringWidth(sideWordSep+helpersDoorWord) + 1 + ansi.StringWidth(a.chords.say(sideHideKey)) + if used+need <= width { + b.WriteString(a.pal.dim(sideWordSep)) + used += ansi.StringWidth(sideWordSep) + w := ansi.StringWidth(helpersDoorWord) + painted := a.pal.dim(helpersDoorWord) + if hot == len(row.doors) { + painted = a.pal.cursor(a.pal.ink(helpersDoorWord), 0) + } + row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + w}, hint: helpersDoorHint + hintSegment + "click", act: sideAct{kind: sideActHelpers}}) + b.WriteString(painted) + used += w + } + } if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` @@ -837,6 +860,9 @@ func (a *app) sideDo(act sideAct) tea.Cmd { case sideActTeams: a.railTake(false) return a.showPage(pageTeams) + case sideActHelpers: + a.railTake(false) + return a.runHelpersCommand("") } return nil } diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index b9cfcec979..24823bae26 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -445,6 +445,19 @@ func (a *app) teamCreateIn(name string, tabs []chatTab, hue teamHueSpec, parent } func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool) (string, error) { + return a.teamMakeLed(name, tabs, hue, parent, freshOnly, "") +} + +// teamCreateLed is [app.teamCreateIn] at the top level with manager, one of +// tabs' keys, made the new team's manager IN THE SAME EDIT, so no reader of the +// store ever sees the team without it and a refusal of either refuses both. It +// is `/helpers`'s door (helpers.go); every rule a team made on the Teams page +// meets is met here, because it is the same edit with one more line. +func (a *app) teamCreateLed(name string, tabs []chatTab, hue teamHueSpec, manager string) (string, error) { + return a.teamMakeLed(name, tabs, hue, "", true, manager) +} + +func (a *app) teamMakeLed(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool, manager string) (string, error) { a.teamsEnsure() name = strings.TrimSpace(name) if name == "" { @@ -485,6 +498,9 @@ func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, pa } f.Teams = append(f.Teams, made) id = fresh + if manager != "" { + return f.SetManager(fresh, manager) + } return nil } // A team remade under its name keeps what its members already had (a diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 83fa43463b..a038be9c98 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -401,7 +401,9 @@ func (a *app) teamsWrite() tea.Cmd { } return func(bool) tea.Cmd { a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) - return nil + // A /helpers whose team this write carried is asked for its + // helpers now, and not before (helpers.go). + return a.helpersAsked() } })) } diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go index df98324747..be93ce6302 100644 --- a/internal/tui3/teamwritesaid.go +++ b/internal/tui3/teamwritesaid.go @@ -61,6 +61,11 @@ func (s *teamWriteSaid) settle(w teamsWrote) bool { // a slow write over a connection still shows its notice for the whole time. func (a *app) teamsWriteSettled(w teamsWrote) { a.tmemberStart.said.settle(w) + // A /helpers acts on its answer from the write's own command, which can + // hand back a turn ([app.helpersAsked]); here it is only marked due. + if a.helpers.said.settle(w) { + a.helpers.due = true + } if a.tp.disbandSaid.settle(w) { a.teamsDisbandSaid(a.tp.disbandName, a.tp.disbandSaid.why) } From 1792d21d124e2fe48e9207199a34045d4df1678b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:50:18 -0400 Subject: [PATCH 2/7] tui3: /helpers lives in the Teams dropdown, and says what it asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column button beside Tasks is gone. The Teams dropdown's last row, under a rule, is now always `+ New from this session`: it puts `/helpers` and a default ask in the message box, in place of what was there (kept on the drafts ring), and sends nothing until the person presses enter. Because the row is always there, the `Teams ▾` chip shows with no teams yet; only over --host to an engine without teams is it absent. The dropdown no longer prints an up/down, enter, esc footer. Nothing hidden is sent any more. /helpers sends exactly the words after it, and a bare /helpers sends the default ask and shows it in full in the transcript. The ask is in the person's words and names no tool; the model knows it manages the team from the role note every manager gets. A helper the manager starts opens as a tab on that team's strip: when the manager is in front, the strip is set to the team's overlay, without moving focus or the draft. The cursor no longer lands on the new row when no team is shown, and the long-list window follows the cursor's line, now that a rule sits between the teams and the last row. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 19 +- internal/manual/chat/screen.md | 7 +- internal/manual/chat/team-manager.md | 43 ++-- internal/manual/chat_test.go | 3 +- internal/tui3/chatsnavigation_test.go | 12 +- internal/tui3/commands.go | 9 +- internal/tui3/helpers.go | 187 +++++++------- internal/tui3/helpers_test.go | 238 ++++++++++++++---- internal/tui3/sidecol.go | 26 -- internal/tui3/teammenu.go | 70 +++++- internal/tui3/teammenu_test.go | 46 +++- internal/tui3/teamoverlay_test.go | 3 +- internal/tui3/teamtraffic.go | 10 + internal/tui3/wallclick_test.go | 7 +- internal/tui3/wallcontract.go | 1 + 16 files changed, 447 insertions(+), 236 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index dba355616d..1cc0fb649e 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | -| `/helpers` | | `` | makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel (also `+ Make team` beside `Tasks` in the right-hand column); on home it says to open a conversation first | +| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown, puts `/helpers` and that ask in the box; on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 278373f14d..278ec769b0 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -7,10 +7,11 @@ with no team overlay selected. Clearing the team's visual overlay does not end membership, change reporting authority, or stop background work. The composer has no team-recipient hint and the sidebar uses the ordinary task view. -With no overlay, the strip shows **Teams ▾**. Selecting a team replaces it with a coloured +With no overlay, the strip shows **Teams ▾**, even before you have a team. Selecting a team replaces it with a coloured team chip and a separate **x**. Click the name to switch teams; click x to remove the overlay and restore ordinary Chats. Clearing selects **All teams** in Teams without -changing membership or background work. The dropdown lists actual teams only, with no **None** option. +changing membership or background work. The dropdown lists actual teams, with no **None** option, +and ends with **+ New from this session**, which makes a team of the conversation in front. The **▦ All** button still opens the independent conversation grid. The Teams dropdown is the only team selector in Chats; there are no membership buttons @@ -216,14 +217,18 @@ them; Undo does not reopen them. Organization otherwise only adds memberships: i not rename teams or remove conversations. Nothing runs automatically. Press Organize again to refresh suggestions. The conversation grid has no Organize or Undo control. -## The team switcher on the tab strip +## The team switcher on the tab strip — the Teams dropdown and + New from this session The dropdown is first on the Chats tab strip. With no overlay its label is **Teams ▾**; with one selected it shows that team's name, colour and a separate **x** to clear it. The dropdown offers **All teams** when a global manager exists, then the active team hierarchy. All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. -The picker has no None option or management actions, and is absent when no overlay is available. +The picker has no None option or management actions. Its last row, under a rule, is always +**+ New from this session**: it puts `/helpers` and a default ask in the message box, replacing +what was there (that draft is kept in `/drafts`), and nothing happens until you edit it +and press enter (see *Turn this conversation into a team* on the team manager page). So the +chip is there even with no teams yet; it is absent only over `--host` to an engine without teams. Closed teams are available through Show closed on Teams, not through this picker. Selecting a team restores its conversation and horizontal strip position. A new overlay @@ -239,9 +244,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. In the conversation you are working in, **`/helpers`** (or `+ Make -team` in the right-hand column) makes a new team with that conversation as its manager, -which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`/helpers`** (or **`+ New +from this session`**, last in the Teams dropdown) makes a new team with that conversation +as its manager, which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index b7b7d29af7..2c466a1803 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -31,8 +31,7 @@ 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 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; a chat in -no team also has `+ Make team` there (`/helpers`, on the team manager page). An untouched empty +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 task is already here. Under the @@ -3328,9 +3327,7 @@ Sections are one blank line apart; the band's rule is the one rule in the column The top of the right-hand column, above the sections under its list: -- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. In a chat - in no team, where it fits, `Tasks N · + Make team`: pressing it is `/helpers`, which makes - this conversation a new team's manager and asks it to propose helpers. The word +- **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 diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 1489a1281e..5edd4a7ec8 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,9 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. -- **`/helpers`**, or **`+ Make team`** in the right-hand column, in the conversation you are - working in: a new team is made with that conversation as its manager, and it proposes - helpers (see **Turn this conversation into a team**). +- **`/helpers`**, or **`+ New from this session`** last in the Teams dropdown, in the + conversation you are working in: a new team is made with that conversation as its + manager, and it proposes helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -69,32 +69,33 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. -## Turn this conversation into a team — /helpers, the + Make team button, convert a chat, bring in helpers +## Turn this conversation into a team — /helpers, + New from this session, convert a chat, bring in helpers You are working in a conversation, the work has grown, and you want helpers with this -conversation in charge. Type `/helpers`, or `/helpers `, or press -**`+ Make team`** beside `Tasks` at the top of the right-hand column. Two things happen: +conversation in charge. Open the **Teams ▾** dropdown at the left of the tab strip and choose +its last row, **`+ New from this session`**. It puts this in the message box, in place of +what was there, and sends nothing: + +`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` + +Edit the words into the team you want, then press enter; typing `/helpers` yourself does +the same, and a bare `/helpers` sends exactly that default. Two things happen: 1. A new top-level team is made with **this conversation as its manager** and only member, - in one save. It is named after the conversation's folder, or a word such as `harbor` - when a team already has that name, and takes the next free colour. The strip narrows to - it, the tab reads `◆ Manager`, and the column turns to the team's Traffic. The rules of - every team apply: a name no other team has, and one conversation manages one team and - its subteams. Rename it or change its colour in Teams, under `Settings`. -2. Once the team is saved, the conversation is sent one message, shown as `/helpers`: it - proposes two or three helpers — a handle, the part of the work, and the brief — and asks - you to accept them, change the list, or cancel. It starts nobody until you agree. + in one save, named after the conversation's folder (or a word such as `harbor` when that + name is taken), in the next free colour. The strip narrows to it and reads `◆ Manager`. + Every team's rules apply; rename it in Teams, under `Settings`. +2. Once it is saved, your words after `/helpers` are sent as your message, exactly as the + transcript shows them. The conversation now knows it is the team's manager. The receipt reads `made the team lab · this conversation is its manager`. Reply in your own -words; there is no card. Yes, and it starts each helper with `team_start`, under your -approval posture like any start. A different list, and it proposes again. Cancel, and it -takes the team back with `team_dissolve` (see **Cancel a team nobody joined**). Nothing is -copied: the manager keeps this conversation, its history and its tasks. +words. Yes, and it starts each helper with `team_start`, under your approval posture; each +opens as a tab on this team's strip, while you stay in front of the manager. A different +list, and it proposes again. Cancel, and it takes the team back with `team_dissolve`. ## When /helpers is not offered, and what it says -`+ Make team` is drawn only in a chat in no team, where it can work and where it fits; a -narrow column drops it first. `/helpers` always answers: +`/helpers`, and `+ New from this session`, answer with a sentence and change nothing: - On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. - On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. @@ -113,7 +114,7 @@ conversation before the save finishes, the team stands and the message is not se Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The team is forgotten as if it had never been made: no row under `Show closed`, nothing stopped, and the conversation, its history and its tasks stay. The strip goes back to all -your chats and the column to `Tasks`, with `+ Make team` again. +your chats and the column to `Tasks`. A manager has `team_dissolve` only while its team has never held anyone but it: no member, no former member, no subteam, and nothing in its Traffic. The rule is the same for diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d99e5ab4a3..95f3d47c92 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4125,7 +4125,8 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"how do I turn this conversation into a team", "Turn this conversation into a team"}, {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, {"bring in helpers for what I am working on", "Turn this conversation into a team"}, - {"what does the + Make team button do", "Turn this conversation into a team"}, + {"what does + New from this session in the Teams dropdown do", "Turn this conversation into a team"}, + {"what does /helpers send to the model", "Turn this conversation into a team"}, {"why is /helpers not offered here", "When /helpers is not offered"}, {"/helpers says open one first", "When /helpers is not offered"}, {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, diff --git a/internal/tui3/chatsnavigation_test.go b/internal/tui3/chatsnavigation_test.go index 7a109a8e0d..224d7afd95 100644 --- a/internal/tui3/chatsnavigation_test.go +++ b/internal/tui3/chatsnavigation_test.go @@ -194,18 +194,24 @@ func TestTeamMenuLongTreeScrollsAndTinyResizeCannotChoose(t *testing.T) { if !a.teamMenu.card.holds(a.teamMenu.card.x0, a.teamMenu.card.y0) || len(a.teamMenu.hits) >= len(a.teamMenuRows()) { t.Fatal("long picker was not bounded") } - for range 37 { + // The cursor counts the rows it can land on; the last is `+ New from this + // session`, under a rule that is a line and not a pick (teammenu.go). + picks := teamMenuPicks(a.teamMenuRows()) + for range len(picks) { drive(t, a, key("down")) menuFrame(t, a) } - chosen := a.teamMenuRows()[a.teamMenu.cursor] + chosen := picks[a.teamMenu.cursor] + if chosen.code != wallPopHelpers { + t.Fatalf("the last row the cursor reaches is %+v", chosen) + } hit := menuHit(t, a, chosen.code, chosen.id) if !a.teamMenu.card.holds(hit.x0, hit.y0) || a.teamMenu.top == 0 { t.Fatal("selected last row is offscreen") } drive(t, a, tea.MouseWheelMsg{X: hit.x0, Y: hit.y0, Button: tea.MouseWheelUp}) menuFrame(t, a) - if !a.teamMenu.on || a.teamMenu.cursor >= 37 || a.wall.activeID != harbor { + if !a.teamMenu.on || a.teamMenu.cursor >= len(picks) || a.wall.activeID != harbor { t.Fatal("wheel escaped the picker or selected a team") } for width := 22; width <= 80; width++ { diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 4b6244df53..a547e1e222 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -157,10 +157,11 @@ var commands = []command{ {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)}, // AND THE ONE DOOR THAT STARTS FROM THE CONVERSATION RATHER THAN FROM THE // TEAM, under the page it fills (helpers.go): this conversation becomes a - // new team's manager and proposes helpers. Two rows, the way /manual has - // two, because the words after it go to the manager as what to help with. - {name: "helpers", desc: "makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel"}, - {name: "helpers", args: "", desc: "…the same, with what you want help with"}, + // new team's manager and is sent the words after the command. Two rows, + // the way /manual has two, because bare it sends a default ask, written + // out in the transcript, and with words it sends those instead. + {name: "helpers", desc: "makes this conversation a new team's manager and asks it to suggest helpers, for you to accept, change or cancel"}, + {name: "helpers", args: "", desc: "…the same, sending your own words instead of the default ask"}, {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 diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index 3937c18d17..7e1815a289 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -19,26 +19,33 @@ package tui3 // one conversation managing one team and its descendants, no deleted // conversation readmitted. The strip narrows to it, so the person sees // `◆ Manager` and the column turns to the team's Traffic. -// 2. Once the store has TAKEN that edit, and only then, the conversation is -// sent one turn, shown in the transcript as `/helpers` and carrying the -// words below ([helpersAskOf]): propose two or three helpers, ask the -// person, start nobody until they agree. Waiting for the store is what -// makes the turn's first step find the conversation already a manager, with -// its verbs on the belt ([teamsWrote], [app.helpersAsked]). +// 2. Once the store has TAKEN that edit, and only then, the person's words +// after `/helpers` are sent to the conversation as their message. Waiting +// for the store is what makes the turn's first step find the conversation +// already a manager, with its role note and its verbs ([teamsWrote], +// [app.helpersAsked]). +// +// NOTHING IS SENT THAT THE PERSON DID NOT SEE. The words the model reads are +// the words in the transcript after `/helpers`, exactly. A bare `/helpers` is +// [helpersPrompt], and the transcript shows it written out after the command, +// so the line on screen is the whole of what was asked. What the model knows +// beyond those words is what every manager knows: the role note internal/session +// lands at its first step, and its verbs' own descriptions. +// +// THE TEAMS DROPDOWN'S LAST ROW IS THE SAME DOOR, ONE STEP EARLIER. `+ New from +// this session` (teammenu.go) puts `/helpers ` and [helpersPrompt] in the box, +// in place of whatever was there, and sends nothing: the person reads the +// request, changes it into the team they want, and presses enter. // // THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the // manager acts on with `team_start`, which runs under the approval posture every -// other start does; a different list is a reply it proposes again from; and no -// is a reply it acts on with `team_dissolve`, which takes back a team that has -// never held anyone but its manager and is on the belt only while that is true -// (internal/session's team_dissolve.go). After the first helper joins, the team -// ends the way every other team does: disbanded by the person, on Teams. -// -// IT IS ABSENT WHERE IT CANNOT WORK, as the column's door says by not being -// drawn ([app.helpersOffered]): over --host to an engine without the teams -// doors, on a connection that holds one conversation at a time (a helper opens -// beside the manager), on the welcome screen before a conversation exists, and -// for a conversation that already manages a team. +// other start does, and each helper it starts opens as a tab in this team's +// overlay (teamtraffic.go's [app.trafficStarted]); a different list is a reply +// it proposes again from; and cancel is a reply it acts on with +// `team_dissolve`, which takes back a team that has never held anyone but its +// manager and is on the belt only while that is true (internal/session's +// team_dissolve.go). After the first helper joins, the team ends the way every +// other team does: disbanded by the person, on Teams. import ( "fmt" @@ -48,12 +55,19 @@ import ( tea "charm.land/bubbletea/v2" ) +// helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts +// in the box after `/helpers `. It is the person speaking, in words they would +// use, because it is their message and it is on their screen: it names no tool +// and nothing the person could not have typed. Its three asks are the three +// things the person needs from the manager: a proposal to read, nobody started +// before they agree, and a way to say no. +const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team." + // The words this door says. Each is quoted in the manual exactly as spelled. const ( - // helpersDoorWord is the side column's door in a chat in no team, and - // helpersDoorHint what the hint line says over it. - helpersDoorWord = "+ Make team" - helpersDoorHint = "Make this conversation the manager of a new team, and ask it to propose helpers" + // helpersRowWord is the Teams dropdown's last row, which puts /helpers and + // [helpersPrompt] in the box. + helpersRowWord = "+ New from this session" // helpersPlaceWord is /helpers typed where no conversation is in front. helpersPlaceWord = "/helpers makes a team of the conversation you are in · open one first" // helpersFreshWord is /helpers on the welcome screen, before there is a @@ -67,7 +81,7 @@ const ( // helpersMadeWord is the receipt, once the store has the team. helpersMadeWord = "made the team %s · this conversation is its manager" // helpersAwayWord is the receipt when the person left the conversation - // before the store answered: the team is made and the ask is not sent. + // before the store answered: the team is made and the message is not sent. helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it for helpers there" ) @@ -75,26 +89,10 @@ const ( // a time: a helper is a conversation that opens beside its manager. var helpersShared = "helpers open beside this conversation, and " + oneConversationWord -// helpersAskOf is the turn the conversation is sent, for team name, with the -// person's own words after /helpers, if any, at the end. It is written as the -// person speaking, because it is their message: the transcript shows what they -// typed and the model reads this behind it, the way /manual works -// (manualcmd.go). It names both verbs, so the yes and the no each have one. -func helpersAskOf(name, rest string) string { - ask := fmt.Sprintf("I have just turned this conversation into a team called %q, and you are its manager: you keep this conversation and everything in it, and nobody else is in the team yet. "+ - "Propose two or three helpers for the work we are doing here. For each, give a handle (2 to 12 lowercase letters or digits), the part of the work it would take, and the brief you would give it. "+ - "Then ask me to accept them, change the list, or cancel. Start nobody until I agree. "+ - "When I agree, start each one with team_start. If I cancel, take the team back with team_dissolve.", name) - if rest = strings.TrimSpace(rest); rest != "" { - ask += " What I want help with: " + rest - } - return ask -} - // helpersPending is one /helpers between its edit and the store's answer: the // edit's notice tie, the conversation it made a manager, the team's name, and -// the turn to send once the store has taken it. due says the answer is in and -// [app.helpersAsked] has not acted on it yet. +// the message to send once the store has taken it. due says the answer is in +// and [app.helpersAsked] has not acted on it yet. type helpersPending struct { said teamWriteSaid key string @@ -117,53 +115,45 @@ func (a *app) helpersTab() (chatTab, bool) { return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true } -// helpersOffered reports whether the side column draws its door: the same -// refusals [app.runHelpersCommand] would give, asked of memory only, because a -// frame asks it. -func (a *app) helpersOffered() bool { - if a.teamsOff() || a.shared || !a.wall.loaded || a.helpers.said.pending { - return false - } - if a.agent == nil || a.startingChat() || a.pageShowing() { - return false - } - key := a.frontTabKey() - if key == "" { - return false - } - _, manages := a.teamManaged(key) - return !manages -} - -// runHelpersCommand is /helpers, and the column's door: the team made, and the -// ask queued behind the store's answer. -func (a *app) runHelpersCommand(rest string) tea.Cmd { - if a.pageShowing() { - a.note(helpersPlaceWord) - return nil - } - if a.teamsOff() { - a.note(teamHostedWord) - return nil - } - if a.shared { - a.note(helpersShared) - return nil +// helpersRefusal is why /helpers cannot make a team of the conversation in +// front, "" when it can, and the team it already manages when that is why. It +// is asked by the command and by the dropdown's row, so the row never fills the +// box with a request the command would refuse. +func (a *app) helpersRefusal() (string, team) { + switch { + case a.pageShowing(): + return helpersPlaceWord, team{} + case a.teamsOff(): + return teamHostedWord, team{} + case a.shared: + return helpersShared, team{} } tab, ok := a.helpersTab() if !ok { - a.note(helpersFreshWord) - return nil + return helpersFreshWord, team{} } if a.helpers.said.pending { - a.note(helpersPendingWord) - return nil + return helpersPendingWord, team{} } a.teamsEnsure() if t, manages := a.teamManaged(tab.key); manages { - a.note(fmt.Sprintf(helpersManagesWord, t.Name)) - return a.teamActivate(t.ID) + return fmt.Sprintf(helpersManagesWord, t.Name), t + } + return "", team{} +} + +// runHelpersCommand is /helpers: the team made, and the person's words queued +// behind the store's answer. With no words after the command they are +// [helpersPrompt], and the transcript shows them. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + if why, managed := a.helpersRefusal(); why != "" { + a.note(why) + if managed.ID != "" { + return a.teamActivate(managed.ID) + } + return nil } + tab, _ := a.helpersTab() name := teamFreshName([]chatTab{tab}, a.teamNames(), "", rand.IntN) hue := nextTeamHue(a.teamHues(""), teamReservedHues(a.pal)) id, err := a.teamCreateLed(name, []chatTab{tab}, hue, tab.key) @@ -177,17 +167,17 @@ func (a *app) runHelpersCommand(rest string) tea.Cmd { // manager` does ([app.teamsTake]): the overlay closes and the store's // teams are taken, so a team the store never had is not drawn. a.teamsWatchManagerStart(tab.key) - shown := "/helpers" - if rest = strings.TrimSpace(rest); rest != "" { - shown += " " + rest + ask := strings.TrimSpace(rest) + if ask == "" { + ask = helpersPrompt } - a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: helpersAskOf(name, rest), shown: shown} + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: ask, shown: "/helpers " + ask} return a.teamActivate(id) } // helpersAsked is a /helpers whose write has come back: the receipt and the -// turn when the store took the team, and nothing when it refused it, because -// [app.teamsTake] has already said why. +// message when the store took the team, and nothing when it refused it, +// because [app.teamsTake] has already said why. func (a *app) helpersAsked() tea.Cmd { p := a.helpers if !p.due { @@ -197,7 +187,7 @@ func (a *app) helpersAsked() tea.Cmd { if p.said.why != "" { return nil } - // THE PERSON MAY HAVE LEFT. The turn goes to the conversation in front, + // THE PERSON MAY HAVE LEFT. The message goes to the conversation in front, // and a manager's first words sent to another conversation would be the // wrong conversation's; so the team stands and the ask is left to them. if a.frontTabKey() != p.key { @@ -207,3 +197,30 @@ func (a *app) helpersAsked() tea.Cmd { a.note(fmt.Sprintf(helpersMadeWord, p.name)) return a.submitShown(p.ask, p.shown, nil) } + +// helpersFill is the Teams dropdown's `+ New from this session`: `/helpers ` +// and [helpersPrompt] put in the box, in place of what was there, for the +// person to read, change and send. Nothing is made until they press enter. +// What was in the box goes onto the drafts ring first, as a draft restored +// over it does (draftpage.go), so replacing it loses nothing. Where /helpers +// would refuse, the row says so instead and leaves the box alone. +func (a *app) helpersFill() tea.Cmd { + a.closeTeamMenu() + if a.wall.on { + a.closeWall() + } + if why, managed := a.helpersRefusal(); why != "" { + a.note(why) + if managed.ID != "" { + return a.teamActivate(managed.ID) + } + return nil + } + line := "/helpers " + helpersPrompt + if live := a.input.String(); strings.TrimSpace(live) != "" && live != line { + a.drafts.push([]rune(live)) + } + a.input.setText(line) + a.touch() + return a.syncLists() +} diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 4db102d161..2bca725380 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -55,16 +55,30 @@ func transcriptSays(a *app, want string) bool { return false } +// helpersMenuRow is the Teams dropdown's `+ New from this session` row, and +// whether it is the last row. +func helpersMenuRow(t *testing.T, a *app) (teamMenuRow, bool) { + t.Helper() + rows := a.teamMenuRows() + for i, r := range rows { + if !r.rule && r.code == wallPopHelpers { + return r, i == len(rows)-1 + } + } + t.Fatalf("the dropdown has no %q row: %+v", helpersRowWord, rows) + return teamMenuRow{}, false +} + // /helpers MAKES A TEAM WITH THIS CONVERSATION AS ITS MANAGER, and only then -// asks it for helpers. The team is on disk with the conversation as manager and -// only member; the strip is narrowed to it; the column is the manager's; the -// transcript says `/helpers …` and the model was handed the ask, naming both the -// verb that grows the team and the one that takes it back. -func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { +// sends the person's words. The team is on disk with the conversation as +// manager and only member; the strip is narrowed to it; the column is the +// manager's; and the model is handed exactly the words the transcript shows +// after `/helpers`, and nothing else. +func TestHelpersMakesThisConversationTheManagerAndSendsWhatWasTyped(t *testing.T) { a, fake := helpersApp(t) front := a.frontTabKey() before := len(fake.sent) - typeLine(t, a, "/helpers the flaky tests") + typeLine(t, a, "/helpers suggest one helper for the flaky tests") teamsFlush(t, a) disk := helpersDisk(t, a) @@ -87,20 +101,13 @@ func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { t.Fatalf("the composer says %q, not that it talks to the manager", hint) } - if len(fake.sent) != before+1 { t.Fatalf("/helpers sent %d messages, want one", len(fake.sent)-before) } - sent := fake.sent[before] - if sent != helpersAskOf("lab", "the flaky tests") { - t.Fatalf("the model was handed %q", sent) - } - for _, want := range []string{"team_start", "team_dissolve", "Start nobody until I agree", "the flaky tests"} { - if !strings.Contains(sent, want) { - t.Fatalf("the ask does not say %q: %q", want, sent) - } + if sent := fake.sent[before]; sent != "suggest one helper for the flaky tests" { + t.Fatalf("the model was handed %q, not the words that were typed", sent) } - if shown := lastPersonLine(t, a); shown != "/helpers the flaky tests" { + if shown := lastPersonLine(t, a); shown != "/helpers suggest one helper for the flaky tests" { t.Fatalf("the transcript says %q, not what was typed", shown) } if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { @@ -108,6 +115,32 @@ func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { } } +// A BARE /helpers SAYS ITS ASK OUT LOUD. The model is handed [helpersPrompt], +// and the transcript shows that same sentence after the command, so nothing was +// asked that the person cannot read. The sentence is the person's own kind of +// words: it names no tool and none of codeaf's machinery. +func TestABareHelpersShowsTheAskItSends(t *testing.T) { + a, fake := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("a bare /helpers sent %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q, not the ask that was sent", shown) + } + for _, internal := range []string{"team_", "tool", "manager", "codeaf", "handle"} { + if strings.Contains(strings.ToLower(helpersPrompt), internal) { + t.Errorf("the default ask says %q, which is codeaf's word and not the person's: %q", internal, helpersPrompt) + } + } + for _, want := range []string{"two or three helpers", "Don't start anyone until I agree", "cancel"} { + if !strings.Contains(helpersPrompt, want) { + t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) + } + } +} + // THE ASK WAITS FOR THE STORE. Until the write that carries the team is back // the model is sent nothing, so its first step cannot run as anything but the // manager; a second /helpers meanwhile makes no second team. @@ -227,42 +260,151 @@ func TestHelpersRefusesWhereItCannotWork(t *testing.T) { } } -// THE COLUMN'S DOOR IS /helpers PRESSED. A chat in no team has `+ Make team` -// beside its Tasks word; pressing it makes the team and asks, as typing does; -// and the chat, now a manager, has the Traffic word there instead. -func TestTheColumnsMakeTeamDoorIsHelpers(t *testing.T) { - a, fake := helpersApp(t) - line, row := a.sideHeadRow(40) - if !strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("a chat in no team has no %q in its column's header: %q", helpersDoorWord, plain(line)) - } - var door *sideDoor - for i := range row.doors { - if row.doors[i].act.kind == sideActHelpers { - door = &row.doors[i] +// THE TEAMS DROPDOWN IS THERE WITH NO TEAMS, and its last row is `+ New from +// this session`. With teams, that row stays last, under a rule. The dropdown +// says nothing about its keys: no footer of up/down, enter and esc. +func TestTheTeamsDropdownIsThereWithNoTeamsAndEndsWithNewFromThisSession(t *testing.T) { + a, _ := helpersApp(t) + if len(a.wall.teams) != 0 { + t.Fatalf("the fixture has teams: %+v", a.wall.teams) + } + if word := a.tabTeamWord(); !strings.Contains(word, "Teams") { + t.Fatalf("with no teams the strip's chip is %q", word) + } + a.openTeamMenu() + if !a.teamMenu.on { + t.Fatal("the dropdown did not open with no teams") + } + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("+ New from this session is not the last row") + } + frame, _ := menuFrame(t, a) + if !strings.Contains(frame, helpersRowWord) { + t.Fatalf("the dropdown does not draw %q:\n%s", helpersRowWord, frame) + } + for _, hint := range []string{"up/down move", "enter choose", "esc cancel"} { + if strings.Contains(frame, hint) { + t.Errorf("the dropdown still says %q:\n%s", hint, frame) } } - if door == nil || !strings.HasPrefix(door.hint, helpersDoorHint) { - t.Fatalf("the header's doors are %+v", row.doors) + + if _, err := a.teamMake("harbor", nil); err != nil { + t.Fatal(err) + } + rows := a.teamMenuRows() + if len(rows) != 3 || rows[0].code != wallPopTeam || !rows[1].rule || rows[2].code != wallPopHelpers { + t.Fatalf("with a team the rows are %+v, want the team, a rule, and the row", rows) + } +} + +// `+ New from this session` IS /helpers ONE STEP EARLIER. Chosen by the +// keyboard, it puts `/helpers ` and the default ask in the box in place of +// what was there (which goes onto the drafts ring), makes nothing and sends +// nothing; enter then does exactly what typing it would. +func TestNewFromThisSessionPutsHelpersInTheBoxAndEnterSendsIt(t *testing.T) { + a, fake := helpersApp(t) + a.input.insert("half a thought") + a.openTeamMenu() + picks := teamMenuPicks(a.teamMenuRows()) + for range picks { + drive(t, a, key("down")) + } + drive(t, a, key("enter")) + if a.teamMenu.on { + t.Fatal("the dropdown stayed up after its row was chosen") + } + if got := a.input.String(); got != "/helpers "+helpersPrompt { + t.Fatalf("the box holds %q", got) + } + if len(a.drafts.entries) == 0 || string(a.drafts.entries[len(a.drafts.entries)-1].text) != "half a thought" { + t.Fatal("what was in the box was not kept on the drafts ring") } - before := len(fake.sent) - cmd, _ := a.sidePress(row, door.span.from) - spend(t, a, cmd) teamsFlush(t, a) - if len(helpersDisk(t, a)) != 1 || len(fake.sent) != before+1 || lastPersonLine(t, a) != "/helpers" { - t.Fatal("pressing the door did not do what /helpers does") + if len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("choosing the row made a team or sent something before enter") } - line, _ = a.sideHeadRow(40) - if strings.Contains(plain(line), helpersDoorWord) || !strings.Contains(plain(line), sideTrafficWord) { - t.Fatalf("the manager's header is %q", plain(line)) + + drive(t, a, key("enter")) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 || len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("enter did not do what /helpers does: teams %d, sent %q", len(helpersDisk(t, a)), fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } +} + +// WHERE /helpers WOULD REFUSE, THE ROW SAYS SO AND LEAVES THE BOX ALONE: in a +// conversation that already manages a team, choosing it shows that team. +func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) { + a, _ := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + a.input.insert("keep this") + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + if got := a.input.String(); got != "keep this" { + t.Fatalf("the row replaced the box in a manager: %q", got) + } + if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { + t.Fatal("the row did not say which team this conversation manages") + } +} + +// THE HELPERS IT STARTS OPEN AS TABS IN THE TEAM'S OVERLAY. The manager's +// team_start is carried out by this window behind the manager; the new +// conversation is a member, held open, its tab on the strip, and the strip is +// this team's even if the person had cleared the overlay meanwhile. The person +// stays in front of the manager, with what they were typing. +func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { + a, _ := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + front := a.frontTabKey() + spend(t, a, a.teamOverlayChoose("")) + if _, ok := a.teamActive(); ok { + t.Fatal("the overlay did not clear") + } + n := 0 + a.start = func(workspace string) (Conversation, error) { + n++ + return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("/tmp/lab/helper-%d.jsonl", n), Workspace: workspace}, nil + } + a.input.insert("half a reply") + for _, handle := range []string{"tests", "docs"} { + trafficAppend(t, a, made.ID, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: handle, Text: "take the " + handle}) + } + trafficReadNow(t, a) + trafficReadNow(t, a) + if n != 2 { + t.Fatalf("two starts opened %d conversations", n) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the strip is not the team's after its helpers started") + } + if a.frontTabKey() != front || a.input.String() != "half a reply" { + t.Fatal("a start moved the person off the manager or out of their words") + } + a.touch() + row := plain(a.tabsRow(a.width)) + for _, handle := range []string{"tests", "docs"} { + m, ok := mustTeam(t, a, made.ID).ByHandle(handle) + if !ok || a.behind[m.Key] == nil { + t.Fatalf("@%s is not a member held open: %+v", handle, mustTeam(t, a, made.ID).Members) + } + if !strings.Contains(row, "@"+handle) { + t.Fatalf("@%s has no tab on the team's strip: %q", handle, row) + } } } // THE MANAGER'S NO PUTS THE CHAT BACK AS IT WAS. The manager's team_dissolve // writes the store from the engine (internal/teams' Dissolve); the window's // Traffic read finds the team gone, closes its overlay, and the column is a -// plain chat's again, with its door. Nothing else in the window moved. -func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { +// plain chat's again. Nothing else in the window moved. +func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { a, _ := helpersApp(t) front := a.frontTabKey() typeLine(t, a, "/helpers") @@ -285,17 +427,7 @@ func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { if got := len(a.tabList()); got != tabs { t.Fatalf("dissolving changed the strip from %d tabs to %d", tabs, got) } - line, _ := a.sideHeadRow(40) - if !strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("the plain chat's door did not come back: %q", plain(line)) - } -} - -// A NARROW COLUMN LOSES THE DOOR, and keeps the key that hides it. -func TestANarrowColumnLosesTheMakeTeamDoorFirst(t *testing.T) { - a, _ := helpersApp(t) - line, _ := a.sideHeadRow(sideColsMin - 6) - if strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("the door was squeezed into %q", plain(line)) + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("the dropdown lost its last row") } } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index 7ad00a3b48..c497c59422 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -154,9 +154,6 @@ const ( sideActHide // sideActTeams opens the teams page, where a packet is answered. sideActTeams - // sideActHelpers makes the chat in front a new team's manager and asks - // it for helpers: /helpers, pressed (helpers.go). - sideActHelpers ) // sideAct is what a press does. @@ -373,26 +370,6 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { trafficAct = sideAct{kind: sideActView, view: sideTraffic} } word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click") - // A CHAT IN NO TEAM IS OFFERED ONE where the Traffic word would stand: the - // door /helpers is (helpers.go), drawn dim like the other word, and only - // where it can work and where it fits beside the key that hides the - // column, which keeps its cell of air. A narrow column loses it first; - // /helpers is always there. - if !team && a.helpersOffered() { - need := ansi.StringWidth(sideWordSep+helpersDoorWord) + 1 + ansi.StringWidth(a.chords.say(sideHideKey)) - if used+need <= width { - b.WriteString(a.pal.dim(sideWordSep)) - used += ansi.StringWidth(sideWordSep) - w := ansi.StringWidth(helpersDoorWord) - painted := a.pal.dim(helpersDoorWord) - if hot == len(row.doors) { - painted = a.pal.cursor(a.pal.ink(helpersDoorWord), 0) - } - row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + w}, hint: helpersDoorHint + hintSegment + "click", act: sideAct{kind: sideActHelpers}}) - b.WriteString(painted) - used += w - } - } if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` @@ -860,9 +837,6 @@ func (a *app) sideDo(act sideAct) tea.Cmd { case sideActTeams: a.railTake(false) return a.showPage(pageTeams) - case sideActHelpers: - a.railTake(false) - return a.runHelpersCommand("") } return nil } diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 5dfad56751..9e52e9f058 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -10,11 +10,19 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -// The strip's switcher chooses only a team overlay. The chip's separate clear +// The strip's switcher chooses a team overlay. The chip's separate clear // action restores ordinary Chats; selecting a team restores its last conversation and strip position. // Management belongs to Teams, so this menu cannot change a membership or manager. // It is modal: arrows choose a row, enter activates it, and escape or a press -// outside closes the menu without changing the view. +// outside closes the menu without changing the view. It says none of that in a +// footer: a list of rows under a chip is read and worked the way every list is. +// +// ITS LAST ROW IS ALWAYS `+ New from this session`, under a rule, which makes a +// team of the conversation in front by putting `/helpers` in the box +// (helpers.go). Because that row is always there, the chip is there too, with no +// team yet: `Teams ▾` is how a person with none finds how to make one from what +// they are doing. Over --host to an engine without the teams doors nothing here +// can work, and the row, and with no teams the chip, are absent. // teamMenu is the switcher's state: whether it is up, the row the keyboard is // on, the row the pointer is on, and where the last frame drew it and its rows, @@ -48,9 +56,20 @@ func (a *app) teamMenuRows() []teamMenuRow { for _, r := range a.teamsOpenTree() { rows = append(rows, teamMenuRow{code: wallPopTeam, id: r.id, depth: r.depth}) } + if a.teamMenuHelpers() { + if len(rows) > 0 { + rows = append(rows, teamMenuRow{rule: true}) + } + rows = append(rows, teamMenuRow{code: wallPopHelpers}) + } return rows } +// teamMenuHelpers reports whether the switcher carries `+ New from this +// session`: everywhere a team can be kept. Where /helpers would refuse for the +// conversation in front, the row is still there and says why when chosen. +func (a *app) teamMenuHelpers() bool { return !a.teamsOff() } + // A picker with no available overlay is absent, including retained teams and // an optional global manager that has not been created. func (a *app) teamMenuOffers(t team) bool { @@ -58,6 +77,9 @@ func (a *app) teamMenuOffers(t team) bool { } func (a *app) teamMenuAvailable() bool { + if a.teamMenuHelpers() { + return true + } for _, t := range a.wall.teams { if a.teamMenuOffers(t) { return true @@ -87,7 +109,9 @@ func (a *app) openTeamMenu() { } a.teamMenu = teamMenu{on: true} for i, r := range picks { - if r.id == a.wall.activeID { + // Only a team's row is the team shown: `+ New from this session` has + // no id, and with no team shown would otherwise match the empty one. + if r.code == wallPopTeam && r.id == a.wall.activeID { a.teamMenu.cursor = i break } @@ -114,10 +138,15 @@ func (a *app) teamMenuFront() chatTab { // teamMenuDo is one row chosen, by the keyboard or the pointer. func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd { - if r.code != wallPopTeam { + switch { + case r.rule: return nil + case r.code == wallPopHelpers: + return a.helpersFill() + case r.code == wallPopTeam: + return a.teamOverlayChoose(r.id) } - return a.teamOverlayChoose(r.id) + return nil } // The chip's clear action and menu selections share the same transition, so @@ -276,7 +305,14 @@ func (a *app) teamMenuCard(width, height int) wallCard { var lines []line for _, r := range rows { ln := line{row: r} + if r.rule { + lines = append(lines, ln) + continue + } switch r.code { + case wallPopHelpers: + ln.left = pal.ink(helpersRowWord) + ln.leftW = ansi.StringWidth(helpersRowWord) case wallPopTeam: t, _ := a.teamByID(r.id) radio := off @@ -320,19 +356,28 @@ func (a *app) teamMenuCard(width, height int) wallCard { } w := inner + 2 + 2*padX top := tabStripRow + 1 - footer := teamFooter(pal, inner, teamHint("up/down", "move"), teamHint("enter", "choose"), teamHint("esc", "cancel")) - capacity := height - top - 2 - len(footer) + capacity := height - top - 2 if w > width-2 || capacity < 1 { return wallCard{} } m := &a.teamMenu - m.cursor = min(max(m.cursor, 0), len(lines)-1) + m.cursor = min(max(m.cursor, 0), len(picks)-1) + // THE CURSOR COUNTS PICKS AND THE WINDOW COUNTS LINES, and the rule above + // the last row is a line that is not a pick; the window is kept on the + // line the cursor's pick is drawn on. + at := 0 + for i, ln := range lines { + if !ln.row.rule && ln.row.code == picks[m.cursor].code && ln.row.id == picks[m.cursor].id { + at = i + break + } + } m.top = min(max(m.top, 0), max(len(lines)-capacity, 0)) - if m.cursor < m.top { - m.top = m.cursor + if at < m.top { + m.top = at } - if m.cursor >= m.top+capacity { - m.top = m.cursor - capacity + 1 + if at >= m.top+capacity { + m.top = at - capacity + 1 } end := min(m.top+capacity, len(lines)) title := "Teams" @@ -360,6 +405,5 @@ func (a *app) teamMenuCard(width, height int) wallCard { hits: []wallHit{{x0: 0, y0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: ln.row.code, id: ln.row.id}}, }) } - cardLines = append(cardLines, footer...) return wallCardBuild(pal, ansi.Truncate(title, w-5, g.more), cardLines, x, top, w, padX, 0) } diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index bf8d1f0a0b..5069437c54 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -193,8 +193,16 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { t.Fatalf("management action %q remains in overlay picker", word) } } - if len(a.teamMenuRows()) != len(a.teamsOpenTree()) { - t.Fatal("picker has extra rows") + // Its rows are the open teams and, last under a rule, `+ New from this + // session`, which puts /helpers in the box and edits no team (helpers.go). + rows := a.teamMenuRows() + if len(rows) != len(a.teamsOpenTree())+2 || !rows[len(rows)-2].rule || rows[len(rows)-1].code != wallPopHelpers { + t.Fatalf("picker has extra rows: %+v", rows) + } + for _, r := range rows[:len(rows)-2] { + if r.code != wallPopTeam { + t.Fatalf("picker has a row that is not a team: %+v", r) + } } } @@ -246,13 +254,16 @@ func TestTeamMenuChoosesOverlayFromGridWithoutFilteringIt(t *testing.T) { } } -// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾` while there are teams, and -// is not there at all while there are none. +// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾`, with teams and without: +// with none, its one row is `+ New from this session` (helpers.go). func TestTeamMenuQuietChipWithNoTeamShown(t *testing.T) { a, _, _ := tabApp(t) a.teamsEnsure() - if row := plain(a.tabsRow(a.width)); strings.Contains(row, "▾") { - t.Fatalf("a chip with no teams: %q", row) + if row := plain(a.tabsRow(a.width)); !strings.Contains(row, " Teams ▾ ") { + t.Fatalf("no chip with no teams: %q", row) + } + if rows := a.teamMenuRows(); len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("with no teams the switcher's rows are %+v", rows) } a, _, _ = menuApp(t) a.teamActivate("") @@ -296,13 +307,17 @@ func TestTeamMenuWithoutAvailableTeamsHasNoPlaceholderChoice(t *testing.T) { t.Fatal(err) } a.teamOverlayChoose("") - row := plain(a.tabsRow(a.width)) - if len(a.teamMenuRows()) != 0 || a.wall.chip.pressable() || strings.Contains(row, "Teams ▾") { - t.Fatal("retained teams or an absent global manager left a placeholder picker") + // No disbanded team and no absent global manager is a choice; the one + // row left is `+ New from this session`, which is not a placeholder but + // the way to make a team from here (helpers.go). + rows := a.teamMenuRows() + if len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("retained teams or an absent global manager left a placeholder choice: %+v", rows) } a.openTeamMenu() - if a.teamMenu.on || len(a.teamMenuCard(a.width, a.height).rows) != 0 { - t.Fatal("empty overlay picker opens or draws a placeholder choice") + card := a.teamMenuCard(a.width, a.height) + if !a.teamMenu.on || len(card.hits) != 1 || card.hits[0].arg != wallPopHelpers { + t.Fatalf("the picker with no team to choose draws %+v", card.hits) } } @@ -330,9 +345,14 @@ func TestTeamMenuDismissesWhenRefreshRemovesItsLastChoice(t *testing.T) { a.teamAdopt(nil) } a.Update(nil) - if a.teamMenu.on || len(a.teamMenu.hits) != 0 { - t.Fatal("refresh left an invisible picker owning keyboard input") + // THE PICKER NEVER OWNS THE KEYBOARD INVISIBLY. Its last team + // choice gone, it still has `+ New from this session`, so it + // stays up and draws that row; esc gives the keys back. + menuFrame(t, a) + if a.teamMenu.on && (len(a.teamMenu.hits) != 1 || a.teamMenu.hits[0].arg != wallPopHelpers) { + t.Fatalf("refresh left a picker drawing %+v", a.teamMenu.hits) } + a.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) a.Update(tea.KeyPressMsg{Code: 'z', Text: "z"}) if !strings.Contains(a.input.String(), "z") { t.Fatal("typing did not return to the ordinary conversation") diff --git a/internal/tui3/teamoverlay_test.go b/internal/tui3/teamoverlay_test.go index 2fd43308c7..538871255d 100644 --- a/internal/tui3/teamoverlay_test.go +++ b/internal/tui3/teamoverlay_test.go @@ -382,7 +382,8 @@ func TestTeamOverlayRootUsesCurrentManagersAndProtectsAutomaticMemberships(t *te a.teamViewSet(root) a.teamOverlaySync() for _, row := range a.teamMenuRows() { - if row.code != wallPopTeam { + // The rule and `+ New from this session` edit no membership. + if !row.rule && row.code != wallPopTeam && row.code != wallPopHelpers { t.Fatal("root offers ordinary membership editing") } } diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index d8d84d7a05..9619fbcdee 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -552,6 +552,16 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te } } if t.Manager != "" && t.Manager == a.frontTabKey() { + // THE STRIP SHOWS THE TEAM WHOSE MEMBER JUST STARTED, when the person + // is in front of that team's manager: the new tab is on the strip + // because the strip is this team's. It is the same conversation in + // front and the same keyboard, so nothing moves the person's focus; + // only the overlay is set, the way choosing the team in the + // dropdown sets it. On the wall or a page the person is somewhere + // else, and the strip is left as it is. + if a.wall.activeID != t.ID && !a.wall.on && !a.pageShowing() && !t.Closed() { + a.teamViewSet(t.ID) + } a.feed.note(a.teamManagerMark() + " started @" + handle + " · its tab is on the strip") } a.touch() diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index 797dd862d2..d38c84ea1b 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -159,12 +159,13 @@ func TestWallKeepsTeamControlsOnTeamsPage(t *testing.T) { } // WHILE A TEAM NARROWS THE STRIP, THE STRIP SAYS WHICH: a chip at its left -// end, which opens the team switcher (teammenu.go). +// end, which opens the team switcher (teammenu.go). With none shown, and none +// made yet, the chip is the quiet `Teams ▾`, whose last row makes one. func TestTabTeamChipNamesTheShownTeam(t *testing.T) { a, _, _ := tabApp(t) plainRow := plain(a.tabsRow(a.width)) - if strings.Contains(plainRow, "▾") { - t.Fatalf("a chip with no team shown: %q", plainRow) + if !strings.Contains(plainRow, " Teams ▾ ") || strings.Contains(plainRow, "●") { + t.Fatalf("the chip with no team shown: %q", plainRow) } tabs := a.tabList() i, err := a.teamMake("harbor", tabs[:1]) diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 1a818ae35c..896c45d8d3 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -245,6 +245,7 @@ const ( wallPopConfirm = -3 // Delete, confirmed wallPopKeep = -4 // Keep, the delete undone wallPopManager = -6 // Make manager, or Remove manager, in the team shown + wallPopHelpers = -7 // + New from this session: /helpers in the box (helpers.go) ) // wallPopKind is which popover is up. From 661d51ccb1e7f347da2a9e49166aae37ae623c24 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:14:16 -0400 Subject: [PATCH 3/7] tui3: + New from this session only with no team selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a team's overlay on the strip, the Teams dropdown no longer offers `+ New from this session`, and /helpers refuses with `/helpers works with no team selected · clear with × first, or use + Add subteam on Teams for a team inside it`. The team /helpers makes is always top-level, and making it from inside another team's overlay read as nesting. Nesting keeps its own doors: + Add subteam on Teams, and a manager's sub-team start. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 14 ++++--- internal/manual/chat/team-manager.md | 5 ++- internal/manual/chat_test.go | 27 ++++++------ internal/tui3/chatsnavigation_test.go | 6 +-- internal/tui3/helpers.go | 15 +++++++ internal/tui3/helpers_test.go | 41 +++++++++++++++++++ internal/tui3/teammenu.go | 33 +++++++++++---- internal/tui3/teammenu_test.go | 9 +++- 9 files changed, 116 insertions(+), 36 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 1cc0fb649e..81300e0eed 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | -| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown, puts `/helpers` and that ask in the box; on home it says to open a conversation first | +| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown while no team is selected, puts `/helpers` and that ask in the box; with a team selected it refuses; on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 278ec769b0..e6081039cc 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -11,7 +11,7 @@ With no overlay, the strip shows **Teams ▾**, even before you have a team. Sel team chip and a separate **x**. Click the name to switch teams; click x to remove the overlay and restore ordinary Chats. Clearing selects **All teams** in Teams without changing membership or background work. The dropdown lists actual teams, with no **None** option, -and ends with **+ New from this session**, which makes a team of the conversation in front. +and, with no team selected, ends with **+ New from this session**, which makes a team of the conversation in front. The **▦ All** button still opens the independent conversation grid. The Teams dropdown is the only team selector in Chats; there are no membership buttons @@ -224,11 +224,13 @@ with one selected it shows that team's name, colour and a separate **x** to clea The dropdown offers **All teams** when a global manager exists, then the active team hierarchy. All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. -The picker has no None option or management actions. Its last row, under a rule, is always -**+ New from this session**: it puts `/helpers` and a default ask in the message box, replacing -what was there (that draft is kept in `/drafts`), and nothing happens until you edit it -and press enter (see *Turn this conversation into a team* on the team manager page). So the -chip is there even with no teams yet; it is absent only over `--host` to an engine without teams. +The picker has no None option or management actions. With **no team selected**, its last row, +under a rule, is **+ New from this session**: it puts `/helpers` and a default ask in the message +box, replacing what was there (that draft is kept in `/drafts`), and nothing happens until you +edit it and press enter (see *Turn this conversation into a team* on the team manager page). So +the chip is there even with no teams yet; it is absent only over `--host` to an engine without +teams. With a team selected the row is not offered: it makes a top-level team, and a team inside +another is `+ Add subteam` on Teams. Closed teams are available through Show closed on Teams, not through this picker. Selecting a team restores its conversation and horizontal strip position. A new overlay diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 5edd4a7ec8..6d6f56349a 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -72,8 +72,8 @@ handle choice may replace that fallback. ## Turn this conversation into a team — /helpers, + New from this session, convert a chat, bring in helpers You are working in a conversation, the work has grown, and you want helpers with this -conversation in charge. Open the **Teams ▾** dropdown at the left of the tab strip and choose -its last row, **`+ New from this session`**. It puts this in the message box, in place of +conversation in charge. With no team selected, open the **Teams ▾** dropdown at the left of +the tab strip and choose its last row, **`+ New from this session`**. It puts this in the message box, in place of what was there, and sends nothing: `/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` @@ -100,6 +100,7 @@ list, and it proposes again. Cancel, and it takes the team back with `team_disso - On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. - On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. - In a conversation that already manages a team: `this conversation already manages harbor · ask it for helpers`, and the strip shows that team. +- With a team selected on the strip: `/helpers works with no team selected · clear harbor with × first, or use + Add subteam on Teams for a team inside it`. The dropdown has no `+ New from this session` row then: the team it makes is always top-level. - On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. - Over `--host` to an engine without teams: `managers are not available over --host yet`. - While the first one is still being saved: `the team is still being saved`. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 95f3d47c92..c9f06a52ce 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4119,30 +4119,31 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { // /helpers: the questions a person in the middle of some work asks when they // want it to become a team, and the ones they ask about saying no, reach the -// sections that answer them by name, not just the manager's page. +// sections that answer them by name, not just the pages they are on. func TestHelpersQuestionsReachTheirSections(t *testing.T) { - for _, probe := range []struct{ question, title string }{ - {"how do I turn this conversation into a team", "Turn this conversation into a team"}, - {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, - {"bring in helpers for what I am working on", "Turn this conversation into a team"}, - {"what does + New from this session in the Teams dropdown do", "Turn this conversation into a team"}, - {"what does /helpers send to the model", "Turn this conversation into a team"}, - {"why is /helpers not offered here", "When /helpers is not offered"}, - {"/helpers says open one first", "When /helpers is not offered"}, - {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, - {"what does team_dissolve do", "Cancel a team nobody joined"}, + for _, probe := range []struct{ question, page, title string }{ + {"how do I turn this conversation into a team", "team-manager", "Turn this conversation into a team"}, + {"convert this chat to a team with it as the manager", "team-manager", "Turn this conversation into a team"}, + {"bring in helpers for what I am working on", "team-manager", "Turn this conversation into a team"}, + {"what does /helpers send to the model", "team-manager", "Turn this conversation into a team"}, + {"what does + New from this session in the Teams dropdown do", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why is /helpers not offered here", "team-manager", "When /helpers is not offered"}, + {"/helpers says open one first", "team-manager", "When /helpers is not offered"}, + {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, + {"what does team_dissolve do", "team-manager", "Cancel a team nobody joined"}, } { found := Chat().Search(probe.question, DefaultResults) reached := false where := make([]string, 0, len(found)) for _, section := range found { where = append(where, section.Page+" · "+section.Title) - if section.Page == "team-manager" && strings.Contains(section.Title, probe.title) { + if section.Page == probe.page && strings.Contains(section.Title, probe.title) { reached = true } } if !reached { - t.Errorf("%q does not reach team-manager · %q; it reached %v", probe.question, probe.title, where) + t.Errorf("%q does not reach %s · %q; it reached %v", probe.question, probe.page, probe.title, where) } } } diff --git a/internal/tui3/chatsnavigation_test.go b/internal/tui3/chatsnavigation_test.go index 224d7afd95..7bd7e39fd8 100644 --- a/internal/tui3/chatsnavigation_test.go +++ b/internal/tui3/chatsnavigation_test.go @@ -194,15 +194,15 @@ func TestTeamMenuLongTreeScrollsAndTinyResizeCannotChoose(t *testing.T) { if !a.teamMenu.card.holds(a.teamMenu.card.x0, a.teamMenu.card.y0) || len(a.teamMenu.hits) >= len(a.teamMenuRows()) { t.Fatal("long picker was not bounded") } - // The cursor counts the rows it can land on; the last is `+ New from this - // session`, under a rule that is a line and not a pick (teammenu.go). + // The cursor counts the rows it can land on. With harbor selected there + // is no `+ New from this session` (teammenu.go), so the last is a team. picks := teamMenuPicks(a.teamMenuRows()) for range len(picks) { drive(t, a, key("down")) menuFrame(t, a) } chosen := picks[a.teamMenu.cursor] - if chosen.code != wallPopHelpers { + if chosen.code != wallPopTeam || a.teamMenu.cursor != len(picks)-1 { t.Fatalf("the last row the cursor reaches is %+v", chosen) } hit := menuHit(t, a, chosen.code, chosen.id) diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index 7e1815a289..c240be952e 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -37,6 +37,12 @@ package tui3 // in place of whatever was there, and sends nothing: the person reads the // request, changes it into the team they want, and presses enter. // +// BOTH ARE FOR A CONVERSATION SEEN WITH NO TEAM SELECTED. With a team's +// overlay on the strip the row is absent and the command refuses, because the +// team it makes is a top-level team and making it from inside another team +// would read as nesting; a team inside a team is `+ Add subteam` on Teams, or +// a manager's own sub-team start. +// // THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the // manager acts on with `team_start`, which runs under the approval posture every // other start does, and each helper it starts opens as a tab in this team's @@ -76,6 +82,9 @@ const ( // helpersManagesWord is /helpers in a conversation that already manages a // team; the strip is narrowed to that team. helpersManagesWord = "this conversation already manages %s · ask it for helpers" + // helpersSelectedWord is /helpers with a team selected in the strip: a + // team made here is a top-level team, and subteams have their own door. + helpersSelectedWord = "/helpers works with no team selected · clear %s with × first, or use + Add subteam on Teams for a team inside it" // helpersPendingWord is a second /helpers while the first is being saved. helpersPendingWord = "the team is still being saved" // helpersMadeWord is the receipt, once the store has the team. @@ -139,6 +148,12 @@ func (a *app) helpersRefusal() (string, team) { if t, manages := a.teamManaged(tab.key); manages { return fmt.Sprintf(helpersManagesWord, t.Name), t } + // ONLY WITH NO TEAM SELECTED, the dropdown's row and the command alike + // ([app.teamMenuHelpers] says why): a team made here is a top-level team, + // and making it from inside another team's overlay would read as nesting. + if t, selected := a.teamActive(); selected { + return fmt.Sprintf(helpersSelectedWord, t.Name), team{} + } return "", team{} } diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 2bca725380..063301eca9 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -341,6 +341,7 @@ func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) typeLine(t, a, "/helpers") teamsFlush(t, a) made := helpersDisk(t, a)[0] + spend(t, a, a.teamOverlayChoose("")) a.input.insert("keep this") row, _ := helpersMenuRow(t, a) spend(t, a, a.teamMenuDo(row)) @@ -352,6 +353,46 @@ func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) } } +// WITH A TEAM SELECTED THERE IS NO `+ New from this session`, and /helpers +// refuses: the team it makes is a top-level team, and made from inside another +// team's overlay it would read as nesting. Nesting is `+ Add subteam` on Teams. +// Nothing is made, nothing is sent, the box is untouched; clearing the +// selection brings the row back. +func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { + a, fake := helpersApp(t) + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + for _, r := range a.teamMenuRows() { + if r.rule || r.code == wallPopHelpers { + t.Fatalf("with a team selected the dropdown offers %+v", a.teamMenuRows()) + } + } + a.openTeamMenu() + if frame, _ := menuFrame(t, a); strings.Contains(frame, helpersRowWord) { + t.Fatalf("with a team selected the dropdown draws %q:\n%s", helpersRowWord, frame) + } + a.closeTeamMenu() + + a.input.insert("/helpers") + drive(t, a, key("enter")) + teamsFlush(t, a) + if !transcriptSays(a, fmt.Sprintf(helpersSelectedWord, "harbor")) { + t.Fatal("/helpers with a team selected did not say why it refused") + } + if n := len(helpersDisk(t, a)); n != 1 || len(fake.sent) != 0 { + t.Fatalf("/helpers with a team selected made %d teams and sent %q", n, fake.sent) + } + + spend(t, a, a.teamOverlayChoose("")) + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("clearing the selection did not bring the row back last") + } +} + // THE HELPERS IT STARTS OPEN AS TABS IN THE TEAM'S OVERLAY. The manager's // team_start is carried out by this window behind the manager; the new // conversation is a member, held open, its tab on the strip, and the strip is diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 9e52e9f058..5236f1b074 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -17,12 +17,14 @@ import ( // outside closes the menu without changing the view. It says none of that in a // footer: a list of rows under a chip is read and worked the way every list is. // -// ITS LAST ROW IS ALWAYS `+ New from this session`, under a rule, which makes a -// team of the conversation in front by putting `/helpers` in the box -// (helpers.go). Because that row is always there, the chip is there too, with no -// team yet: `Teams ▾` is how a person with none finds how to make one from what -// they are doing. Over --host to an engine without the teams doors nothing here -// can work, and the row, and with no teams the chip, are absent. +// WITH NO TEAM SELECTED ITS LAST ROW IS `+ New from this session`, under a +// rule, which makes a team of the conversation in front by putting `/helpers` +// in the box (helpers.go). Because that row is there whenever no team is, the +// chip is there too with no team yet: `Teams ▾` is how a person with none finds +// how to make one from what they are doing. With a team selected the row is +// gone ([app.teamMenuHelpers] says why). Over --host to an engine without the +// teams doors nothing here can work, and the row, and with no teams the chip, +// are absent. // teamMenu is the switcher's state: whether it is up, the row the keyboard is // on, the row the pointer is on, and where the last frame drew it and its rows, @@ -66,9 +68,22 @@ func (a *app) teamMenuRows() []teamMenuRow { } // teamMenuHelpers reports whether the switcher carries `+ New from this -// session`: everywhere a team can be kept. Where /helpers would refuse for the -// conversation in front, the row is still there and says why when chosen. -func (a *app) teamMenuHelpers() bool { return !a.teamsOff() } +// session`: only while NO team is selected, wherever a team can be kept. +// +// WITH A TEAM SELECTED THE ROW IS NOT THERE AT ALL. A new team made from a +// conversation seen through a team's overlay reads as one made inside that +// team, and it is not: /helpers always makes a top-level team. Nesting has its +// own doors, `+ Add subteam` on Teams and a manager's own sub-team start, with +// their own rules, and this row must not look like a third. Where /helpers +// would refuse for the conversation in front for another reason, the row is +// still there and says why when chosen. +func (a *app) teamMenuHelpers() bool { + if a.teamsOff() { + return false + } + _, selected := a.teamActive() + return !selected +} // A picker with no available overlay is absent, including retained teams and // an optional global manager that has not been created. diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index 5069437c54..2c0f8c21f6 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -193,11 +193,16 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { t.Fatalf("management action %q remains in overlay picker", word) } } - // Its rows are the open teams and, last under a rule, `+ New from this + // With a team selected its rows are the open teams and nothing else. + if len(a.teamMenuRows()) != len(a.teamsOpenTree()) { + t.Fatal("picker has extra rows") + } + // With none selected they end, under a rule, with `+ New from this // session`, which puts /helpers in the box and edits no team (helpers.go). + spend(t, a, a.teamOverlayChoose("")) rows := a.teamMenuRows() if len(rows) != len(a.teamsOpenTree())+2 || !rows[len(rows)-2].rule || rows[len(rows)-1].code != wallPopHelpers { - t.Fatalf("picker has extra rows: %+v", rows) + t.Fatalf("with no team selected the rows are %+v", rows) } for _, r := range rows[:len(rows)-2] { if r.code != wallPopTeam { From 9d6b9bfcd30f989ac84502abd541aca0a9477c0a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:56 -0400 Subject: [PATCH 4/7] =?UTF-8?q?tui3:=20a=20=F0=9F=92=A1=20tip=20for=20/hel?= =?UTF-8?q?pers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/helpers makes this conversation the manager of a new team` joins the tips, after /task's. It is armed only where /helpers would act: after the first exchange, with no team selected, in a conversation that manages none, never on home. It retires the first time /helpers is typed or the Teams dropdown's `+ New from this session` is chosen. The table is twenty-five tips now, and the hints page says so. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/hints-and-tips.md | 8 +++-- internal/tui3/helpers.go | 17 ++++++++++ internal/tui3/helpers_test.go | 47 ++++++++++++++++++++++++++ internal/tui3/hometip_test.go | 9 ++--- internal/tui3/notice.go | 18 +++++++++- internal/tui3/notice_test.go | 1 + 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index e3a0fcea38..6f72c0e6c3 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -44,7 +44,7 @@ screen you pass through, so the tip worth saying differs: a conversation gets th urgent thing that is true right now, and home gets everything in turn. Home's row does not wait for quiet; a conversation's waits 15 seconds. -Both rows draw from **one list** of twenty-four tips (below), and using a gesture on +Both rows draw from **one list** of twenty-five tips (below), and using a gesture on either retires it on both. **A conversation's tip has moved several times.** It was the keys row's lowest rung until @@ -121,7 +121,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-four, one list for both boxes. Each one says the moment it first appears +There are twenty-five, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -150,6 +150,10 @@ build if the two disagree), so a tip you saw is on it word for word. it. It is the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. +- `/helpers makes this conversation the manager of a new team` — after the first exchange, + in a conversation with no team selected on the strip that does not already manage a team; + never on home. Retired the first time `/helpers` is typed or the Teams dropdown's + `+ New from this session` is chosen. - `/automations add sets one up exactly · or just say when, like every Monday at 9` — from the first minute on home and after the first exchange in a conversation. It is the other half of the `/automations` row above — the place lists them, and this is how one is made, diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index c240be952e..d57e9b6312 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -157,10 +157,26 @@ func (a *app) helpersRefusal() (string, team) { return "", team{} } +// helpersTipArmed is the 💡 tip's rule (notice.go): a conversation that has +// been spoken to, in front, with no team selected, not already a manager, and +// on a window that can keep teams and open helpers beside it. It reads memory +// only, because the table asks it at every event. +func helpersTipArmed(a *app) bool { + if a.turn < 1 || a.pageShowing() || a.teamsOff() || a.shared || !a.wall.loaded { + return false + } + if _, selected := a.teamActive(); selected { + return false + } + _, manages := a.teamManaged(a.frontTabKey()) + return !manages +} + // runHelpersCommand is /helpers: the team made, and the person's words queued // behind the store's answer. With no words after the command they are // [helpersPrompt], and the transcript shows them. func (a *app) runHelpersCommand(rest string) tea.Cmd { + a.noticeEvent(eventHelpersUsed) if why, managed := a.helpersRefusal(); why != "" { a.note(why) if managed.ID != "" { @@ -220,6 +236,7 @@ func (a *app) helpersAsked() tea.Cmd { // over it does (draftpage.go), so replacing it loses nothing. Where /helpers // would refuse, the row says so instead and leaves the box alone. func (a *app) helpersFill() tea.Cmd { + a.noticeEvent(eventHelpersUsed) a.closeTeamMenu() if a.wall.on { a.closeWall() diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 063301eca9..a8f4202107 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -472,3 +472,50 @@ func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { t.Fatal("the dropdown lost its last row") } } + +// THE 💡 TIP IS SAID WHERE /helpers WOULD ACT, AND ONLY THERE: after the first +// exchange, with no team selected, in a conversation that manages none; never +// before a word was said, never with a team on the strip, never in a manager. +// Using the command retires it for good. +func TestTheHelpersTipIsArmedWhereHelpersWouldAct(t *testing.T) { + var row notice + for _, n := range notices { + if n.id == "helpers-team" { + row = n + } + } + if row.id == "" || row.text != "/helpers makes this conversation the manager of a new team" || row.retire != eventHelpersUsed { + t.Fatalf("the /helpers tip is %+v", row) + } + a, _ := helpersApp(t) + a.turn = 0 + if row.armed(a) { + t.Fatal("the tip is armed before the first exchange") + } + a.turn = 1 + if !row.armed(a) { + t.Fatal("the tip is not armed in a spoken-to conversation with no team") + } + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + if row.armed(a) { + t.Fatal("the tip is armed with a team selected") + } + spend(t, a, a.teamOverlayChoose("")) + if !row.armed(a) { + t.Fatal("clearing the team did not arm the tip again") + } + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if !a.notices.retired(row.id) { + t.Fatal("using /helpers did not retire its tip") + } + spend(t, a, a.teamOverlayChoose("")) + if row.armed(a) { + t.Fatal("the tip is armed in a conversation that already manages a team") + } +} diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 3ac6cf46ab..6aa95ff8db 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -248,10 +248,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { // The cut was thirty, /project made it thirty-one, and three reads of the whole // list by the owner took it to twenty-two, and the way home by two spaces made // it twenty-three on 2026-09-24. The separate conversation-budget hint makes -// twenty-four. There is ONE set: every hint draws +// twenty-four, and /helpers made it twenty-five on 2026-10-19, at the owner's +// word. There is ONE set: every hint draws // on both boxes, a news row on neither, and a row filed under home's slot does // not build. -func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +func TestTheTableIsTwentyFiveHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -265,8 +266,8 @@ func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 24 { - t.Fatalf("the table holds %d hints, want 24 — the cut is deliberate, and the manual page counts them", hints) + if hints != 25 { + t.Fatalf("the table holds %d hints, want 25 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 72e84f7796..4923b2c70f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -197,6 +197,9 @@ const ( // are doors a person already knows, and the tip is about the one they // cannot see. eventHomeGesture = "home-gesture" + // eventHelpersUsed is /helpers reaching its command, bare or with words, + // or the Teams dropdown's `+ New from this session` chosen (helpers.go). + eventHelpersUsed = "helpers-used" ) // noticeEvents is every event there is, in one list, so the table check can @@ -211,7 +214,7 @@ var noticeEvents = []string{ eventConversationBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSubharnessOpened, - eventConnectOpened, eventAutonomyAsked, eventHomeGesture, + eventConnectOpened, eventAutonomyAsked, eventHomeGesture, eventHelpersUsed, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -422,6 +425,19 @@ var notices = []notice{ text: "/task starts a single-shot task on the side", retire: eventTaskTyped, }, + { + // THE BIGGER HALF OF HANDING WORK OUT, at the owner's word on + // 2026-10-19: /task sends one piece of the work away, and /helpers + // keeps this conversation and makes it the manager of a team for the + // rest (helpers.go). It is armed only where /helpers would act: a + // conversation that has been spoken to, no team selected on the strip, + // and not already a manager ([helpersTipArmed]). It retires the first + // time /helpers is typed or the Teams dropdown's row is chosen. + id: "helpers-team", slot: slotHint, + armed: helpersTipArmed, + text: "/helpers makes this conversation the manager of a new team", + retire: eventHelpersUsed, + }, { // THE OTHER HALF OF THE SAME LESSON: an automation is made by typing it // exactly or by saying what you want and when, and the card shows the diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index e3e120dc74..b3c65c3655 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -753,6 +753,7 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, + eventHelpersUsed: func(t *testing.T, a *app) { a.slash("/helpers") }, eventHomeGesture: func(t *testing.T, a *app) { // A door that can open a conversation is what opens home at all // ([app.homeDoorOpen]); the bare test app has none. From 4becb93520cc4fce28c35d4f9a0a19dbb86ece3f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:56 -0400 Subject: [PATCH 5/7] session: a conversation that has gone on may suggest /helpers, once After the person's fourth message, an interactive conversation in no team is told once, in a note of its own, that it may end a reply with one sentence suggesting a team when the work is long and has parts a few helpers could take on at once: "Want to bring in some helpers? Type /helpers and I'll suggest who." Only the person can make the team; the note says so, and says never for small work, never instead of the work, and never again once suggested or declined. It is a note and not a paragraph of prompts/system.md because the page is the fixed prefix every request pays for, held under a budget that only shrinks, and the suggestion only fits a conversation that has gone on. Tasks' workers, automation runs, home's ask here and conversations already in a team are never told. The team manager page says why the chat suggested it. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/team-manager.md | 15 +++ internal/manual/chat_test.go | 2 + internal/session/agent.go | 7 +- internal/session/helpersnote.go | 74 ++++++++++++++ internal/session/helpersnote_test.go | 144 +++++++++++++++++++++++++++ internal/session/session.go | 3 + 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 internal/session/helpersnote.go create mode 100644 internal/session/helpersnote_test.go diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 6d6f56349a..87042e9e6c 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -110,6 +110,21 @@ the strip goes back, the reason is said, and nothing is sent. If you leave the conversation before the save finishes, the team stands and the message is not sent: `made the team lab · its manager is the conversation you left; ask it for helpers there`. +## Why the chat suggested bringing in helpers or /helpers + +A conversation that has gone on may end a reply with one sentence like "This could be +split up and worked on in parallel by a small team. Want to bring in some helpers? Type +/helpers and I'll suggest who." That is codeaf letting it say so: after your fourth message, +a conversation in no team is told, once, that it may suggest a team when the work is long +and has parts a few helpers could work on at once. It is told to say it at most +once, never for small or quick work, never instead of what you asked, and never again +after you pass on it. Reopening a long conversation tells it once more, and it can see in +its history that it already asked. + +It cannot make the team itself; only you can, with `/helpers` or `+ New from this session` +in the Teams dropdown. Ignore it, or say no, and nothing changes. A conversation already in +a team, a task's worker, an automation's run and home's `ask here` are never told this. + ## Cancel a team nobody joined — team_dissolve Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c9f06a52ce..2d1cd63306 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4130,6 +4130,8 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, {"why is /helpers not offered here", "team-manager", "When /helpers is not offered"}, {"/helpers says open one first", "team-manager", "When /helpers is not offered"}, + {"why did the chat suggest bringing in helpers", "team-manager", "Why the chat suggested bringing in helpers"}, + {"the model asked if I want a small team working in parallel", "team-manager", "Why the chat suggested bringing in helpers"}, {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, {"what does team_dissolve do", "team-manager", "Cancel a team nobody joined"}, } { diff --git a/internal/session/agent.go b/internal/session/agent.go index 9f020618dc..b91967218a 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2857,6 +2857,10 @@ func (a *Agent) landVolatileLocked() { // read made before this request ([Agent.teamBoundary]) rather than beside // the work, so it is never a step late (team.go's [teamRoleNoteOpening]). a.landTeamRoleLocked() + // AND, ONCE, IN A CONVERSATION IN NO TEAM THAT HAS GONE ON, leave to + // suggest /helpers (helpersnote.go). After the role, which is what says + // whether there is a team. + a.landHelpersNoteLocked() a.landNoteLocked(volatileNoteOpening, a.volatileBlockLocked()) // AND A MANAGER'S TEAM, in a note of its own: a team moves whenever a member // does, and riding the card's note @@ -2926,7 +2930,8 @@ func isVolatileNote(text string) bool { return strings.HasPrefix(text, volatileNoteOpening) || strings.HasPrefix(text, bashBeltFrameOpening) || strings.HasPrefix(text, teamNoteOpening) || - strings.HasPrefix(text, teamRoleNoteOpening) + strings.HasPrefix(text, teamRoleNoteOpening) || + strings.HasPrefix(text, helpersNoteOpening) } // mayBashBelt is [Config.mayBashBelt] asked of a live agent, so that the diff --git a/internal/session/helpersnote.go b/internal/session/helpersnote.go new file mode 100644 index 0000000000..0047698704 --- /dev/null +++ b/internal/session/helpersnote.go @@ -0,0 +1,74 @@ +package session + +// THE ONE NOTE THAT LETS A CONVERSATION SUGGEST /helpers. +// +// A person deep in a piece of work can turn it into a team effort with the +// conversation in charge: the chat's `/helpers` makes a new team with this +// conversation as its manager (internal/tui3's helpers.go). Nobody finds a +// command they have never heard of in the middle of their work, and the model, +// which can see the work growing, is the one placed to mention it. So a +// conversation is told, once, that it may. +// +// IT IS A NOTE AND NOT A PARAGRAPH OF THE PAGE, for two reasons that are one. +// The page (prompts/system.md) is the fixed prefix every request of every turn +// of every conversation pays for, and it is held under a budget that only +// shrinks (prefixbudget_test.go); a sentence most conversations never act on +// does not belong there. And the suggestion only fits a conversation that has +// gone on: a note that lands after [helpersNoteAfter] things the person has +// said costs nothing in the short exchanges that are most of them, and arrives +// in the long ones, which are the ones whose work may have outgrown a +// conversation. +// +// IT IS ONCE, AND SAID AS RARE. It lands at most once in a process, never in a +// conversation that already has a part in a team (its role note says what it +// is), and its words make the suggestion a single sentence at the end of a +// reply, never repeated, never for small work and never instead of the work. +// Only the person can make the team; the note says so, because a model that +// thought it could would promise one and fail. +// +// IT IS FOR A CONVERSATION A PERSON SITS IN, ON THE SURFACE THAT HAS THE +// COMMAND ([Config.mayOfferHelpers]): not a task's node or worker, not an +// automation's run, not home's short `ask here` exchange. + +// helpersNoteOpening is the note's identity: [isVolatileNote] tells it from +// the person's own words by it, and [Agent.lastNoteLocked] finds it by it. +const helpersNoteOpening = "Instructions from codeaf, not from the person: when to suggest helpers." + +// helpersNoteAfter is how many messages the person has sent before the note +// lands: past a quick question and its follow-ups, into work that is going on. +const helpersNoteAfter = 4 + +// helpersNoteBody is what the note says. +const helpersNoteBody = "If the work here has grown long, with parts that could be worked on at once by a few helpers, " + + "you may suggest a team once, briefly, at the end of a reply, in words like: " + + "\"This could be split up and worked on in parallel by a small team. Want to bring in some helpers? " + + "Type /helpers and I'll suggest who.\" " + + "Only the person can make the team, with /helpers; this conversation then becomes its manager. " + + "Not for small or quick work, never instead of doing what was asked, and never again once you have suggested it or they passed." + +// mayOfferHelpers says whether this conversation may be told it can suggest +// /helpers: one a person is sitting in ([Config.Interactive], which the +// surface sets and an automation's run clears), that is not a task's node or +// worker, not home's short `ask here` exchange, and that can be in a team at +// all ([Config.teamProfile]). +func (c Config) mayOfferHelpers() bool { + return c.Interactive && !c.InTask && c.taskID == 0 && c.AutomationRun == nil && !c.Errand && c.teamProfile() != "" +} + +// landHelpersNoteLocked lands the note when this conversation has earned it +// and has not had it. The caller holds a.mu, at the step boundary +// ([Agent.landVolatileLocked]), after the team role has been composed, so a +// conversation that is in a team is known to be one. +func (a *Agent) landHelpersNoteLocked() { + if a.helpersNoted || a.personSeq < helpersNoteAfter || !a.config.mayOfferHelpers() { + return + } + if a.teamRoleText != "" || a.lastNoteLocked(teamRoleNoteOpening) != "" { + return + } + a.helpersNoted = true + if a.lastNoteLocked(helpersNoteOpening) != "" { + return + } + a.landNoteLocked(helpersNoteOpening, helpersNoteBody) +} diff --git a/internal/session/helpersnote_test.go b/internal/session/helpersnote_test.go new file mode 100644 index 0000000000..1b864cc223 --- /dev/null +++ b/internal/session/helpersnote_test.go @@ -0,0 +1,144 @@ +package session + +// THE /helpers NOTE, AS TESTS: a conversation a person sits in, in no team, is +// told once — after it has gone on — that it may suggest /helpers; and nobody +// else is told at all. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/manual" +) + +// helpersNotes is every /helpers note in one request, in order. +func helpersNotes(messages []ai.Message) []string { + var notes []string + for _, message := range messages { + if text := messageText(message); message.Role == "user" && strings.HasPrefix(text, helpersNoteOpening) { + notes = append(notes, text) + } + } + return notes +} + +// loneConversation is a transcript beside the fixture's team and in no team +// of its own: an ordinary conversation on the same profile. +func loneConversation(t *testing.T, fixture teamFixture) string { + t.Helper() + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.manager)), "lone") + 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) + } + return path +} + +// interactive is a conversation a person is sitting in, as the chat door +// builds one. +func interactive(config *Config) { config.Interactive = true } + +// THE NOTE LANDS ONCE, AFTER THE PERSON HAS SAID [helpersNoteAfter] THINGS, and +// stays where it landed: the requests before carry none, and every request +// after carries the one note, never a second. It is the session's note and +// not the person's words, and it names the command and no tool. +func TestTheHelpersNoteLandsOnceAfterTheConversationHasGoneOn(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 2 + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + for i := 0; i < turns; i++ { + notes := helpersNotes(completer.request(i)) + want := 0 + if i >= helpersNoteAfter-1 { + want = 1 + } + if len(notes) != want { + t.Fatalf("request %d carried %d /helpers notes, want %d:\n%s", i, len(notes), want, userTextIn(completer.request(i))) + } + } + note := helpersNotes(completer.request(turns - 1))[0] + if !isVolatileNote(note) { + t.Fatal("the note would be drawn as something the person typed") + } + for _, want := range []string{"/helpers", "Only the person can make the team", "once", "Not for small or quick work"} { + if !strings.Contains(note, want) { + t.Errorf("the note does not say %q:\n%s", want, note) + } + } + if strings.Contains(note, "team_") { + t.Errorf("the note names a tool the conversation does not have:\n%s", note) + } +} + +// THE MANUAL SAYS "after your fourth message" (team-manager.md, *Why the chat +// suggested bringing in helpers*). Move the threshold and that sentence moves +// with it, in the same change. +func TestTheHelpersNoteThresholdIsTheOneTheManualStates(t *testing.T) { + const manualSays = 4 + if helpersNoteAfter != manualSays { + t.Fatalf("the note lands after %d messages and the manual says the fourth; change both", helpersNoteAfter) + } + if !manual.Chat().Mentions("after your fourth message") { + t.Fatal("the manual no longer says when the note lands") + } +} + +// A CONVERSATION IN A TEAM IS NEVER TOLD: its role note says what it is, and +// a manager suggesting a second team would be the nesting /helpers refuses. +func TestAConversationInATeamIsNeverToldToSuggestHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 1 + for _, transcript := range []string{fixture.manager, fixture.web} { + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, transcript, completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(turns - 1)); len(notes) != 0 { + t.Fatalf("%s, in a team, was told to suggest helpers:\n%s", filepath.Base(filepath.Dir(transcript)), notes[0]) + } + } +} + +// NOBODY BUT A CONVERSATION A PERSON SITS IN IS TOLD: a headless one, an +// automation's run, home's `ask here` exchange, a task's node, and one with no +// profile a team could be kept in. +func TestOnlyAConversationAPersonSitsInMayOfferHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + base := Config{Interactive: true, ProfileDir: fixture.profile, SessionFile: loneConversation(t, fixture)} + if !base.mayOfferHelpers() { + t.Fatal("an interactive conversation on a profile may not offer helpers") + } + for name, change := range map[string]func(*Config){ + "headless": func(c *Config) { c.Interactive = false }, + "automation run": func(c *Config) { c.AutomationRun = &AutomationRun{} }, + "ask here": func(c *Config) { c.Errand = true }, + "task node": func(c *Config) { c.InTask = true }, + "no transcript": func(c *Config) { c.SessionFile = "" }, + } { + config := base + change(&config) + if config.mayOfferHelpers() { + t.Errorf("a %s conversation may offer helpers", name) + } + } + + completer := oneAnswer(helpersNoteAfter + 1) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, nil) + for i := 0; i <= helpersNoteAfter; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(helpersNoteAfter)); len(notes) != 0 { + t.Fatal("a headless conversation was told to suggest helpers") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index dfc0666a25..87a8c5606d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2968,6 +2968,9 @@ type Agent struct { // [teamRoleBlock]), set at every step boundary before the request and // landed in a note of its own ahead of the others. teamRoleText string + // helpersNoted says the one note that lets this conversation suggest + // /helpers has landed in this process (helpersnote.go). + helpersNoted bool // team is this conversation's account of its teams: its key, its roles and // its Traffic cursors (team.go). It has its own lock and is never read // under mu. From a224985d43f84f470a7cd76a6eeffeac8e3f26ae Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:15:33 -0400 Subject: [PATCH 6/7] session: a member a manager started wakes with its brief again On an ordinary launch every team edit goes through the engine's teams door, which stamps a new member's join time. A member started by team_start always joins after the start that made it was written, and the join bound in the Traffic delivery dropped that start: nothing was addressed to the member, the start watch never woke it, and helpers sat idle while the manager reported them running. The unit test missed it because it added the member with no join time. A member's own start, addressed to its handle within the start grace, is now exempt from the bound; every other line from before the join is still withheld, which the new test also holds. The default /helpers ask no longer says "Don't start anyone until I agree", which the memory that notices rules kept as a standing project rule. It now says what happens next in this exchange: "I'll answer yes, ask for changes, or cancel." Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/team-manager.md | 2 +- internal/session/team.go | 19 ++++++++++- internal/session/team_wake_test.go | 49 ++++++++++++++++++++++++++++ internal/tui3/helpers.go | 15 ++++++--- internal/tui3/helpers_test.go | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 87042e9e6c..a62004dcf7 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -76,7 +76,7 @@ conversation in charge. With no team selected, open the **Teams ▾** dropdown a the tab strip and choose its last row, **`+ New from this session`**. It puts this in the message box, in place of what was there, and sends nothing: -`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` +`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel.` Edit the words into the team you want, then press enter; typing `/helpers` yourself does the same, and a bare `/helpers` sends exactly that default. Two things happen: diff --git a/internal/session/team.go b/internal/session/team.go index f183a90449..207b7f75ce 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -669,7 +669,7 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { var lines []string for _, entry := range entries { cursor = entry.ID - if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) { + if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) && !a.teamOwnStart(role, entry) { continue } if line := a.teamEntryLineLocked(profile, role, entry); line != "" { @@ -718,6 +718,23 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { // it, because the start carries the brief, and the brief is handed to the new // member here, marked as the manager's, on its first request ([teamBriefLine]). // The person never typed it, so it must not arrive as the person's message. +// teamOwnStart reports whether entry is the start that made this +// conversation: a manager's start addressed to its handle in the team, inside +// [teamStartGrace] of when this process began. It is the one line a member is +// owed from before it joined, the line [Agent.firstTeamCursor] reaches back +// for, and the join bound in [Agent.teamNewsLocked] must let it through. +// +// THE JOIN IS ALWAYS AFTER THE START. The manager writes the start, the +// interface opens the conversation on reading it, and only then makes it a +// member; and the engine's teams door stamps a new member's join with the time +// it took the edit (internal/remote's teams.go). A bound that took the start +// too handed every started member nothing, so none of them ever woke +// (team_wake.go), and a manager reported helpers running that were idle. +func (a *Agent) teamOwnStart(role teamRole, entry teams.Entry) bool { + return !role.manager && role.handle != "" && entry.Kind == teams.KindStart && entry.To == role.handle && + !entry.At.Before(a.startedAt.Add(-teamStartGrace)) +} + func (a *Agent) firstTeamCursor(profile string, role teamRole) string { tail, err := teams.ReadTraffic(profile, role.id, "", teamFirstLook) if err != nil { diff --git a/internal/session/team_wake_test.go b/internal/session/team_wake_test.go index 2aa8001baf..5ea17af6a1 100644 --- a/internal/session/team_wake_test.go +++ b/internal/session/team_wake_test.go @@ -65,6 +65,55 @@ func TestAStartedMemberWakesOnItsOwnWithTheBriefOnce(t *testing.T) { } } +// AND IT WAKES WHEN THE STORE STAMPED ITS JOIN, as every ordinary launch's +// does. The engine's teams door gives a new member the time it joined +// (internal/remote's teams.go), and a member a manager started always joins +// after the start that made it was written. The join bound keeps a membership +// from being handed lines from before it began; its own start is the one line +// it is owed from before, and the bound must not take that away. It used to: +// the brief was filtered out, nothing was addressed to the member, and every +// helper a manager started sat idle until the person asked why. +// +// A DIRECTIVE FROM BEFORE THE JOIN IS STILL NOT HANDED OVER: the exemption is +// the start alone. +func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { + fixture := newWakingTeamFixture(t) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "Rewrite the lexer."}) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "lexer", Text: "Also delete the parser."}) + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.parser)), "lexer") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, placeTranscript) + if err := os.WriteFile(transcript, nil, 0o600); err != nil { + t.Fatal(err) + } + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("on it"), nil }, + }} + teamAgent(t, fixture, transcript, completer, nil) + time.Sleep(10 * time.Millisecond) + if err := teams.Update(fixture.profile, func(file *teams.File) error { + return file.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, transcript), File: transcript, Handle: "lexer", JoinedAt: time.Now()}) + }); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for completer.requests() == 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if completer.requests() == 0 { + t.Fatal("a started member whose join was stamped never took its first turn") + } + first := userTextIn(completer.request(0)) + if !briefIn(first, "Rewrite the lexer.") { + t.Fatalf("the first request did not carry the brief:\n%s", first) + } + if strings.Contains(first, "Also delete the parser.") { + t.Fatalf("a directive from before the join was handed over:\n%s", first) + } +} + // WITH THE TEAM'S AUTO-WAKE OFF, A START OPENS THE MEMBER AND STARTS NO TURN. // The brief stays in the Traffic and is handed over on the first turn something // else starts, and the Traffic says why nothing ran. diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index d57e9b6312..eebcef2c7c 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -64,10 +64,17 @@ import ( // helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts // in the box after `/helpers `. It is the person speaking, in words they would // use, because it is their message and it is on their screen: it names no tool -// and nothing the person could not have typed. Its three asks are the three -// things the person needs from the manager: a proposal to read, nobody started -// before they agree, and a way to say no. -const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team." +// and nothing the person could not have typed. It asks for a proposal to read +// and says what happens next — the person answers yes, a change, or cancel — +// which is what keeps the manager from starting anyone before they agree. +// +// IT DESCRIBES THIS EXCHANGE AND STATES NO STANDING RULE. It used to end +// "Don't start anyone until I agree, and if I say cancel, undo the team.", and +// the memory that notices rules (internal/session's memory) kept that as one +// for the whole project: a person's standing order never to start helpers +// without asking, read by every manager after. Said as the next step of this +// one conversation, there is nothing in it to keep. +const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel." // The words this door says. Each is quoted in the manual exactly as spelled. const ( diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index a8f4202107..4bd2778101 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -134,7 +134,7 @@ func TestABareHelpersShowsTheAskItSends(t *testing.T) { t.Errorf("the default ask says %q, which is codeaf's word and not the person's: %q", internal, helpersPrompt) } } - for _, want := range []string{"two or three helpers", "Don't start anyone until I agree", "cancel"} { + for _, want := range []string{"two or three helpers", "I'll answer yes", "cancel"} { if !strings.Contains(helpersPrompt, want) { t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) } From 898f5ac545c6c8b1460c9b462ae0d67c6e4a47d2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:23:18 -0400 Subject: [PATCH 7/7] docs: change entry for /helpers (#1881) Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1881-helpers.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/1881-helpers.md diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md new file mode 100644 index 0000000000..d7ad349785 --- /dev/null +++ b/docs/changes/unreleased/1881-helpers.md @@ -0,0 +1,13 @@ +--- +kind: added +title: /helpers makes the conversation you are in a new team's manager, which proposes helpers +pr: 1881 +surface: [chat, engine, docs] +invalidates: + - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. `/helpers` makes a new top-level team with the conversation in front as its manager, in one save, then sends exactly the words after it (bare, a default ask shown in full) as the person's message." + - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`, which puts `/helpers` and the default ask in the box. With a team selected the row is absent and `/helpers` refuses: the team it makes is top-level, and nesting is + Add subteam." + - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." + - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. A member's own start is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld." + - "There are twenty-five tips, not twenty-four: `/helpers makes this conversation the manager of a new team`." + - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest /helpers when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." +---