diff --git a/docs/changes/unreleased/1485-one-task-page.md b/docs/changes/unreleased/1485-one-task-page.md new file mode 100644 index 000000000..6fb3d6d41 --- /dev/null +++ b/docs/changes/unreleased/1485-one-task-page.md @@ -0,0 +1,18 @@ +--- +kind: changed +title: Every task opens the one task room, on either engine +pr: 1485 +surface: [chat, docs] +invalidates: + - A run's task opened a separate store-backed page (taskSheetPlan, taskPlanFrame); it now opens the task room, read from the store. + - The run's page had no transcript or work tab; every task room now has both, switched with tab over an empty box. + - The run's page took p to hold a part and cancelled a part directly; in the room p is a letter and x raises the stop card. + - Tokens and the model were said not to be in the plan store; the room's head reads both from the spend ledger. + - The tasks place's enter and the run's tab opened the plan page; both open the task room now. +--- + +A task on the run engine now opens the same task room as any other task. Its transcript is +the worker's steps drawn as the room's own calls, its work tab is the run's working copy +difference, its box leaves a note on the task, and `x` stops it. The head shows the model +and tokens when the store's ledger records them. Every task room gains `transcript` and +`work` tabs. diff --git a/internal/e2e/tui_e2e_test.go b/internal/e2e/tui_e2e_test.go index b863096ef..44adc00f7 100644 --- a/internal/e2e/tui_e2e_test.go +++ b/internal/e2e/tui_e2e_test.go @@ -2204,7 +2204,7 @@ func testTaskOnTheDefaultBelt(t *testing.T) { // taskOnTheRunEngine is the body the two subtests above share: launch with the // key and whatever belt words the caller names, put one `/task` on the run -// engine, and read the run off the tasks place, the plan page and the thread. +// engine, and read the run off the tasks place, the task's room and the thread. func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { home := newHome(t, nil) ws := newWorkspace(t, "runws", false) @@ -2235,15 +2235,12 @@ func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { running := planRowWaitsToWear(t, r, 40*time.Second, runRowWord, say(t, "planRunningWord")) t.Logf("the run on the tasks place, while a worker holds its task:\n%s", running) - // ── the page mid-run: the live step at the live edge ──────────────────── + // ── the room mid-run ──────────────────────────────────────────────────── // - // ENTER OPENS THE PLAN PAGE, and while a worker holds the task the page - // follows its live edge: the step being run right now is drawn ONE STEP EARLY, - // with the running glyph beside the command and the call's own clock under it - // (docs/design/worker-harness/SURFACE.md §4, Cell 3; taskplan.go's - // taskPlanBody). It is read here, before the root lands, because the live step - // is gone the moment its command ends — the page after the landing is the - // settled page the section below reads. + // ENTER OPENS THE TASK'S ROOM, the one page every task opens, and while a + // worker holds the task the room follows it on its own beat (internal/tui3's + // planroom.go). It is read here, before the root lands; the room after the + // landing is the settled room the section below reads. // // AND THE CURSOR IS MOVED ONTO THE ROW FIRST ([tasksPlaceRunRow]). Enter on // the conversation row is the door into the conversation and always was, so @@ -2252,29 +2249,27 @@ func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { // conversation too. tasksPlaceRunRow(t, r) r.keys("Enter") - // THE PAGE COMING UP IS THE ASSERTION AND THE LIVE STEP IS AN OBSERVATION, - // and the two are separated here because only one of them is a fact about - // the surface. That the press over the row opens the STORE'S page rather - // than a room is true every time, and the page's own note box says it is - // the page. Which command a worker happens to be part-way through when the - // key lands is a moment: this brief is four steps and about half a minute, - // so a page opened a second after the last one ends is an honest page of a - // task that has finished — [rig.glimpse]'s own bargain, which never fails - // on a thing that is only on screen while work runs. Asserting it made a - // red out of a fast run and said nothing about any defect. - page := r.waitFor(40*time.Second, say(t, "planNoteBoxWord")) - t.Logf("the page the press over the run's row opened:\n%s", page) + // THE ROOM COMING UP IS THE ASSERTION: its two tabs are there whatever state + // the task is in. The note box's own words are there only while the task can + // still take a note, and which command a worker happens to be part-way + // through when the key lands is a moment, so both are only logged. + page := r.waitFor(40*time.Second, say(t, "roomTabsWords")) + t.Logf("the room the press over the run's row opened:\n%s", page) + if box, saw := r.glimpse(2*time.Second, say(t, "planNoteBoxWord")); saw { + t.Logf("the room's box takes a note while the task runs:\n%s", box) + } else { + t.Logf("the task had ended before its room opened, so its box names another door") + } if live, sawLive := r.glimpse(15*time.Second, say(t, "planLiveGlyph")); sawLive { - if !strings.Contains(live, say(t, "planLiveClockWord")) { - t.Errorf("the plan page's live line has no clock under it (%q):\n%s", - say(t, "planLiveClockWord"), live) - } - t.Logf("the plan page mid-run, carrying the live step:\n%s", live) + t.Logf("the room mid-run, carrying the live step:\n%s", live) } else { - t.Logf("the run finished before a live step could be caught on the page, which is this " + + t.Logf("the run finished before a live step could be caught in the room, which is this " + "brief on a fast worker and not a defect") } + // `esc` LEAVES THE ROOM FOR THE CONVERSATION, the way every room does, so + // the place is opened again to watch the row land. r.keys("Escape") + openTasksPlace(t, r) done := planRowWaitsToWear(t, r, runPatience, runRowWord, say(t, "planDoneWord")) t.Logf("the run on the tasks place once its root landed:\n%s", done) @@ -2295,20 +2290,16 @@ func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { t.Errorf("the landing does not name the branch the run's work is on (%q):\n%s", branch, landed) } - // ── the page one row opens ────────────────────────────────────────────── + // ── the room one row opens ────────────────────────────────────────────── // - // ENTER OVER THE ROW OPENS THE STORE'S OWN PAGE — the description the worker - // was given, the notes left on the task, and the trajectory: one line per - // command the worker ran, and the run's own finish among them. `esc` backs out - // one layer to the list, the card's own bargain. + // ENTER OVER THE ROW OPENS THE TASK'S ROOM, read through the store: the + // description the worker was given, the notes left on the task, and the + // trajectory as the room's own call rows. `esc` leaves for the conversation. openTasksPlace(t, r) tasksPlaceRunRow(t, r) r.keys("Enter") - // THE PAGE IS READ BY TWO OF ITS OWN WORDS, and neither is the model's. The - // note box stands on this page and on nothing else, and the head's figures - // are the store's count of the steps its worker took and what they cost — - // so the pair says the press opened THE STORE'S PAGE and not the room a - // record row opens, which is the whole of what this scenario came to prove. + // THE ROOM IS READ BY ITS OWN WORDS, and not by the model's: the + // room's two tabs, which every task's room draws on either engine. // // WHICH COMMANDS ARE ON IT IS THE WORKER'S BUSINESS. The finish is the // worker's own `plandb done` when the worker writes one, and the RUN's when @@ -2317,8 +2308,8 @@ func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { // page carried `echo`, `cat` and the run's own ending note. So the finish // command is observed and logged, never waited out: asserting it made a red // out of a model's choice and said nothing about the surface. - stored := r.waitFor(40*time.Second, say(t, "planNoteBoxWord"), say(t, "planStepsSpend")) - t.Logf("the page the run's row opens, with the store's own figures on it:\n%s", stored) + stored := r.waitFor(40*time.Second, say(t, "roomTabsWords")) + t.Logf("the room the run's row opens:\n%s", stored) if finish, saw := r.glimpse(5*time.Second, say(t, "planFinishCommand")); saw { t.Logf("and this worker wrote its own finish into the trajectory:\n%s", finish) } else { @@ -2326,8 +2317,9 @@ func taskOnTheRunEngine(t *testing.T, rigName string, beltWords ...string) { say(t, "planFinishCommand")) } r.keys("Escape") - back := r.waitFor(30*time.Second, say(t, "planDoneWord")) - t.Logf("esc backed out of the page to the list:\n%s", back) + openTasksPlace(t, r) + back := planRowWaitsToWear(t, r, 30*time.Second, runRowWord, say(t, "planDoneWord")) + t.Logf("the list, after esc left the room:\n%s", back) r.quit() } diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 473738ae4..ea31e38e5 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -637,39 +637,38 @@ var tuiWords = map[string]tuiWord{ source: "plandb done", pkg: "internal/session", why: "the command a bash-belt worker finishes its store task with, recorded in the task's own " + - "trajectory and drawn as a step line on the plan page (taskplan.go's taskPlanBody reads " + + "trajectory and drawn as a call row in the task's room (planroom.go reads " + "PlanTaskPage.Steps). THE PAGE DOES NOT SPELL IT — the worker runs it — so the gate looks where " + "it is written: internal/session's plandb_plan.go, the sentence that teaches the finish. " + "IT IS OBSERVED AND NEVER WAITED OUT, because whether it is on a page is the WORKER'S " + "choice: the run writes the ending itself for a task whose worker stopped calling tools " + "without writing one (internal/run's worker.go), which a small brief on a fast model " + - "regularly is. What the suite asserts about that page instead is that it is the store's " + - "page at all — planNoteBoxWord and planStepsSpend, two words no room draws", + "regularly is. What the suite asserts about that room instead is that it is the task's " + + "room at all: roomTabsWords", }, "planNoteBoxWord": { screen: "a note for this task", - why: "the plan page's own note box (taskplan.go's taskPlanNoteWord), and the one word on it " + - "that is there whatever state the task is in. It is what says the press over a run's row " + - "opened THE STORE'S PAGE rather than a room — the assertion that defect #1359 was about — " + - "where the live step beside it is a moment and is only ever observed", + why: "the note box a run's task takes its notes in (taskplan.go's taskPlanNoteWord, drawn as " + + "the task room's steer box by room.go) while the task can still take a note. A task that has " + + "ended names another door in its box, so the suite only observes this word; what it asserts " + + "about the room is roomTabsWords", + }, + "roomTabsWords": { + screen: "transcript · work", + source: "transcript", + why: "the two tabs every task's room draws on its trail, on either engine (roomtabs.go): the " + + "transcript and the work the task changed. A run's task opens the same room as any other " + + "task, so the tabs are what say the press opened the room and not a page of its own", }, "planLiveGlyph": { screen: tokens.GlyphStepRunning, pkg: tokensPkg, - why: "the mark the plan page leads the step it is running RIGHT NOW with, where the recorded " + - "steps lead with their number (taskplan.go's taskPlanBody, drawn through palette.glyph so the " + - "tier picks the rune rather than a literal in the surface). It is tokens.GStepRunning — one " + + why: "the mark the task's room leads the step it is running RIGHT NOW with (planroom.go draws " + + "the store's live step as a call in flight, through palette.glyph so the tier picks the rune " + + "rather than a literal in the surface). It is tokens.GStepRunning — one " + "shape for one state, the same rune `working` wears — so the gate looks where the rune is " + "spelled, and this suite sees the plain tier its throwaway profile pins", }, - "planLiveClockWord": { - screen: "running ", - source: "running ", - why: "the call's own clock, said under the live step on the plan page: the same ten-second " + - "count the rail keeps (taskplan.go's taskPlanBody reads PlanTaskPage.Live and taskToolFloor " + - "gates it, the 10-second clock the rail already keeps). It is the page's word, so the gate " + - "looks for it in the surface", - }, // ── the run's plan row, on the live edge (c185, SURFACE.md §2A/§3) ──────── // diff --git a/internal/enginehost/enginehost_test.go b/internal/enginehost/enginehost_test.go index 124c0838b..838a3ed37 100644 --- a/internal/enginehost/enginehost_test.go +++ b/internal/enginehost/enginehost_test.go @@ -674,3 +674,67 @@ func TestHostedAgentReadsSeededPlanTasksEndToEnd(t *testing.T) { } t.Fatalf("PlanTasks over host = %+v, want seeded real-store row", rows) } + +// THE TASK PAGE'S MODEL IS THE LEDGER'S, and it has to cross the host: the +// room head reads it off the page the engine served, and a page that arrives +// without it draws the price and the tokens and never the model. +func TestHostedPlanPageCarriesTheLedgersModel(t *testing.T) { + workspace := t.TempDir() + t.Setenv("CODEAF_TASK_BELT", "bash") + store, err := session.OpenRunPlan(workspace, "Hosted run", "prove the model crosses the host") + if err != nil { + t.Fatalf("open run plan: %v", err) + } + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "seeded", Title: "Seeded task", Description: "the real row"}}); err != nil { + t.Fatalf("seed run plan: %v", err) + } + if err := store.AddSpend("seeded", "z-ai/glm-5.3-flash", "worker", 0.0017, 20000, 1000); err != nil { + t.Fatalf("write spend: %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("close seeded plan: %v", err) + } + + engine, err := session.New(session.Config{ + Workspace: workspace, + Model: "test/model", + APIKey: "fixture", + BaseURL: "http://127.0.0.1:1/v1", + System: "Test only.", + }) + if err != nil { + t.Fatalf("build real session agent: %v", err) + } + t.Cleanup(func() { _ = engine.Close() }) + + host := &Host{ + workspace: workspace, + opts: Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: engine, Workspace: workspace}, nil + }}, + sessions: map[string]*remote.Session{}, + done: make(chan struct{}), + } + surface, hosted := net.Pipe() + go host.attach(hosted) + client, err := remote.Dial(surface, "", remote.Hello{Version: remote.Version, Workspace: workspace}) + if err != nil { + t.Fatalf("dial hosted session: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + + page, ok := client.Agent().PlanTaskPage("t-seeded") + if !ok { + t.Fatal("PlanTaskPage over the host answered no page") + } + if page.Row.Model != "z-ai/glm-5.3-flash" { + t.Fatalf("page model = %q, want the ledger's z-ai/glm-5.3-flash (tokens %d usd %v)", page.Row.Model, page.Row.Tokens, page.Row.USD) + } + if page.Row.Tokens != 21000 { + t.Fatalf("page tokens = %d, want 21000", page.Row.Tokens) + } + work, found := client.Agent().PlanTaskWork("t-seeded") + if !found || work.NoDoor { + t.Fatalf("PlanTaskWork over the host = (%+v, %v), want the door to answer", work, found) + } +} diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index 5949b6c16..47ed4f7dc 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -181,6 +181,25 @@ all re-fit it. Only the overflow folds. The conversation keeps exactly the last calls in the no-caption fallback above a line reading `N earlier tool calls · ctrl+o`, and scrolling the conversation never opens a fold — `ctrl+o` or a click does. +## See what a task changed: the `transcript` and `work` tabs, and the `tab` key + +Two tabs are named at the right of the top line, beside the way back: `transcript` and +`work`. They are there for every task on either engine. `transcript` is everything this +manual describes: the brief, the steps, the notes and the report. `work` shows the files +changed. + +- On this window's own engine, `work` lists the files once the task has landed: + `files this task changed`, or `this task changed no files`. Before it lands it says + `the files this task changes are listed here when it lands`. +- On a run's task, `work` is the run's working copy read against the commit it was cut + from: the difference, and the files added, with the files codeaf keeps there left out. + The whole run shares that one copy, and the tab says so at its top. Once the run has + ended its copy is given back, and the tab reads the work off the run's branch instead. + With that branch gone too, it says `the run's working copy is not here any more`. + +`tab` over an empty box moves from one tab to the other, and a click on a tab's name opens +it. With words in the box, `tab` is the box's own key. + ## What the line at the top of a task's page tells you **It is drawn under the conversation tabs, not above them.** Those say which conversation @@ -399,12 +418,11 @@ the page says it in one dim line, above whatever else it already knows: - a read that came back with an error says `couldn't read this task's conversation · retrying`, which is a different fact from either of the two above and keeps its beat -**A run's task page follows its newest step the same way.** The page `enter` opens on a run's -row — a row drawn in the tasks place — keeps up with the work: the newest step walks in at the -bottom, and the page stays stuck to that live edge until you scroll up, which releases it. -Scrolling back to the bottom resumes the follow, and a page on a task that has settled is a -still page, never re-read. The step being run right now is drawn one step early, the running -glyph beside its command. +**A run's task follows its newest step the same way.** A task on the run engine opens this +same room, read from the run's store, and keeps up with the work: the newest step walks in at +the bottom, and the room stays stuck to that live edge until you scroll up, which releases it. +Scrolling back to the bottom resumes the follow, and a room on a task that has settled is a +still page, never re-read. The step being run right now is the newest call, drawn running. ## Why is a task I just started showing an empty page @@ -578,11 +596,13 @@ until you answer it. A task that is still running is never refused: if nobody is to read you — it is being checked, or its worker has just closed — the line is held on the task's record instead, which the clause above says out loud. -**A run task's page carries this box too**, and it says when the note is read rather than merely -saved. Under the box the run page writes `the worker reads a note at its next step`: the note -goes to the task's store and waits until the worker asks for its next step, which is when it -reads what you wrote. A task that has ended, `done` or `incomplete`, takes no next step, so -its page leaves that sentence out. +**A run task's room carries this box too**, and it says when the note is read rather than merely +saved. Once the store has the note the room writes `the worker reads a note at its next step` +under it: the note waits in the store until the next step, which is when it is read. Once the +work has ended, `done` or `incomplete`, there is no next step, so the room leaves that sentence +out and takes no note: `enter` says `this task has finished` and where to say them instead, and +leaves them in the box. A note the store refuses for another reason shows the store's own +sentence on the line you typed. ## Task page says finished but the work is still running @@ -740,7 +760,7 @@ tree's own connectors (`├─`, `└─`). A part that is running says the comm on, such as `bash go test ./...`, and under that how long it has run and what it has cost, each left out when the store has not got it. -Select any row there and press `enter` to open that task's page. Press `esc` to -return to the page you came from; the trail at the top of the page reads -` ▸ ▸ ` so you can see where it returns. -This opens a page rather than changing the rail's fold. +Click any row there to open that task's room. The trail at the top of the room reads +` ▸ ▸ `, and a click on the parent's name in it +opens the parent's room again. `esc` returns to the conversation. This opens a room +rather than changing the rail's fold. diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 22dbb80fd..f0bf391b5 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -150,14 +150,15 @@ The task request keeps its existing three-line preview and clickable disclosure; that request. Completed work keeps its existing `ctrl+e` disclosure. The sidebar does not repeat the request or change the transcript's presentation. -## Reading a note on a run task's page — and the page following the newest step +## Reading a note on a run task's room, and the room following the newest step -A run's task page — the one `enter` opens on a run's row in the tasks place — keeps up with +A run's task opens the same task room as any other task: from its row on the side list, +from `enter` on its row in the tasks place, or from the run's tab. The room keeps up with its newest step, reading the task again every three seconds while it is queued or running, and follows it until you scroll up; scrolling back to the bottom resumes the follow, and a -page on a settled task is a still page. Its foot is a **note box**, under the -placeholder `a note for this task`: type in it and press `enter`, and the words go to the -task's store rather than to a model. The page says when they are read: +room on a settled task is a still page. Its box says `a note for this task`: type in it and +press `enter`, and the words go to the task's store rather than to a model. Once the store +has the note, the room says when it is read: ``` the worker reads a note at its next step @@ -165,20 +166,20 @@ the worker reads a note at its next step A worker is a separate loop, so a note waits until the worker asks for its next step, and that is when it reads what you wrote. A task that has ended, `done` or `incomplete`, takes no next -step, so its page leaves that sentence out. `x stop it` and `p pause` are read only over an empty box: the -moment there is a note to type, a letter is a letter. Under a run's own task `x stop it` raises -the `Stop this task?` card before anything ends, and `p pause` is not offered, because a run -cannot be paused as a whole. On a task that has ended neither key is offered and neither acts: -both are letters in the note. - -## Typing while a task page is still loading — the letters land in its note box - -A plan task's page can take a moment to arrive, most of all over `--host`. Everything you type -between the press that asks for it and the page appearing is kept for the page and **typed into -its note box, and nowhere else**: none of it reaches the conversation's box, and none of it is -taken as one of the page's keys. So a note that happens to begin with `x` or `p` never stops or holds the -task, and an `enter` pressed in that gap does not send anything — the words wait in the box, -unsent, until you have seen the page they are going to and press `enter` there. `esc` in the gap +step, so its room takes no note: `enter` there says `this task has finished` and where the +words can go, and leaves them in the box. `x` is read only over an empty box: the moment +there is a note to type, a letter is a letter. `x` raises the `Stop this task?` card before +anything ends. On a task that has ended it is not offered and does nothing: it is a letter in +the box. `p` is always a letter in the room; a part is held from its row in the tasks place. + +## Typing while a task's room is still loading: the letters land in its box + +A run task's room can take a moment to arrive, most of all over `--host`. Everything you type +between the press that asks for it and the room appearing is kept for the room and **typed into +its box, and nowhere else**: none of it reaches the conversation's box, and none of it is +taken as one of the room's keys. So a note that happens to begin with `x` never stops the +task, and an `enter` pressed in that gap does not send anything. The words wait in the box, +unsent, until you have seen the room they are going to and press `enter` there. `esc` in the gap withdraws the press and drops what was typed. ## Task setup through the session host diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a89049b57..a3e89d4a8 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -328,8 +328,8 @@ start together. that begins sets aside a footprint — one core's share of memory, or more where this session's pieces were seen to need more — so a wide hand-out runs as many pieces as the memory above `task.min_free_mb` can hold and leaves the rest queued. The run's rail row -reads `waiting · machine busy`; held parts on its plan page read -`queued · machine busy`. Those begin by themselves as earlier pieces finish; there is +reads `waiting · machine busy`; its held parts read `queued · machine busy` on their +own rows. Those begin by themselves as earlier pieces finish; there is nothing to do about it and nothing to come back for. how-tasks-run has the arithmetic. What it will *not* do is watch them. Each landing writes one dim line in the diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 27b6c323d..e903ddccb 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -81,26 +81,31 @@ Each row wears one state word, mapped off the store's own status: Beside the word a row may carry the steps its worker recorded and the dollars its spend rows hold — each left out when it is nothing. -**`enter` opens the task's page.** It is built from the store's own read and is the -same full frame, the same `esc`, and the same way back as a record row's card. Its -head is a task room's head: the trail, ` ▸ ` with `esc back` at -its far end, and under it the rule that leads with the task's state mark and word — -the spinner while it runs — then how long it has run, its steps and how many of its -parts are running or queued, with what it has cost at the far end. Each figure is -left out when it is nothing. Under the head it shows, in order, each section left -out when nothing is behind it: - -- `description` — the work order the worker was given; -- `notes` — every note left on the task, with its moment. A note you left reads - `you`. A note a worker or the run left names no author: the store knows those - only by ids of its own, and an id is never drawn on this page; -- `steps` — the trajectory its worker recorded: each command that ran, led by the - shell's mark (`$`) as a task room leads a command, with the head of what came back - dim under it, the whole observation on disk behind the row. The step in flight is - the newest row, with `running ` under it once it has run ten seconds. A call known not to - have run stays in the record and its count and is never drawn as a step: a command the - worker tried and was refused is one dim line, `refused` and the command, and any other - has no row. +**`enter` opens the task's room**, the same room every task opens on either engine: +the same full frame, the same head, the same `esc`, the same box and the same two tabs. +The only difference is where it reads from: a run's task is read from the run's store. + +- The head is a task room's head: the trail with the way back at its far end, then + the task's name with its state word, how long it has run, what it has cost, and the + tokens its worker read and wrote. Each figure is left out when the store has not + got it. The model its worker spent the most through is shown with the task's setup + on the right. A task whose store holds no spend for it shows neither. +- The `transcript` tab opens on the work order the worker was given, then the + trajectory its worker recorded: each command that ran is the room's own shell + call, folded the way any task's calls are folded, with what came back + behind it. The step in flight is the newest call. A call known not to have run + stays in the record and in the step count and is never drawn as a call: a command + the worker tried and was refused is one dim line, `refused` and the command, and + any other has no row. Then the notes left on the task: a note you left is drawn + where your own corrections are drawn, and a note a worker or the run left names no + author, because the store knows those only by ids of its own. +- Under the transcript, the tasks it hangs on (`waits`) and the tasks under it + (`under it`), each drawn as the side list draws a task. A press on any of those + rows opens that task's room. +- The `work` tab is the run's working copy: what every task of the run changed in + it against the commit it was cut from, and the files it added. The files codeaf + itself keeps there are left out. `tab` over an empty box moves between the two + tabs, and a click on either name does the same. A page the engine will not answer for — a task this conversation did not spawn, or one whose store has gone — is not opened; the list stays where it was. @@ -113,24 +118,25 @@ looks exactly like any other task row**: the state mark (the spinner while it works), the name, its `#id` at the far end — a part's id is the store's own, such as `#k3x9qa` — and, under a row that is running, the command it is on and a `4m · $0.02` line of how long it has run and what it has cost, each figure left -out when the store has not got it. Tokens and the model are not in the store, so -a run's rows never show them. The parts hang in the tree's own connectors, one +out when the store has not got it. The rows on the side list do not show tokens +or the model; the task's room does, where the store's spend has them. The parts hang in the tree's own connectors, one row each, the finished ones included. Every one of those rows is a -door: click the run's row, or select it and press `enter`, and its page opens over the -conversation; click a part's row or a check's row and THAT task's page opens. The page -is the one the tasks place opens: what the task was asked, its notes, its steps, and the -box that leaves a note. `esc` goes back to the conversation exactly as you left it, with -whatever you had typed still in the box. - -The page can take a moment to arrive. From the press on, what you type belongs to the -page and never to the conversation: the keys are kept in order and land in the page's -note box when it opens, `enter` included. `esc` in that moment withdraws the press. If -the row turns out to have no page and its room opens instead, those keys are dropped. - -A page opened on a task that is queued or running follows it: it reads the task again +door: click the run's row, or select it and press `enter`, and its room opens over the +conversation; click a part's row or a check's row and THAT task's room opens. It is the +room the tasks place opens and the run's tab opens: what the task was asked, its steps, +its notes, and the box that leaves a note. `esc` goes back to the conversation exactly as +you left it, with whatever you had typed still in the box. + +The room can take a moment to arrive. From the press on, what you type belongs to the +room and never to the conversation: the keys are kept in order and land in the room's +box when it opens, `enter` included, unsent. `esc` in that moment withdraws the press. +If the row turns out to have no store page and its ordinary room opens instead, those +keys are dropped. + +A room opened on a task that is queued or running follows it: it reads the task again every three seconds, so a new step shows within that, and it stops reading when the task -has settled. A page on a task that has ended is read once, to open it. A step whose command is many lines long is drawn as -its first line and `…`; what ran is unchanged. +has settled. A room on a task that has ended is read once, to open it. A step whose +command is many lines long is drawn as its first line and `…`; what ran is unchanged. A row the store has no page for opens what it always opened, its room. That is every task when the switch is off. A task of an earlier run keeps its page after a later run @@ -192,8 +198,8 @@ task>), not this worker's run (t-), so nothing was read or written`. ## Typing into a run's row, and a row nothing drives any more -A message typed in the room of a run's row is left as a note on that task's page, -and the room says `left on the task's page — its worker reads it between steps`. +A message typed in the room of a run's task is left as a note on that task, and once +the store has it the room says `the worker reads a note at its next step`. A run row that nothing drives any more, because its run is not the one this conversation is driving or its plan holds no such task, answers a message with @@ -240,33 +246,17 @@ store clears the step the moment its command ends. These rows are a run's **plan in the tasks place (`/history`, `ctrl+.`, `alt+3`, and the roster raised over the frame), not on the always-on column, which draws this conversation's own tree. -## What a run task's page shows while it runs +## What a run task's room shows while it runs -`enter` on a run's row opens the task's page, and while the task is running the page follows -its newest step: it re-reads itself on the clock and stays stuck to the bottom — the newest -step in view — until you scroll up, which releases it. Scrolling back to the bottom takes the -follow up again without your pressing anything. +`enter` on a run's row opens the task's room, and while the task is running the room follows +its newest step: it re-reads the store every three seconds and stays stuck to the bottom, +the newest step in view, until you scroll up, which releases it. Scrolling back to the +bottom takes the follow up again without your pressing anything. -The step being run right now is drawn **one step early**, as the newest row of the page's -`steps` section, and, once the call has been open ten seconds, its own clock dim under it. -The page's head says the task is running with the spinner, as a task room's head does: - -``` - the chat ▸ Add rate limiter to /api/upload esc back -─ ⠋ running 6m · 12 steps ──────────────────────────────── $0.11 ─ - -brief -Add a per-IP rate limiter to the upload handler; … -steps -$ sed -n 40,120p internal/api/upload.go -$ git grep -n RateLimit internal/api - 3 hits -$ go test ./internal/api/... - running 41s -``` - -When the command ends the store clears the live step and the next read draws it as an ordinary -step, with the head of what came back. +The step being run right now is the newest call in the transcript, drawn running. The +room's head says the task is working, how long it has run and what it has cost, as every +task room's head does. When the command ends the store clears the live step and the next +read draws it as an ordinary call, with what came back behind it. ## Why is a step missing, the step numbers skip, the cd at the front of a command is gone @@ -347,7 +337,7 @@ The person's door onto a run's plan is six verbs, each resolving an id **inside this conversation's plan**, so a task another chat spawned is never reachable: - **note** — a note in your own voice on one task, which that task's worker is - handed between its own steps. On a plan task's page it is what the composer + handed between its own steps. In a run task's room it is what the box sends: type in it and press `enter`, under the placeholder `a note for this task`. It is not a chat turn — the words go to the store and never to the conversation's model. "Does a note actually reach the worker" has its own @@ -355,21 +345,23 @@ this conversation's plan**, so a task another chat spawned is never reachable: - **pause** / **resume** — hold a task and everything under it out of the ready frontier without changing its rung, so running steps finish and nothing new in the subtree is launched; or release the hold. The key is `p`: a running row - reads `p pause` and a held one `p resume`. A run cannot be paused as a whole: - under the run's own task no `p pause` is named, and there `p` is a letter in the note. + reads `p pause` and a held one `p resume`. The key is on the tasks place's + rows only: in a task's room `p` is a letter in the box. A run cannot be paused + as a whole, so its own row names no `p pause`. - **cancel** — end a task, its descendants and the work hard-depending on it. The - key is `x stop it` (the roster's own cancel key), on the row and on the page. - On the run's own row and page that key ends the whole run and asks first; see - "How do I stop a run?" below. + key is `x stop it` (the roster's own cancel key), on the row and in the room. + In a room, `x` over an empty box raises the `Stop this task?` card first, for a + part as for the run. On the run's own row and room that key ends the whole run + and asks first; see "How do I stop a run?" below. - **amend** — prepend text to a task's description, the way the CLI's `task amend --prepend` does, so the plan learns while it runs. - **priority** — set a task's priority through the store's revision verb. `x` and `p` are read only over an **empty box**: the moment there is a note to type, a letter is a letter. A task that has ended, `done` or `incomplete`, is offered -neither: its row and its page name no `x stop it` and no `p pause`, because the store -would refuse both — **and neither key does anything there**. On an ended task's page -both are letters in the note box; on an ended row in the list they are letters too. No +neither: its row and its room name no `x stop it` and no `p pause`, because the store +would refuse both, **and neither key does anything there**. In an ended task's room +both are letters in the box; on an ended row in the list they are letters too. No `Stop this task?` card is raised over a run that has already finished. Two refusals are this layer's own, and they are the words the pane reads back: @@ -488,13 +480,13 @@ note is something somebody knows, not a direction, and that its work order has not changed. A note can never move what a task is judged by: asking for something *different* is a revised assignment, not a note. -Three hands write notes — you, from the task's page; another worker in the run; +Three hands write notes — you, from the task's room; another worker in the run; and the conversation itself — and each is named where the note is drawn: `the person`, `task t-…`, or `you` when it was the conversation. ## Where do I see the notes on a run, why didn't the chat know about the note -Every note is on the task's page, under `notes`, oldest first. +Every note is in the task's room, after its steps, oldest first. The conversation reads them too, and you can ask it: the run's listing puts the newest note on each row after the row's state, as `note: …`, and asking about one @@ -509,9 +501,8 @@ what you actually asked for would list every row of the run and never learn it. ## How do I stop a run? Stop it did nothing and the task kept running, cancel the whole run Press `x` over an empty box while the run's row is the one task row on the side list, -or open the run's own page and press `x stop it` there. Both raise the same card, -`Stop this task?`, with `stop it` and `keep going`; the page steps aside so the card -is drawn in the conversation. A digit moves the choice, `enter` takes it, and `esc` is +or open the run's task room and press `x` there over an empty box. Both raise the same +card, `Stop this task?`, with `stop it` and `keep going`. A digit moves the choice, `enter` takes it, and `esc` is `keep going`. Nothing ends on one keystroke. Telling the chat "stop task 1" ends a run the same way and asks nothing, because your sentence is the decision. @@ -519,8 +510,9 @@ A stop ends the run now: every part still open is ended, what it was running is off, and no further model call is made for it. The row reads `stopped`. A second stop on a run that is already stopping answers that it is already stopping. -`x` on one PART of a run ends that part only, at once and without a card, and the -rest of the run carries on. A row nothing drives any more is cleared the same way: +`x` on one PART's row in the tasks place ends that part only, at once and without a +card, and the rest of the run carries on. In a part's room `x` asks first, with the +same card, and ends that part only. A row nothing drives any more is cleared the same way: the stop settles it as `stopped` and answers `stopped task N () — nothing was driving it any more`. A run cannot be paused as a whole, so under the run's own task no `p pause` is named. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 60d368648..c48380e0f 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -138,7 +138,7 @@ func classify(method string) callClass { case MethodPing, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, - MethodPlanTasks, MethodPlanTaskPage, MethodPlanRunSummary, MethodRefreshRunSummary, + MethodPlanTasks, MethodPlanTaskPage, MethodPlanTaskWork, MethodPlanRunSummary, MethodRefreshRunSummary, MethodReasoningFor, MethodEffort, MethodResolvedEffort, MethodResolvedApproval, MethodAttachedSkills, MethodSkillShelf, MethodSessionsRecent, MethodHeldQuestions, diff --git a/internal/remote/client.go b/internal/remote/client.go index 5a28082ae..1becfd22f 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1981,6 +1981,25 @@ func (a *Agent) PlanTaskPage(id string) (session.PlanTaskPage, bool) { return result.Page, result.OK } +// PlanTaskWork reads the run's working copy over the wire. An engine that +// has no such door answers "no such method", which is [PlanTaskWork.NoDoor]: +// the work tab draws its absence sentence, the same one it draws for an +// agent that was never given the door. +func (a *Agent) PlanTaskWork(id string) (session.PlanTaskWork, bool) { + payload, err := a.c.call(nil, MethodPlanTaskWork, PlanTaskArgs{ID: id}) + if err != nil { + if strings.Contains(err.Error(), "no such method") { + return session.PlanTaskWork{NoDoor: true}, false + } + return session.PlanTaskWork{}, false + } + var result PlanTaskWorkResult + if json.Unmarshal(payload, &result) != nil { + return session.PlanTaskWork{}, false + } + return result.Work, result.OK +} + func (a *Agent) PlanNote(id, text string) error { _, err := a.c.call(nil, MethodPlanNote, PlanTextArgs{ID: id, Text: text}) return err diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index 0be4a92fa..b65486921 100644 --- a/internal/remote/client_test.go +++ b/internal/remote/client_test.go @@ -963,6 +963,33 @@ func TestAStandingCallThatFailsSaysSoRatherThanAnsweringNothing(t *testing.T) { } } +// THE WORK TAB'S READ CROSSES THE SAME WAY THE PAGE DOES. An engine that +// answers it hands back the copy's difference. An engine that has never heard +// of the door answers "no such method", and that is the absence the tab draws, +// not an empty difference. +func TestPlanTaskWorkCrossesAndAnOldEngineHasNoDoor(t *testing.T) { + client, e := newEngine(t) + want := session.PlanTaskWork{ + Dir: "/work/copy", Read: true, Added: []string{"notes.txt"}, + Patch: "diff --git a/load.go b/load.go\n+new line\n", + } + e.answers[MethodPlanTaskWork] = PlanTaskWorkResult{Work: want, OK: true} + got, ok := client.Agent().PlanTaskWork("t-6") + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("PlanTaskWork = (%#v, %v), want the copy", got, ok) + } + var args PlanTaskArgs + if err := json.Unmarshal(e.calls(MethodPlanTaskWork)[0].Payload, &args); err != nil || args.ID != "t-6" { + t.Fatalf("PlanTaskWork args = %+v, %v", args, err) + } + + e.fails[MethodPlanTaskWork] = `engine: no such method "PlanTaskWork"` + got, ok = client.Agent().PlanTaskWork("t-6") + if ok || !got.NoDoor { + t.Fatalf("an engine without the door answered (%#v, %v), want no door", got, ok) + } +} + func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { client, e := newEngine(t) started := time.Date(2026, 9, 18, 1, 2, 3, 4, time.UTC) diff --git a/internal/remote/plan_capability.go b/internal/remote/plan_capability.go index 601105f41..625fc3fe5 100644 --- a/internal/remote/plan_capability.go +++ b/internal/remote/plan_capability.go @@ -5,3 +5,4 @@ import "github.com/Agent-Field/codeaf/internal/session" // This assertion belongs on the transport side because remote may import // session while session cannot legally import its remote implementation. var _ session.PlanAgent = (*Agent)(nil) +var _ session.PlanWorkAgent = (*Agent)(nil) diff --git a/internal/remote/server.go b/internal/remote/server.go index 4fad7a7d4..09b9815fd 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2740,6 +2740,20 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { page, found := door.PlanTaskPage(args.ID) return json.Marshal(PlanTaskPageResult{Page: page, OK: found}) + case MethodPlanTaskWork: + args, err := arg[PlanTaskArgs](call) + if err != nil { + return nil, err + } + door, ok := agent.(interface { + PlanTaskWork(string) (session.PlanTaskWork, bool) + }) + if !ok { + return json.Marshal(PlanTaskWorkResult{}) + } + work, found := door.PlanTaskWork(args.ID) + return json.Marshal(PlanTaskWorkResult{Work: work, OK: found}) + case MethodPlanNote: args, err := arg[PlanTextArgs](call) if err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 82bb6ea86..b49bdc2b4 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -540,9 +540,15 @@ const ( // that as the block being absent HERE — which is exactly what it drew before // this door existed — and the emptiness law is kept. Nothing that was drawn // goes dark, so nothing is refused at the door. - MethodPlanSpend = "PlanSpend" // PlanSpendArgs → []session.PlanSpendLine - MethodPlanTasks = "PlanTasks" // nothing → []session.PlanTaskRow - MethodPlanTaskPage = "PlanTaskPage" // PlanTaskPageArgs → PlanTaskPageResult + MethodPlanSpend = "PlanSpend" // PlanSpendArgs → []session.PlanSpendLine + MethodPlanTasks = "PlanTasks" // nothing → []session.PlanTaskRow + MethodPlanTaskPage = "PlanTaskPage" // PlanTaskPageArgs → PlanTaskPageResult + // MethodPlanTaskWork is the task room's work tab: the difference in the + // run's working copy. It rides this version rather than moving it, for + // [MethodPlanSpend]'s reason — an engine that does not know it answers + // "no such method", and the tab draws the absence sentence it already + // drew for an engine with no door. + MethodPlanTaskWork = "PlanTaskWork" // PlanTaskArgs → PlanTaskWorkResult MethodPlanNote = "PlanNote" // PlanTextArgs → nothing MethodPlanPause = "PlanPause" // PlanTaskArgs → nothing MethodPlanResume = "PlanResume" // PlanTaskArgs → nothing @@ -1831,6 +1837,12 @@ type PlanTaskPageResult struct { OK bool } +// PlanTaskWorkResult preserves both the working copy and whether the task belongs to the plan. +type PlanTaskWorkResult struct { + Work session.PlanTaskWork + OK bool +} + // PlanRunSummaryArgs names the run whose stored summary is read. type PlanRunSummaryArgs struct { RootID string `json:"root_id"` diff --git a/internal/session/agent.go b/internal/session/agent.go index c17448108..5e5f40e4f 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1617,12 +1617,13 @@ const ( // sent, and the sentence says that rather than reporting a second delivery // that did not happen. steerAgainWord = "already on the task's record from the same message — nothing was sent a second time" - // steerRunNoteWord is a line said to a run's own row. A run's task has no - // worker to splice into; its worker reads the notes on its task's page - // between its steps, so the line is left there, and the sentence says when - // it is read rather than claiming it arrived now (stoprun.go's + // steerRunNoteWord is what a note on a run's own row answers. A run's task + // has no worker to splice a line into. The words are a note on the task, + // and the worker reads a note at its next step, which is the same sentence + // the task room says once the store has the note. Saying the note arrived + // now would claim a read that has not happened (stoprun.go's // [Agent.sayToRunRow]). - steerRunNoteWord = "left on the task's page — its worker reads it between steps" + steerRunNoteWord = "the worker reads a note at its next step" ) // steerRecord is what the JOURNAL keeps about this line when it is a correction diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 6a5b154ba..d1f6c34b4 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -70,6 +70,14 @@ type PlanTaskRow struct { // USD is the sum of the task's spend rows: what this piece of the plan has // cost so far. USD float64 + // Model is the model this task's own spend rows spent most through, and + // Tokens the tokens those rows carried, in and out together. Both are read + // off the same ledger as USD and both are EMPTY WHEN THE LEDGER NAMES NONE: + // a task that has written no spend row has no known model and no known + // token count, which is not the same fact as a model called "" or a count + // of zero, and a surface draws nothing for either. + Model string `json:",omitempty"` + Tokens int `json:",omitempty"` // Started is when the task was created and Ended when it completed; a task // still open carries the zero Ended. Started time.Time @@ -354,6 +362,12 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { } } } + // THE PAGE'S OWN ROW CARRIES WHO SPENT AND HOW MUCH THEY READ AND WROTE, off + // the same ledger its price comes from. A listing does not: the figures are + // drawn on a task's page and nowhere else. + if usage, ok := planUsageByTask(store.Path())[task.ID]; ok { + pageRow.Model, pageRow.Tokens = usage.model, usage.tokens + } return PlanTaskPage{ Row: pageRow, Description: task.Description, @@ -852,6 +866,61 @@ func planSpendByTask(path string) map[string]float64 { return totals } +// planTaskUsage is one task's model and token figures, read off its spend rows. +type planTaskUsage struct { + model string + tokens int +} + +// planUsageByTask reads the run's spend ledger per task for the two figures a +// task's page draws beside its price: the model the task's rows spent most +// through, and the tokens they carried. It is [planSpendByTask]'s reading, on +// its own read-only connection for the same reason. A row that names no model +// names none, and a task whose rows carry no tokens has none known. +func planUsageByTask(path string) map[string]planTaskUsage { + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + return nil + } + defer db.Close() + rows, err := db.Query(`SELECT task_id, model, SUM(usd), COUNT(*), SUM(in_tokens + out_tokens) FROM spend GROUP BY task_id, model`) + if err != nil { + return nil + } + defer rows.Close() + type tally struct { + usd float64 + calls int + } + best := map[string]tally{} + out := map[string]planTaskUsage{} + for rows.Next() { + var id, model string + var usd float64 + var calls, tokens int + if rows.Scan(&id, &model, &usd, &calls, &tokens) != nil { + return out + } + usage := out[id] + if tokens > 0 { + usage.tokens += tokens + } + // THE MODEL IS THE ONE THE TASK SPENT MOST THROUGH, with the call count + // and then the name breaking a tie, so the same ledger always names the + // same model ([planSpendBySeat] chooses a seat's model the same way). + if model = strings.TrimSpace(model); model != "" { + held, seen := best[id] + if !seen || usd > held.usd || (usd == held.usd && calls > held.calls) || + (usd == held.usd && calls == held.calls && model < usage.model) { + best[id] = tally{usd: usd, calls: calls} + usage.model = model + } + } + out[id] = usage + } + return out +} + // planRunCopies says which folders are a run's own copy. live is the copy of // the run in flight, or the folder the row names when no run is; root is the // conversation's own folder of copies. AN ENDED RUN'S COPY HAS BEEN GIVEN BACK diff --git a/internal/session/plandb_usage_test.go b/internal/session/plandb_usage_test.go new file mode 100644 index 000000000..52b385453 --- /dev/null +++ b/internal/session/plandb_usage_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// A TASK'S PAGE CARRIES THE MODEL AND THE TOKENS ITS LEDGER RECORDS: the model +// it spent most through, and every token read and written. A task the ledger +// names nothing for carries neither, so a room draws no blank and no zero. +func TestPlanTaskPageCarriesTheLedgersModelAndTokens(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", + plandb.TaskSpec{ID: "alpha", Title: "Alpha", Description: "the work order"}, + plandb.TaskSpec{ID: "beta", Title: "Beta", Description: "no spend yet"}, + ) + store, err := plandb.Open(path, "", planRootID, "", "") + if err != nil { + t.Fatalf("reopen the store to write spend: %v", err) + } + for _, charge := range []struct { + model string + usd float64 + in, out int + }{ + {"deepseek/deepseek-v4-flash", 0.03, 9000, 1000}, + {"qwen/qwen3-coder", 0.01, 2000, 400}, + } { + if err := store.AddSpend("alpha", charge.model, "worker", charge.usd, charge.in, charge.out); err != nil { + t.Fatalf("write spend: %v", err) + } + } + _ = store.Close() + + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + page, ok := agent.PlanTaskPage("t-alpha") + if !ok { + t.Fatal("the chat's own task answered no page") + } + if page.Row.Model != "deepseek/deepseek-v4-flash" || page.Row.Tokens != 12400 { + t.Fatalf("page row model = %q tokens = %d, want the ledger's deepseek/deepseek-v4-flash and 12400", page.Row.Model, page.Row.Tokens) + } + bare, ok := agent.PlanTaskPage("t-beta") + if !ok { + t.Fatal("the chat's second task answered no page") + } + if bare.Row.Model != "" || bare.Row.Tokens != 0 { + t.Fatalf("a task the ledger names nothing for carries model %q tokens %d", bare.Row.Model, bare.Row.Tokens) + } +} diff --git a/internal/session/plandb_work.go b/internal/session/plandb_work.go new file mode 100644 index 000000000..cc61d0a66 --- /dev/null +++ b/internal/session/plandb_work.go @@ -0,0 +1,235 @@ +package session + +// plandb_work.go is the READING OF A RUN'S WORK: what a run's working copy +// holds that its ground did not, for the task page's `work` tab. +// +// A RUN'S TASK HAS NO JOURNAL, AND IT HAS A WORKING COPY. The task room reads a +// node's changes off the node's own record; a run's parts are rows of a plan +// store and every one of them types in the run's one copy. So the question +// "what has this task changed" is asked of the copy the run's row wrote down +// when the run started ([TaskNotice.Copy], [runCopyOf]) and answered as git +// answers it: the difference between the commit the copy was cut from and the +// files as they stand now, committed or not. +// +// IT IS THE RUN'S WORK, NOT ONE PART'S. A part and the run's own task share the +// copy, so every page of one run reads the same difference; the page says so +// rather than pretending a part's share can be told apart. +// +// NOTHING HERE WRITES. The copy is read with plumbing commands only, so opening +// a page never moves an index or a branch under a worker that is typing in it. + +import ( + "os" + "path/filepath" + "strings" +) + +// PlanTaskWork is what a run's working copy holds that its ground did not: the +// patch, the files git has never seen, and where the copy is. +// +// ITS ZERO VALUE IS "NOTHING KNOWN", and a surface draws it as the absence it +// is. An empty Patch on a copy that was read is a run that has changed +// nothing yet, which [PlanTaskWork.Read] tells apart from a copy nobody could +// read. +type PlanTaskWork struct { + // Dir is the run's copy, the directory its workers type in. + Dir string + // Patch is the unified difference between the commit the copy was cut + // from and the files in it now, the harness's own files left out + // ([harnessWrote]). It is capped at [planWorkPatchCap] bytes, and Cut says + // the cap was reached. + Patch string + Cut bool + // Added is every file in the copy git has never been told about, relative + // to the copy, the harness's own left out. A new file a worker wrote and + // did not stage is work too, and a patch alone would not show it. + Added []string + // Read says the copy was found and git answered for it. False is a run + // whose copy was never written down, or is no longer on disk, or is not a + // repository: a page has nothing to show and says so. + Read bool + // NoDoor says the engine that was asked has no working-copy read at all. + // It is set by a client whose engine answered "no such method", and it + // does not travel on the wire: a current engine never sets it, and a page + // draws the absence sentence rather than an empty difference. + NoDoor bool `json:"-"` +} + +// planWorkPatchCap is the most of a patch a page is handed. A run that rewrote +// a generated file can produce megabytes of difference, and the page is read, +// not archived: past the cap the page says the rest is in the copy. +const planWorkPatchCap = 256 << 10 + +// PlanWorkAgent is the optional door onto a run's working copy. It is its own +// interface rather than a method on [PlanAgent] because only an engine that +// holds the copy on its own disk can answer it: a surface asserts it, and a +// page on an engine without it draws the tab's honest absence. +type PlanWorkAgent interface { + PlanTaskWork(id string) (PlanTaskWork, bool) +} + +// PlanTaskWork answers the working copy of the run one store task belongs to. +// False is a task this conversation's plan does not hold. +func (a *Agent) PlanTaskWork(id string) (PlanTaskWork, bool) { + root, ok := a.planTaskRoot(id) + if !ok { + return PlanTaskWork{}, false + } + copied := a.planRunCopy(root) + if copied == nil { + return PlanTaskWork{}, true + } + return readPlanWork(copied), true +} + +// planTaskRoot is the store root of the run a task belongs to, spelled the +// way a run's row names its store task ([planStoreID]). +func (a *Agent) planTaskRoot(id string) (string, bool) { + stores, plan, closeStores := a.openPlanReadHandles() + defer closeStores() + for _, store := range stores { + if task := store.Task(planTaskID(id)); task != nil && task.Chat == plan.chat { + return planStoreID(store.RootID()), true + } + } + return "", false +} + +// planRunCopy is the copy a run wrote down, read from the live run while it is +// going and from the run's own row once it is not. +func (a *Agent) planRunCopy(root string) *TaskCopyRecord { + a.beltMu.Lock() + if run := a.beltRun; run != nil && planStoreID(run.root) == root { + copied := runCopyOf(run.tree) + a.beltMu.Unlock() + if copied != nil { + return copied + } + } else { + a.beltMu.Unlock() + } + g := a.graph() + if g == nil { + return nil + } + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + // THE NEWEST ROW NAMING THE RUN WINS, because every publish replaces the + // last and only a later one can know more ([Agent.publishRunRow]). + for i := len(rows) - 1; i >= 0; i-- { + if rows[i].PlanTask == root && rows[i].Copy != nil { + return rows[i].Copy + } + } + return nil +} + +// readPlanWork asks git what the copy holds against the commit it was cut +// from. A copy that is gone from disk is read off its branch instead, which is +// where a stopped or landed run's work still is. +// +// A COPY THAT WAS GIVEN BACK IS STILL A FOLDER ON DISK. An ended run leaves its +// copy behind as a plain folder with the note that it was released, and git no +// longer answers for it, so only a folder git still answers for is read as the +// live copy. Every other copy, gone or given back, is read off its branch. +func readPlanWork(copied *TaskCopyRecord) PlanTaskWork { + work := PlanTaskWork{Dir: strings.TrimSpace(copied.Dir)} + base := strings.TrimSpace(copied.HomeSha) + live := false + if info, err := os.Stat(work.Dir); err == nil && info.IsDir() { + _, live = runTreeRoot(work.Dir) + } + if live { + if base == "" { + base = "HEAD" + } + patch, err := git(work.Dir, "diff", "--no-color", "--no-ext-diff", base, "--", ".") + if err != nil { + return work + } + work.Patch, work.Cut = planWorkPatch(patch) + if out, err := git(work.Dir, "ls-files", "--others", "--exclude-standard", "-z", "--", "."); err == nil { + for _, name := range strings.Split(out, "\x00") { + if name = strings.TrimSpace(name); name != "" && !harnessWrote(filepath.ToSlash(name)) { + work.Added = append(work.Added, name) + } + } + } + work.Read = true + return work + } + root, branch := strings.TrimSpace(copied.Root), strings.TrimSpace(copied.Branch) + if root == "" || branch == "" || base == "" { + return work + } + patch, err := git(root, "diff", "--no-color", "--no-ext-diff", base, "refs/heads/"+branch, "--") + if err != nil { + return work + } + work.Patch, work.Cut = planWorkPatch(patch) + work.Read = true + return work +} + +// planWorkPatch drops every file section the harness wrote and caps what is +// left. The sections are cut on git's own header line, so a file the harness +// owns is left out whole and never half. +func planWorkPatch(patch string) (string, bool) { + var kept strings.Builder + for _, section := range planPatchSections(patch) { + if harnessWrote(planPatchPath(section)) { + continue + } + kept.WriteString(section) + } + out := kept.String() + if len(out) <= planWorkPatchCap { + return out, false + } + cut := out[:planWorkPatchCap] + if at := strings.LastIndexByte(cut, '\n'); at > 0 { + cut = cut[:at+1] + } + return cut, true +} + +// planPatchSections splits a unified patch into one piece per file, each +// starting at its `diff --git` line. +func planPatchSections(patch string) []string { + var out []string + start := -1 + for at := 0; at < len(patch); { + end := strings.IndexByte(patch[at:], '\n') + line := patch[at:] + next := len(patch) + if end >= 0 { + line = patch[at : at+end] + next = at + end + 1 + } + if strings.HasPrefix(line, "diff --git ") { + if start >= 0 { + out = append(out, patch[start:at]) + } + start = at + } + at = next + } + if start >= 0 { + out = append(out, patch[start:]) + } else if strings.TrimSpace(patch) != "" { + out = append(out, patch) + } + return out +} + +// planPatchPath is the path one file section is about, read off its header's +// `b/` side, which is the file as it stands now. +func planPatchPath(section string) string { + head, _, _ := strings.Cut(section, "\n") + head = strings.TrimPrefix(head, "diff --git ") + if at := strings.LastIndex(head, " b/"); at >= 0 { + return head[at+3:] + } + return head +} diff --git a/internal/session/plandb_work_test.go b/internal/session/plandb_work_test.go new file mode 100644 index 000000000..5b08f3f4c --- /dev/null +++ b/internal/session/plandb_work_test.go @@ -0,0 +1,117 @@ +package session + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// A RUN'S WORKING COPY IS READ AS WHAT THE WORK CHANGED: the patch against the +// commit the copy was cut from, and the files the work added, with every file +// the harness itself writes left out. +func TestReadPlanWorkIsTheCopysOwnChangesWithoutTheHarnessFiles(t *testing.T) { + dir := t.TempDir() + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + write := func(name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + run("init", "-q") + write("load.go", "old line\n") + run("add", "load.go") + run("commit", "-q", "-m", "base") + base := run("rev-parse", "HEAD") + + write("load.go", "new line\n") + write("notes.txt", "added by the work\n") + write(planStoreFilename, "the harness's own store\n") + + work := readPlanWork(&TaskCopyRecord{Dir: dir, HomeSha: base}) + if !work.Read || work.Cut { + t.Fatalf("the copy was not read whole: read %v cut %v", work.Read, work.Cut) + } + if !strings.Contains(work.Patch, "-old line") || !strings.Contains(work.Patch, "+new line") { + t.Fatalf("the patch does not carry the change:\n%s", work.Patch) + } + if len(work.Added) != 1 || work.Added[0] != "notes.txt" { + t.Fatalf("added files = %q, want the work's own file and not the harness's", work.Added) + } +} + +// A PATCH WITH A FILE THE HARNESS WROTE LOSES THAT FILE WHOLE, never half. +func TestPlanWorkPatchDropsAHarnessFileWhole(t *testing.T) { + patch := "diff --git a/load.go b/load.go\n--- a/load.go\n+++ b/load.go\n@@ -1 +1 @@\n-a\n+b\n" + + "diff --git a/" + planStoreFilename + " b/" + planStoreFilename + "\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-c\n+d\n" + got, cut := planWorkPatch(patch) + if cut || strings.Contains(got, planStoreFilename) || !strings.Contains(got, "+b") { + t.Fatalf("planWorkPatch kept %q (cut %v)", got, cut) + } +} + +// A RUN THAT HAS ENDED GIVES ITS COPY BACK AND THE FOLDER STAYS ON DISK, as a +// plain folder git no longer answers for. The work is on the run's branch, and +// that is where the work tab reads it: a finished or stopped run whose branch is +// still in the repository has work to show, not a copy that is gone. +func TestReadPlanWorkReadsAGivenBackCopyOffItsBranch(t *testing.T) { + root := t.TempDir() + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + write := func(dir, name, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + run("init", "-q") + write(root, "README.md", "the ground\n") + run("add", "README.md") + run("commit", "-q", "-m", "base") + base := run("rev-parse", "HEAD") + run("checkout", "-q", "-b", "task/given-back") + write(root, "made.txt", "the run's work\n") + run("add", "made.txt") + run("commit", "-q", "-m", "the run") + run("checkout", "-q", "--detach", base) + + // The copy as the run leaves it once it is given back: the files, and the + // note that it was released, in a folder that is no repository. + given := t.TempDir() + write(given, "made.txt", "the run's work\n") + write(given, ".codeaf/released.json", `{"branch":"task/given-back"}`) + + work := readPlanWork(&TaskCopyRecord{Dir: given, Root: root, Branch: "task/given-back", HomeSha: base}) + if !work.Read { + t.Fatalf("a given-back copy whose branch is kept read as gone: %+v", work) + } + if !strings.Contains(work.Patch, "+the run's work") { + t.Fatalf("the branch's work is not in the patch:\n%s", work.Patch) + } +} diff --git a/internal/session/work_tree_belt_test.go b/internal/session/work_tree_belt_test.go index 15d56c63b..432fda1b4 100644 --- a/internal/session/work_tree_belt_test.go +++ b/internal/session/work_tree_belt_test.go @@ -15,6 +15,7 @@ package session import ( "context" "path/filepath" + "strconv" "testing" "time" @@ -154,3 +155,31 @@ func TestClosingTheConversationWritesNoStopOnTheRun(t *testing.T) { t.Fatal("the room closing wrote a stop on the run's own record") } } + +// A NOTE ON A RUN'S ROW SAYS WHEN THE WORKER READS IT, in the task room's own +// sentence. The row has no worker to splice a line into, so the words are a +// note, and the receipt is when that note is read. +func TestANoteOnARunRowSaysWhenTheWorkerReadsIt(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + const row uint64 = 7 + path := filepath.Join(t.TempDir(), planStoreFilename) + store, err := plandb.Open(path, "run", strconv.FormatUint(row, 10), "the run", "person ask") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + _, cut := context.WithCancel(context.Background()) + t.Cleanup(cut) + agent.beltMu.Lock() + agent.beltRun = &beltRun{store: store, root: store.RootID(), row: row, title: "the run", cut: cut} + agent.beltMu.Unlock() + + receipt, err := agent.SteerTask(row, "keep the middleware order") + if err != nil { + t.Fatalf("a note on a run row: %v", err) + } + const want = "the worker reads a note at its next step" + if receipt.Landing != want { + t.Fatalf("a note on a run row answered %q, want %q", receipt.Landing, want) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index e2aaee8c2..3e0cdddaa 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1852,6 +1852,9 @@ type app struct { roomStop hudSpan // roomBackSpan is the padded Back action in the breadcrumb row. roomBackSpan hudSpan + // roomTabSpans is where the room's two tab names were drawn on the trail + // row, in tab order, and none on a frame too narrow for them (roomtabs.go). + roomTabSpans []hudSpan // crumbs is where the breadcrumbs were drawn on the frame's first row, in // columns, and what each of them opens (roomcrumbs.go). It is written by the // draw — [app.roomHead] — and read by the press and the @@ -1872,8 +1875,6 @@ type app struct { // chatTabBar is the strip as it was last laid out, kept from frame to frame // (chattabs.go's [tabBar] states the whole of why). chatTabBar tabBar - workTabOn bool - railTaskPlanOn bool railPlanPending railPlanPending workTabSettled string tabView tabViewport @@ -4354,6 +4355,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.roomBackPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } + if cmd, took := a.roomTabPress(msg.Mouse().X, msg.Mouse().Y); took { + return a, cmd + } // THE TASK STRIP IS READ BEFORE THE RAIL, because the strip spans the // WHOLE window and the rail claims every press in its own columns // whether or not one landed on a row (room.go) — asked the other way @@ -4757,6 +4761,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { case farRoomTickMsg: return a, a.farRoomPoll(msg.gen) + case planRoomTickMsg: + // A RUN'S TASK'S PAGE, one beat later (planroom.go). + return a, a.planRoomPoll(msg.gen) + case jobLogMsg: // A BACKGROUND JOB'S LOG, ONE READING LATER (roomjoblog.go). The reader // itself decides whether another beat is owed, because the row it watches @@ -5154,11 +5162,6 @@ func (a *app) paint() tea.Cmd { if a.room != nil { a.room.dirty = true } - // AND THE PLAN PAGE'S OWN READING IS TAKEN ON THE SAME CLOCK, for the same - // reason: a page left open on a running task follows its newest step - // ([app.taskPlanFollow]), and a page on a settled task is not read at all — - // the clock stops with the task, one row down. - kick = tea.Batch(kick, a.taskPlanFollow()) // A TOOL THAT HAS JUST ENDED IS ASKED ABOUT ON THIS FRAME, not at the next // tenth ([app.usageOwed]) — the ask alone, because nothing else on this // beat has moved with it. ONLY WHILE THE WORK IS STILL RUNNING: the ask is @@ -5346,11 +5349,11 @@ func (a *app) paint() tea.Cmd { // row, an opened tile growing into the frame, and a working tile's // spinner, each a function of this clock's time (wall.go). a.wallAnimating() || - // AND A PLAN PAGE ON A RUNNING TASK IS THE SEVENTEENTH, and it is the - // fourth that can be the whole of what is happening: the page follows a - // live edge the store writes from another process, and no turn of ours - // runs while it moves (taskplan.go's [app.taskPlanFollow]). - a.taskPlanRunning() + // AND A RUN'S TASK'S ROOM ON WORK THAT IS STILL GOING IS THE SEVENTEENTH, + // and it is the fourth that can be the whole of what is happening: the + // room follows a live edge the store writes from another process, and no + // turn of ours runs while it moves (planroom.go's [app.planRoomPoll]). + a.planRoomRunning() // THE WAIT ON THE MODEL is the only term that can hold this clock while // the screen shows nothing but the spinner and the ellipsis, and a spinner // glyph only changes every spinnerStep-th paint (styles.go). A wait whose @@ -6925,6 +6928,12 @@ func (a *app) press(x, y int) (cmd tea.Cmd) { // ([app.roomBackPress]), which is the pointer's share of the same exit. return } + // A ROW UNDER A RUN'S TASK STANDS FOR ANOTHER TASK OF THE RUN, and a press + // on it opens that task's room, the way its row on the side list does + // (planroom.go's [app.planRoomPartRows]). + if r.plan != "" && a.roomPlan() != nil { + return a.openRailPlan(r.plan, nil) + } // A TASK LINK IS THE ONE TARGET INSIDE A ROW, so it is resolved before the // row's own answer: the prose it sits in has no gesture of its own, and a // reference read after the body would be a door the body had already closed diff --git a/internal/tui3/c246_tree_test.go b/internal/tui3/c246_tree_test.go index cf03f8c85..9ce2a037b 100644 --- a/internal/tui3/c246_tree_test.go +++ b/internal/tui3/c246_tree_test.go @@ -37,7 +37,7 @@ func TestPlanTreeNestsByParentOnlyAcrossACousinWait(t *testing.T) { } } -func TestPlanFamiliesStartExpandedAndEnterOpensThePage(t *testing.T) { +func TestPlanFamiliesStartExpandedAndEnterOpensTheRoom(t *testing.T) { t.Run("finished family", func(t *testing.T) { root := session.PlanTaskRow{ID: "t-root", Title: "Finished family", Status: "done"} child := session.PlanTaskRow{ID: "t-child", Title: "landed child", Parent: root.ID, Status: "done"} @@ -52,8 +52,8 @@ func TestPlanFamiliesStartExpandedAndEnterOpensThePage(t *testing.T) { t.Fatalf("the finished family did not show its child by default:\n%s", text) } drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != root.ID { - t.Fatalf("enter on the folded family did not open its page: on=%v row=%q", a.taskSheet.planOn, a.taskSheet.plan.Row.ID) + if plan := a.roomPlan(); plan == nil || plan.id != root.ID || a.at(pageTasks) { + t.Fatalf("enter on the family did not open its task room: room=%v tasks=%v", a.roomOpen(), a.at(pageTasks)) } }) t.Run("live family", func(t *testing.T) { @@ -69,7 +69,7 @@ func TestPlanFamiliesStartExpandedAndEnterOpensThePage(t *testing.T) { }) } -func TestPlanPageUnderItDrawsWholeSubtreeLiveLinesAndReverseWaitCounts(t *testing.T) { +func TestPlanRoomUnderItDrawsWholeSubtreeWithLiveLines(t *testing.T) { root := session.PlanTaskRow{ID: "t-root", Title: "Root", Status: "running"} handler := session.PlanTaskRow{ID: "t-handler", Title: "Handler", Parent: root.ID, Status: "running"} handler.Live.Step, handler.Live.Command = 2, "go test ./internal/auth/..." @@ -78,27 +78,23 @@ func TestPlanPageUnderItDrawsWholeSubtreeLiveLinesAndReverseWaitCounts(t *testin kids := []session.PlanTaskRow{handler, fixtures, tests} pages := map[string]session.PlanTaskPage{root.ID: {Row: root, Children: kids}} a, _ := planAppWith(t, append([]session.PlanTaskRow{root}, kids...), pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - text := taskSheetText(a) + a.height = 40 + openPlanRoomNow(t, a, root.ID) + text := planRoomText(t, a) // THE PARTS ARE THE RAIL'S ROWS: the part in flight names its call the way a // node row names one, and a part's row carries no count of the work queued // behind it, because a node row never did. for _, want := range []string{"under it", "Handler", "Fixtures", "Tests", "bash go test ./internal/auth/..."} { if !strings.Contains(text, want) { - t.Fatalf("the subtree page is missing %q:\n%s", want, text) + t.Fatalf("the run's room is missing %q:\n%s", want, text) } } - lines := strings.Split(text, "\n") var handlerLead, fixtureLead int - for _, line := range lines { - plain := ansi.Strip(line) - if at := strings.Index(plain, "Handler"); at >= 0 { + for _, line := range strings.Split(text, "\n") { + if at := strings.Index(line, "Handler"); at >= 0 { handlerLead = at } - if at := strings.Index(plain, "Fixtures"); at >= 0 { + if at := strings.Index(line, "Fixtures"); at >= 0 { fixtureLead = at } } @@ -107,7 +103,10 @@ func TestPlanPageUnderItDrawsWholeSubtreeLiveLinesAndReverseWaitCounts(t *testin } } -func TestEnterOnSubtreeRowOpensItAndEscapeReturnsToCallingPage(t *testing.T) { +// A PART'S ROOM HANGS UNDER THE RUN'S ON ITS TRAIL, and the crumb is a door +// onto the run's own room. `esc` leaves for the conversation, as it does from +// every room. +func TestAPartsRoomNamesItsParentOnTheTrailAndTheCrumbOpensIt(t *testing.T) { root := session.PlanTaskRow{ID: "t-root", Title: "Root page", Status: "running"} child := session.PlanTaskRow{ID: "t-child", Title: "Child page", Parent: root.ID, Status: "pending"} pages := map[string]session.PlanTaskPage{ @@ -115,74 +114,51 @@ func TestEnterOnSubtreeRowOpensItAndEscapeReturnsToCallingPage(t *testing.T) { child.ID: {Row: child}, } a, _ := planAppWith(t, []session.PlanTaskRow{root, child}, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") + openPlanRoomNow(t, a, child.ID) + if plan := a.roomPlan(); plan == nil || plan.id != child.ID { + t.Fatal("the part's room did not open") + } + if trail := roomTrailText(t, a); !strings.Contains(trail, "Root page"+roomCrumbSep+"Child page") { + t.Fatalf("the part's room has no parent crumb:\n%s", trail) } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - drive(t, a, tea.KeyPressMsg{Code: tea.KeyDown}) - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != child.ID { - t.Fatalf("enter on the subtree row stayed on %q", a.taskSheet.plan.Row.ID) + var parent roomCrumb + for _, crumb := range a.roomCrumbs() { + if crumb.word == "Root page" { + parent = crumb + } + } + if !parent.door() { + t.Fatalf("the parent crumb is not a door: %+v", parent) } - if text := taskSheetText(a); !strings.Contains(text, "Root page"+roomCrumbSep+"Child page") { - t.Fatalf("the child page has no parent breadcrumb:\n%s", text) + spend(t, a, a.openRailRoom(parent.node)) + if plan := a.roomPlan(); plan == nil || plan.id != root.ID { + t.Fatal("the parent crumb did not open the run's own room") } drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != root.ID { - t.Fatalf("esc did not return to the calling page: on=%v row=%q", a.taskSheet.planOn, a.taskSheet.plan.Row.ID) + if a.roomOpen() { + t.Fatal("esc did not leave the room for the conversation") } } -func TestPlanPageWaitsOwnFirstThenTasksWaitingOnItAndOmitsEmptySection(t *testing.T) { +func TestPlanRoomWaitsOwnFirstThenTasksWaitingOnItAndOmitsEmptySection(t *testing.T) { handler := session.PlanTaskRow{ID: "t-handler", Title: "write the handler", Status: "running", Steps: 12} tests := session.PlanTaskRow{ID: "t-tests", Title: "write the tests", Status: "pending", Waits: []string{handler.ID}} fixtures := session.PlanTaskRow{ID: "t-fixtures", Title: "write the fixtures", Status: "pending", Waits: []string{tests.ID}} page := session.PlanTaskPage{Row: tests, WaitRows: []session.PlanTaskRow{handler, fixtures}} a, _ := planAppWith(t, []session.PlanTaskRow{handler, tests, fixtures}, map[string]session.PlanTaskPage{tests.ID: page}) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, a.taskSheetPlan(tests.ID)()) - text := taskSheetText(a) - own := padTo("write the tests · waits: write the handler", 51) + tierGlyph(a.pal, planStatus(handler)) + " 12 steps" - behind := padTo("write the fixtures · waits: write the tests", 51) + tierGlyph(a.pal, planStatus(fixtures)) + " queued" - if !strings.Contains(text, "waits") || strings.Index(text, own) < 0 || strings.Index(text, behind) < 0 || strings.Index(text, own) > strings.Index(text, behind) { + a.height = 40 + openPlanRoomNow(t, a, tests.ID) + text := planRoomText(t, a) + own := strings.Index(text, "write the tests · waits: write the handler") + behind := strings.Index(text, "write the fixtures · waits: write the tests") + if !strings.Contains(text, "waits") || own < 0 || behind < 0 || own > behind || !strings.Contains(text, "12 steps") { t.Fatalf("waits is not own-first in each row's sentence shape:\n%s", text) } leaf := session.PlanTaskRow{ID: "t-leaf", Title: "leaf", Status: "done"} - a, _ = planAppWith(t, []session.PlanTaskRow{leaf}, map[string]session.PlanTaskPage{leaf.ID: {Row: leaf}}) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if text := taskSheetText(a); strings.Contains(text, "\n waits\n") { - t.Fatalf("an empty waits section was drawn:\n%s", text) - } -} - -func TestPlanPageHeaderCountsRunningAndQueuedOverSubtreeDroppingZeros(t *testing.T) { - root := session.PlanTaskRow{ID: "t-root", Title: "root", Status: "done"} - kids := []session.PlanTaskRow{ - {ID: "t-running", Title: "running child", Parent: root.ID, Status: "running"}, - {ID: "t-queued", Title: "queued child", Parent: root.ID, Status: "pending"}, - {ID: "t-done", Title: "done child", Parent: root.ID, Status: "done"}, - } - a, _ := planAppWith(t, append([]session.PlanTaskRow{root}, kids...), map[string]session.PlanTaskPage{root.ID: {Row: root, Children: kids}}) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if text := taskSheetText(a); !strings.Contains(text, "1 running · 1 queued") { - t.Fatalf("header does not count the subtree:\n%s", text) - } - - a, _ = planAppWith(t, []session.PlanTaskRow{root}, map[string]session.PlanTaskPage{root.ID: {Row: root}}) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if text := taskSheetText(a); strings.Contains(text, "0 running") || strings.Contains(text, "0 queued") { - t.Fatalf("zero header figures were drawn:\n%s", text) + a, _ = planAppWith(t, []session.PlanTaskRow{leaf}, map[string]session.PlanTaskPage{leaf.ID: {Row: leaf, Description: "the leaf's brief"}}) + openPlanRoomNow(t, a, leaf.ID) + if text := planRoomText(t, a); strings.Contains(text, "waits") || strings.Contains(text, "under it") { + t.Fatalf("an empty section was drawn:\n%s", text) } } diff --git a/internal/tui3/c263_rail_plan_test.go b/internal/tui3/c263_rail_plan_test.go index 0a66dc886..3b391a145 100644 --- a/internal/tui3/c263_rail_plan_test.go +++ b/internal/tui3/c263_rail_plan_test.go @@ -193,36 +193,39 @@ func TestARunsPartsHangUnderTheNodeRowThatCarriesIt(t *testing.T) { } } -// A RUN'S PAGE WEARS THE TASK ROOM'S HEAD: the trail with the way back at its -// end, and the facts rule led by the state's own mark — the spinner, the one a -// working row wears on the rail — then the clock and the steps, with the money -// at the far end. The figures the store has not got are absent, never zero. -func TestARunsPageWearsTheTaskRoomsHead(t *testing.T) { +// A RUN'S TASK OPENS THE TASK ROOM AND WEARS ITS HEAD: the trail with the way +// back at its end, and the facts with the clock, the steps and the money. The +// figures the store has not got are absent, never zero. +func TestARunsTaskWearsTheTaskRoomsHead(t *testing.T) { row := session.PlanTaskRow{ID: "t-alpha", Title: "Alpha", Status: "claimed", Steps: 17, USD: 0.02, Started: taskFixtureNow.Add(-4 * time.Minute)} pages := map[string]session.PlanTaskPage{"t-alpha": {Row: row, Description: "the work order", Steps: []session.PlanStep{{Step: 1, Command: "gh issue list", Observation: "12 issues"}}}} a, _ := planAppWith(t, []session.PlanTaskRow{row}, pages) - a.paints = 0 - openPlanPage(t, a) - lines := strings.Split(taskSheetText(a), "\n") - if !strings.Contains(lines[0], roomCrumbSep+"Alpha") || !strings.HasSuffix(strings.TrimRight(lines[0], " "), taskCardBackWord) { - t.Fatalf("the page's first row is not the trail with the way back:\n%s", strings.Join(lines, "\n")) + a.height = 40 + openPlanRoomNow(t, a, "t-alpha") + head := plain(strings.Join(a.roomHeadRows(a.width), "\n")) + lines := strings.Split(head, "\n") + if !strings.Contains(head, "Alpha") || !strings.HasSuffix(strings.TrimRight(lines[0], " "), roomBackWord) { + t.Fatalf("the room's head is not the trail with the way back over the task:\n%s", head) + } + for _, want := range []string{"4m", "$0.02"} { + if !strings.Contains(head, want) { + t.Fatalf("the room's head does not say %q:\n%s", want, head) + } } - lead := "─ " + tokens.Spinner(0) + " running 4m" + rowSep + "17 steps " - if !strings.HasPrefix(lines[1], lead) || !strings.HasSuffix(lines[1], " $0.02 ─") { - t.Fatalf("the page's second row is not the facts rule %q … $0.02:\n%s", lead, strings.Join(lines, "\n")) + if got := roomStepsWord(a); got != "17 steps" { + t.Fatalf("the room's facts count %q, want the store's 17 steps", got) } - shell := a.actionLead(session.ActionRun, true) - if text := strings.Join(lines, "\n"); !strings.Contains(text, shell+"gh issue list") || !strings.Contains(text, " 12 issues") { - t.Fatalf("the page's step is not the room's shell row with its head under it:\n%s", text) + if got := roomCommands(a); len(got) != 1 || got[0] != "gh issue list" { + t.Fatalf("the task's step is not the room's shell row: %q", got) } // AND A TASK THAT HAS NOT STARTED OR SPENT SAYS NEITHER. bare := session.PlanTaskRow{ID: "t-beta", Title: "Beta", Status: "pending"} b, _ := planAppWith(t, []session.PlanTaskRow{bare}, map[string]session.PlanTaskPage{"t-beta": {Row: bare}}) - openPlanPage(t, b) - facts := strings.Split(taskSheetText(b), "\n")[1] + openPlanRoomNow(t, b, "t-beta") + facts := plain(strings.Join(b.roomHeadRows(b.width), "\n")) for _, zero := range []string{"$0", "0 steps", "0s"} { if strings.Contains(facts, zero) { t.Fatalf("an unstarted task's facts rule says %q:\n%s", zero, facts) @@ -232,8 +235,9 @@ func TestARunsPageWearsTheTaskRoomsHead(t *testing.T) { // A PART WITH NO STEP IN FLIGHT DRAWS NO LIVE LINE. The page used to draw the // running mark and the shell lead under a part that had landed, with nothing -// after them — `◑ $` — because the line was drawn whatever the part's live -// step said. A part is the rail's row now, and a row with no call says none. +// after them (`◑ $`), because the line was drawn whatever the part's live +// step said. A part in the room is the rail's row, and a row with no call says +// none. func TestAPartWithNoStepInFlightDrawsNoLiveLine(t *testing.T) { root := session.PlanTaskRow{ID: "t-root", Title: "Root", Status: "claimed"} landed := session.PlanTaskRow{ID: "t-landed", Parent: "t-root", Title: "Landed part", Status: "done"} @@ -241,10 +245,11 @@ func TestAPartWithNoStepInFlightDrawsNoLiveLine(t *testing.T) { empty.Live.Step = 3 pages := map[string]session.PlanTaskPage{"t-root": {Row: root, Children: []session.PlanTaskRow{landed, empty}}} a, _ := planAppWith(t, []session.PlanTaskRow{root, landed, empty}, pages) - openPlanPage(t, a) - text := taskSheetText(a) + a.height = 40 + openPlanRoomNow(t, a, "t-root") + text := planRoomText(t, a) if !strings.Contains(text, "Landed part") || !strings.Contains(text, "Empty call") { - t.Fatalf("the page lacks its parts:\n%s", text) + t.Fatalf("the room lacks its parts:\n%s", text) } for _, line := range strings.Split(text, "\n") { trimmed := strings.TrimSpace(strings.TrimLeft(line, " │├└─")) diff --git a/internal/tui3/c295_rail_task_page_test.go b/internal/tui3/c295_rail_task_page_test.go index 996a5d606..3c031972e 100644 --- a/internal/tui3/c295_rail_task_page_test.go +++ b/internal/tui3/c295_rail_task_page_test.go @@ -62,11 +62,11 @@ func TestRailClickReadsTheStoreAtTheGestureAndOpensTheTaskPage(t *testing.T) { if fake.pages != 1 { t.Fatalf("rail click read task pages %d times, want once at the gesture", fake.pages) } - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != "2" || a.roomOpen() { - t.Fatalf("rail click opened plan=%v id=%q room=%v", a.taskSheet.planOn, a.taskSheet.plan.Row.ID, a.roomOpen()) + if plan := a.roomPlan(); plan == nil || plan.id != "2" { + t.Fatalf("rail click did not open the task room over store task 2: room=%v", a.roomOpen()) } - if got := taskSheetText(a); !strings.Contains(got, "replace the parser") { - t.Fatalf("the rail did not open taskPlanBody:\n%s", got) + if got := planRoomText(t, a); !strings.Contains(got, "replace the parser") { + t.Fatalf("the rail did not open the task's room:\n%s", got) } } @@ -74,8 +74,8 @@ func TestRailEnterReadsTheStoreAtTheGestureAndOpensTheTaskPage(t *testing.T) { a, fake := railTaskPageApp(t, true) a.railWhere, a.railHold = railSpot{id: 2}, true drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if fake.pages != 1 || !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != "2" || a.roomOpen() { - t.Fatalf("rail enter reads=%d plan=%v id=%q room=%v", fake.pages, a.taskSheet.planOn, a.taskSheet.plan.Row.ID, a.roomOpen()) + if plan := a.roomPlan(); fake.pages != 1 || plan == nil || plan.id != "2" { + t.Fatalf("rail enter reads=%d room=%v", fake.pages, a.roomOpen()) } } @@ -85,8 +85,8 @@ func TestRailRowWithoutAStoredPageStillOpensItsRoom(t *testing.T) { if fake.pages != 1 { t.Fatalf("room fallback did %d page reads, want one gesture read", fake.pages) } - if a.taskSheet.planOn || !a.roomOpen() || a.room.id != 2 { - t.Fatalf("absent page opened plan=%v room=%v id=%d", a.taskSheet.planOn, a.roomOpen(), roomID(a)) + if a.roomPlan() != nil || !a.roomOpen() || a.room.id != 2 { + t.Fatalf("absent page opened room=%v id=%d", a.roomOpen(), roomID(a)) } } @@ -107,17 +107,16 @@ func TestRailTaskPageSurvivesSettledFramesAndFramesDoNotReadTheAgent(t *testing. drive(t, a, frameMsg{}) frame, _, _ := a.frame() if !strings.Contains(plain(frame), "replace the parser") { - t.Fatalf("a settled frame closed the task page:\n%s", plain(frame)) + t.Fatalf("a settled frame closed the task room:\n%s", plain(frame)) } } - // A PAGE ON A RUNNING TASK FOLLOWS IT, one read at a time on the paint clock, - // and the read that finds the task settled is the last: ten frames on a - // settled page cost that one read and no more, and no frame reads the rows. - if fake.rows != reads || fake.pages > pages+1 { - t.Fatalf("a settled page kept calling the agent: row reads %d→%d, page reads %d→%d", reads, fake.rows, pages, fake.pages) + // A ROOM ON A RUNNING TASK FOLLOWS IT ON ITS OWN BEAT, never on the paint + // clock: ten frames cost no read of the page and no read of the rows. + if fake.rows != reads || fake.pages != pages { + t.Fatalf("frames called the agent: row reads %d→%d, page reads %d→%d", reads, fake.rows, pages, fake.pages) } - if !a.taskSheet.planOn { - t.Fatal("ten settled frames closed the stored task page") + if a.roomPlan() == nil { + t.Fatal("ten settled frames closed the task room") } } @@ -126,24 +125,26 @@ func TestEscFromARailTaskPageReturnsExactlyToTheConversation(t *testing.T) { a.input.value = []rune("draft stays here") clickRail(t, a, 0) drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) - if a.taskSheet.planOn || a.at(pageTasks) || a.roomOpen() { - t.Fatalf("esc left plan=%v tasks=%v room=%v", a.taskSheet.planOn, a.at(pageTasks), a.roomOpen()) + if a.at(pageTasks) || a.roomOpen() { + t.Fatalf("esc left tasks=%v room=%v", a.at(pageTasks), a.roomOpen()) } if got := string(a.input.value); got != "draft stays here" { t.Fatalf("esc returned with draft %q", got) } } -func TestAChildRemainsOpenableFromARailTaskPage(t *testing.T) { +func TestAChildRemainsOpenableFromARailTaskRoom(t *testing.T) { a, fake := railTaskPageApp(t, true) clickRail(t, a, 0) - a.taskSheet.planAt = 0 - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if fake.pages != 2 || a.taskSheet.plan.Row.ID != "3" || len(a.taskSheet.planBack) != 1 { - t.Fatalf("child open reads=%d id=%q back=%d", fake.pages, a.taskSheet.plan.Row.ID, len(a.taskSheet.planBack)) + if got := planRoomText(t, a); !strings.Contains(got, "cover the parser") { + t.Fatalf("the run's room does not draw its part:\n%s", got) + } + openPlanRoomNow(t, a, "3") + if plan := a.roomPlan(); fake.pages != 2 || plan == nil || plan.id != "3" { + t.Fatalf("child open reads=%d room=%v", fake.pages, a.roomOpen()) } - if got := taskSheetText(a); !strings.Contains(got, "the child page") { - t.Fatalf("child taskPlanBody was not drawn:\n%s", got) + if got := planRoomText(t, a); !strings.Contains(got, "the child page") { + t.Fatalf("the child's room was not drawn:\n%s", got) } } @@ -199,11 +200,11 @@ func TestARunIsItsOwnRowWithItsPartsUnderItAndEachOpensItsPage(t *testing.T) { t.Fatal("a press on a part's row was not the rail's") } drain(t, a, cmd) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != "p2" || !a.railTaskPlanOn { - t.Fatalf("the press opened plan=%v task=%q over the chat=%v, want the second part's page", a.taskSheet.planOn, a.taskSheet.plan.Row.ID, a.railTaskPlanOn) + if plan := a.roomPlan(); plan == nil || plan.id != "p2" { + t.Fatalf("the press opened room=%v, want the second part's room", a.roomOpen()) } - if got := taskSheetText(a); !strings.Contains(got, "the second part's own page") { - t.Fatalf("the page drawn is not the part's:\n%s", got) + if got := planRoomText(t, a); !strings.Contains(got, "the second part's own page") { + t.Fatalf("the room drawn is not the part's:\n%s", got) } } @@ -214,8 +215,8 @@ func TestARunIsItsOwnRowWithItsPartsUnderItAndEachOpensItsPage(t *testing.T) { func TestANoteTypedOnARailTaskPageNeverRaisesTheStopCard(t *testing.T) { a, _ := railTaskPageApp(t, true) clickRail(t, a, 0) - if !a.railTaskPlanOn { - t.Fatal("the rail row did not open its page") + if a.roomPlan() == nil { + t.Fatal("the rail row did not open its room") } for _, r := range "an example" { drive(t, a, key(string(r))) @@ -223,8 +224,8 @@ func TestANoteTypedOnARailTaskPageNeverRaisesTheStopCard(t *testing.T) { if a.stopping() { t.Fatal("a letter in a note raised the stop card") } - if got := a.taskSheet.planNote.String(); got != "an example" { - t.Fatalf("the note box holds %q, want %q", got, "an example") + if got := string(a.input.value); got != "an example" { + t.Fatalf("the room's box holds %q, want %q", got, "an example") } } @@ -269,10 +270,10 @@ func TestALetterTypedWhileARailPageOpensNeverRaisesTheStopCard(t *testing.T) { } close(held.release) drive(t, a, <-answer) - if !a.railTaskPlanOn { - t.Fatal("the answer did not open the page") + if a.roomPlan() == nil { + t.Fatal("the answer did not open the room") } - if got := a.taskSheet.planNote.String(); got != "an example" { - t.Fatalf("the page's box holds %q, want every key typed while it opened: %q", got, "an example") + if got := string(a.input.value); got != "an example" { + t.Fatalf("the room's box holds %q, want every key typed while it opened: %q", got, "an example") } } diff --git a/internal/tui3/c331_stopped_plan_test.go b/internal/tui3/c331_stopped_plan_test.go index 3f01fdeda..38b93c1a7 100644 --- a/internal/tui3/c331_stopped_plan_test.go +++ b/internal/tui3/c331_stopped_plan_test.go @@ -24,10 +24,10 @@ func assertDrawnPlanWord(t *testing.T, text, title, want string) { func TestAStoppedRootPageDrawsStopped(t *testing.T) { row := session.PlanTaskRow{ID: "t-root", Title: "the run", Status: "cancelled", Stopped: true} a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"t-root": {Row: row}}) - openPlanPage(t, a) - text := taskSheetText(a) + openPlanRoomNow(t, a, "t-root") + text := planRoomText(t, a) if !strings.Contains(text, "stopped") || strings.Contains(text, "incomplete") { - t.Fatalf("the stopped root page draws the wrong state word:\n%s", text) + t.Fatalf("the stopped root's room draws the wrong state word:\n%s", text) } } @@ -65,7 +65,7 @@ func TestAStoppedRootCrossesTheHostedWireAndDrawsStopped(t *testing.T) { t.Fatal(err) } openHostedPage(t, a) - text := taskSheetText(a) + text := planRoomText(t, a) if !strings.Contains(text, "stopped") || strings.Contains(text, "incomplete") { t.Fatalf("the hosted stopped-root page draws the wrong state word:\n%s", text) } diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 835b975d7..d97a52571 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1268,7 +1268,6 @@ func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { if tab.work { return a.openWorkTab() } - a.workTabOn = false a.tabReveal() if tab.start { return a.openChatStart() diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 4e839f5f4..7ff82c0d6 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -352,13 +352,6 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { a.railPlanPending.keys = append(a.railPlanPending.keys, msg) return nil } - if a.railTaskPlanOn && !door { - cmd := a.taskPlanKey(msg) - if !a.taskSheet.planOn { - a.railTaskPlanOn = false - } - return cmd - } // A key ends an opened tile's zoom, so what it types is drawn whole. a.wallZoomDone() // The team switcher is a menu, and a menu has the keyboard while it is up @@ -372,9 +365,6 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { if !door && wallOpenPressed(msg) { return a.openWall() } - if a.workTabOn { - return a.workTabKey(msg) - } if cmd, taken := a.pasteChipKey(msg); taken { return cmd } diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index 60b50aebf..d6d4e3898 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -433,6 +433,8 @@ func TestClosingTheLineStillAsksWhatIsInIt(t *testing.T) { // end (#919). var doorsBesideTheLine = map[string]string{ "PlanRunSummary": "reads the run's stored summary for a refresh nobody pressed for", + "PlanTaskPage": "reads an open task room's page again on the room's own beat; nobody pressed for it, and a note's own read is asked on the ordered line", + "PlanTaskWork": "reads the run's working copy for the work tab; it changes nothing, and no later gesture waits on it", "PlanTasks": "reads the run's rows for the side list after a message; nobody pressed for it, and a verb's own read is asked only once the verb has landed", "RefreshRunSummary": "asks a model for the run's summary under a budget of seconds; nobody pressed for it and no gesture depends on it", "NameTeam": "asks a model for a suggested team name under a budget of seconds; it changes nothing on the engine, typing overrides it, and a message sent while it thinks must not wait behind it", diff --git a/internal/tui3/onetaskpage_test.go b/internal/tui3/onetaskpage_test.go new file mode 100644 index 000000000..8b71f7050 --- /dev/null +++ b/internal/tui3/onetaskpage_test.go @@ -0,0 +1,87 @@ +package tui3 + +// ONE TASK PAGE. The owner's ruling on the two pages a task could open — the +// task room for a node of this window's graph, and a store-backed page for a +// task on the run engine that looked like the room and was not one — was that +// they should be the same. These tests hold the ruling: a task on either +// engine opens the same room with the same two tabs, and a line typed into a +// run's task's room reaches its store as a note. + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// roomTrailText is the room's trail row as it is drawn, without its inks. +func roomTrailText(t *testing.T, a *app) string { + t.Helper() + frame, _, _ := a.frame() + rows := strings.Split(plain(frame), "\n") + at := a.roomHeadRow() + if at < 0 || at >= len(rows) { + t.Fatalf("no trail row at %d in a frame of %d rows", at, len(rows)) + } + return rows[at] +} + +func TestANewEngineTaskAndAnOlderEngineTaskOpenTheSameRoomWithTheSameTabs(t *testing.T) { + for _, tc := range []struct { + name string + stored bool + work string + }{ + // A task on the run engine has a page in the store; its work tab reads + // the run's working copy, which this fixture's engine has no door onto. + {name: "new engine", stored: true, work: "this engine does not read the run's working copy"}, + // A task on the older engine has none, and opens the room it always did; + // its work tab lists the files its record says it wrote. + {name: "older engine", stored: false, work: "the files this task changes are listed here when it lands"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _ := railTaskPageApp(t, tc.stored) + clickRail(t, a, 0) + if !a.roomOpen() { + t.Fatalf("a press on the %s task's row opened no task room", tc.name) + } + trail := roomTrailText(t, a) + for _, word := range []string{"transcript", "work"} { + if !strings.Contains(trail, word) { + t.Fatalf("the %s task's room has no %q tab on its trail row:\n%s", tc.name, word, trail) + } + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + if !a.roomOpen() { + t.Fatalf("tab over an empty box left the %s task's room", tc.name) + } + frame, _, _ := a.frame() + if !strings.Contains(plain(frame), tc.work) { + t.Fatalf("tab did not open the %s task's work tab (want %q):\n%s", tc.name, tc.work, plain(frame)) + } + }) + } +} + +func TestAMessageTypedIntoANewEngineTasksRoomReachesItsStoreAsANote(t *testing.T) { + a, fake := railTaskPageApp(t, true) + clickRail(t, a, 0) + if !a.roomOpen() { + t.Fatal("a press on a run's task's row opened no task room") + } + typeText(t, a, "use the fast parser") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 1 || fake.noted[0].id != "2" || fake.noted[0].text != "use the fast parser" { + t.Fatalf("the line did not reach the store as one note on task 2: %+v", fake.noted) + } + if len(fake.sent) != 0 { + t.Fatalf("a note typed into a task's room started a chat turn: %q", fake.sent) + } + if !a.roomOpen() { + t.Fatal("sending the note left the room") + } + frame, _, _ := a.frame() + if !strings.Contains(plain(frame), "use the fast parser") { + t.Fatalf("the note is not on the room it was typed into:\n%s", plain(frame)) + } +} diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 60f2244fe..96c88d9eb 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -100,47 +100,6 @@ type tasksPlace struct { // node made it, and the card is read rather than walked, so an offset is the // only thing that moves ([clampTop], expand.go). detailTop int - // plan is the run's plan page standing over the list — the description, the - // notes and the trajectory one plan task carries — and planOn says it is up. - // It is the SAME latch the record card uses ([tasksPlace.detailOn]) and draws - // in its place: enter over a plan row opens it and esc backs out one layer to - // the list, exactly as the card does, because a second key for one door is a - // second thing to learn ([app.taskSheetPlan]). - plan session.PlanTaskPage - planOn bool - planBriefFull bool - planAt int - planBack []session.PlanTaskPage - // planNote is the note a person types on a plan task's page, and it is the - // [editor] every other box on this surface is rather than a string of its own - // (the filter is one, and so is the conversation's composer). Typing on the - // page lands here and `enter` sends it through the store's note verb - // ([app.taskPlanNoteSend]); the row's own keys are read over an EMPTY box, so - // a note that starts with `p` or `x` is a letter the moment it has one. - planNote editor - // planSending is a note on its way to the store: set by the `enter` that sent - // it and cleared by the store's answer ([app.taskPlanNoteSend]). While it is - // set, `enter` sends nothing, because the box still holds the words until the - // answer empties it, and a second press sent the same note twice. - planSending bool - // planStick is whether the page is pinned to its live edge — the bottom of - // the trajectory, where the newest step arrives. It is the SAME mechanism the - // room follows its own live edge with ([app.roomOffsetFor] resolves - // [tasksPlace.detailTop] here, [app.taskPlanTopFor] is its twin at this - // offset): a scroll up releases the pin and a scroll back to the bottom takes - // it again. It is beside the offset and not inside it because the bottom moves - // as the body grows, and a pinned page resolves to wherever the bottom now is - // rather than to the number it was last drawn at. - planStick bool - // planFollowing is a follow read that has been asked and has not come back. - // THE PAINT CLOCK ASKS AT MOST ONE AT A TIME: over a slow link a read per - // frame would stand in the door line in front of the key a person presses - // next, and every one of them would answer the same page. - planFollowing bool - // planPageAt is when the open page was last read, for any reason: the read - // that opened it, a follow, the re-read after a note. The follow's beat is - // counted from it ([app.taskPlanFollow]). - planPageAt time.Time // planGen is the read of the run's rows this reading was filed from // ([app.planRowsGen]); a newer one re-files it ([tasksPlace.regroup]). planGen uint64 @@ -943,9 +902,6 @@ func (a *app) taskSheetKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { // which backs out one layer to the list rather than closing the page // (taskrecord.go). if a.taskSheet.detailOn { - if a.taskSheet.planOn { - return a.taskPlanKey(msg), true - } return a.taskCardKey(key), true } // THE CARET'S OWN CHORDS BEFORE THE PAGE'S KEYS (editkeys.go). `home` and @@ -1116,11 +1072,13 @@ func (p *tasksPlace) enter(a *app) tea.Cmd { if !ok { return nil } - // A PLAN ROW OPENS ITS OWN PAGE — the description, the notes and the - // trajectory the store carries — through the sheet's own machinery and the - // same key a record row opens its card with ([app.taskSheetPlan]). + // A PLAN ROW OPENS ITS TASK'S ROOM, the one page every task has + // (planroom.go): the place steps aside for the conversation the room is + // drawn over, and the room reads the store. if item.plan != nil { - return a.taskSheetPlan(item.plan.ID) + id := item.plan.ID + a.closeTaskSheet() + return a.openRailPlan(id, nil) } if item.away { // THE LADDER IS WALKED BEFORE THE CARD IS DRAWN, which is the whole of @@ -1179,11 +1137,6 @@ func (a *app) taskSheetInside(entry *session.TaskIndexEntry) tea.Cmd { // over an ordinary record row can never inherit the recovery band of the away // row somebody opened before it ([app.taskSheetAwayCard] sets it back after). a.taskSheet.awayOwner = tasksAwayOwner{} - // AND THE PLAN LATCH IS CLEARED WITH IT, so the card can never be drawn in the - // plan page's place ([app.taskSheetPlan] sets it for the one row that opens a - // plan task's page). - a.taskSheet.plan, a.taskSheet.planOn = session.PlanTaskPage{}, false - a.taskSheet.planNote.reset() return a.readTaskTail(*entry) } @@ -1950,12 +1903,6 @@ func (placeTasks) ownFrame(a *app, width, height int) ([]string, []placeHit, int if !a.taskSheet.detailOn { return nil, nil, 0, 0, false } - // THE PLAN PAGE DRAWS IN THE CARD'S PLACE, through the same latch and the - // same frame slot ([app.taskPlanFrame]). - if a.taskSheet.planOn { - lines, caretX, caretY := a.taskPlanFrame(width, height) - return lines, nil, caretX, caretY, true - } // NOTHING ON THE CARD IS TYPED INTO, so the caret is hidden rather than // parked at the frame's origin over the title — the same law the job page // and home at rest follow (view.go states it in [app.frameBody]), and the @@ -2014,9 +1961,6 @@ func (placeTasks) owns(a *app, msg tea.KeyPressMsg) (tea.Cmd, bool) { if cmd, took := a.placeKey(msg); took { return cmd, true } - if a.taskSheet.planOn { - return a.taskPlanKey(msg), true - } return a.taskCardKey(msg.String()), true } diff --git a/internal/tui3/planbeat_host_test.go b/internal/tui3/planbeat_host_test.go index 5b12b70f1..828483ef8 100644 --- a/internal/tui3/planbeat_host_test.go +++ b/internal/tui3/planbeat_host_test.go @@ -180,8 +180,8 @@ func TestHostedRailPageOwnsKeysWhileItsReadIsInFlight(t *testing.T) { } close(held.release) drive(t, a, <-answer) - if !a.taskSheet.planOn || !a.railTaskPlanOn { - t.Fatal("the delayed answer did not open the task page") + if a.roomPlan() == nil { + t.Fatal("the delayed answer did not open the task room") } } @@ -189,7 +189,7 @@ func TestHostedRailPagePendingEscCancelsAndSecondPressSupersedes(t *testing.T) { a, _, _ := hostedPlanApp(t, false) a.railPlanPending = railPlanPending{id: "first", keys: []tea.KeyPressMsg{key("x")}} drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) - if a.railPlanPending.id != "" || a.taskSheet.planOn { + if a.railPlanPending.id != "" || a.roomOpen() { t.Fatal("esc did not cancel the pending page") } a.railPlanPending = railPlanPending{id: "first", keys: []tea.KeyPressMsg{key("x")}} @@ -199,8 +199,8 @@ func TestHostedRailPagePendingEscCancelsAndSecondPressSupersedes(t *testing.T) { } } -// openHostedPage opens the run's own page the way `enter` on its row does, over -// the real wire, and answers the run's id. +// openHostedPage opens the run's own task room the way a press on its row does, +// over the real wire, and answers the run's id. func openHostedPage(t *testing.T, a *app) string { t.Helper() // THE RUN'S SUMMARY IS KEPT OUT OF A TEST THAT COUNTS THE PAGE'S READS. The @@ -213,26 +213,24 @@ func openHostedPage(t *testing.T, a *app) string { t.Fatal("the first reading did not hold the run's row") } id := a.taskSheet.mine.plan[0].ID - cmd := a.taskSheetPlan(id) - if cmd == nil { - t.Fatal("the page was not asked for") - } - drive(t, a, cmd()) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != id { - t.Fatal("the run's page did not open") + openPlanRoomNow(t, a, id) + if plan := a.roomPlan(); plan == nil || plan.id != id { + t.Fatal("the run's task room did not open") } return id } -// paintTicks is the paint clock turning `ticks` times inside one beat: every -// tick offers the page its follow, and whatever the follow asks is answered and -// folded before the next tick, the way a fast local engine answers. +// paintTicks is the paint clock turning `ticks` times inside one beat and then +// the room's own beat once: a frame never reads the page, and the beat reads it +// once, answered and folded before the next, the way a fast local engine +// answers. func paintTicks(t *testing.T, a *app, ticks int) { t.Helper() for tick := 0; tick < ticks; tick++ { - if cmd := a.taskPlanFollow(); cmd != nil { - drive(t, a, cmd()) - } + drive(t, a, frameMsg{}) + } + if a.room != nil { + drive(t, a, planRoomTickMsg{gen: a.room.gen}) } } @@ -247,8 +245,8 @@ func TestAnOpenPageOnARunningTaskReadsOncePerBeat(t *testing.T) { now := taskFixtureNow a.clock = func() time.Time { return now } openHostedPage(t, a) - if !a.taskPlanFollows() { - t.Fatalf("the fixture's run is %q, which the page does not follow, so the test proves nothing", a.taskSheet.plan.Row.Status) + if !a.planRoomRunning() { + t.Fatalf("the fixture's run is %q, which the room does not follow, so the test proves nothing", a.room.plan.page.Row.Status) } opened := counted.pages.Load() const beats = 5 @@ -256,7 +254,6 @@ func TestAnOpenPageOnARunningTaskReadsOncePerBeat(t *testing.T) { paintTicks(t, a, 20) now = now.Add(elsewhereEvery) } - paintTicks(t, a, 20) if got := counted.pages.Load() - opened; got != beats { t.Fatalf("an open page on a moving task crossed the wire %d times over %d beats of twenty ticks each, want one read a beat", got, beats) } @@ -328,7 +325,7 @@ func TestHostedPlanPartsCrossTheWireAndFilterThePage(t *testing.T) { } openHostedPage(t, a) - screen := taskSheetText(a) + screen := planRoomText(t, a) for _, never := range []string{workspace, "plandb task overview", "recorded-output"} { if strings.Contains(screen, never) { t.Fatalf("hosted page contains filtered %q:\n%s", never, screen) @@ -376,20 +373,17 @@ func TestHostedTaskPageOmitsEngineEstablishedNotRunStep(t *testing.T) { t.Fatal(err) } openHostedPage(t, a) - page := taskSheetText(a) - shell := a.actionLead(session.ActionRun, true) - for _, want := range []string{shell + "printf ran-one", shell + "printf ran-three", shell + "printf older-record"} { - if !strings.Contains(page, want) { - t.Fatalf("hosted page lost %q:\n%s", want, page) - } + page := roomCallText(t, a) + if got, want := roomCommands(a), []string{"printf ran-one", "printf ran-three", "printf older-record"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("hosted room's calls are %q, want %q", got, want) } for _, forbidden := range []string{"cat first second third", "[not run]", "no action executed", "this belt has one hand"} { if strings.Contains(page, forbidden) { t.Fatalf("hosted page drew engine-established refusal text %q:\n%s", forbidden, page) } } - if !strings.Contains(page, "4 steps") { - t.Fatalf("the record count changed when a row was omitted:\n%s", page) + if got := roomStepsWord(a); got != "4 steps" { + t.Fatalf("the record count changed when a row was omitted: %q", got) } } @@ -426,23 +420,24 @@ func TestHostedTaskPageDrawsARefusedActionAsOneLineAndACorrectionAsNone(t *testi t.Fatal(err) } openHostedPage(t, a) - page := taskSheetText(a) + page := roomCallText(t, a) refused := taskPlanRefusedWord + railSep + "touch /outside/the-ground" var drawn []string - for _, line := range strings.Split(page, "\n") { + for _, line := range strings.Split(planRoomText(t, a), "\n") { if strings.Contains(line, "touch /outside/the-ground") { - drawn = append(drawn, strings.TrimSpace(line)) + drawn = append(drawn, strings.TrimSpace(strings.TrimRight(line, " │"))) } } - if len(drawn) != 1 || drawn[0] != refused { + if len(drawn) != 1 || !strings.HasSuffix(drawn[0], refused) || strings.ContainsAny(drawn[0], "0123456789") { t.Fatalf("a refused action draws as exactly one line, %q, with no number; drew %q:\n%s", refused, drawn, page) } - shell := a.actionLead(session.ActionRun, true) - for _, want := range []string{shell + "printf ran-one", shell + "printf ran-four", "4 steps"} { - if !strings.Contains(page, want) { - t.Fatalf("the page lost %q:\n%s", want, page) - } + if got, want := roomCommands(a), []string{"printf ran-one", "printf ran-four"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("the room's calls are %q, want %q", got, want) + } + if got := roomStepsWord(a); got != "4 steps" { + t.Fatalf("the room counts %q, want the record's 4 steps", got) } + shell := a.actionLead(session.ActionRun, true) for _, forbidden := range []string{doorSentence, formSentence, "cat first second third", shell + "touch"} { if strings.Contains(page, forbidden) { t.Fatalf("the page drew %q, which is the worker's answer, a correction's row, or a shell mark on a call that never ran:\n%s", forbidden, page) @@ -470,20 +465,11 @@ func TestHostedPageDrawsContinuedTaskStepsOnceInOrder(t *testing.T) { if err := os.WriteFile(filepath.Join(taskDir, "trajectory.jsonl"), []byte(record), 0o600); err != nil { t.Fatalf("record the continued task: %v", err) } - cmd := a.taskSheetPlan(id) - if cmd == nil { - t.Fatal("the continued page was not asked for") - } - drive(t, a, cmd()) + a.closeRoom() + openPlanRoomNow(t, a, id) - var got []string - shell := a.actionLead(session.ActionRun, true) + "printf " - for _, line := range strings.Split(taskSheetText(a), "\n") { - if at := strings.Index(line, shell); at >= 0 { - got = append(got, strings.TrimSpace(line[at+len(shell):])) - } - } - if want := []string{"one", "two", "three", "four", "five"}; strings.Join(got, " ") != strings.Join(want, " ") { - t.Fatalf("drawn step rows = %q, want %q exactly once and in order:\n%s", got, want, taskSheetText(a)) + got := roomCommands(a) + if want := []string{"printf one", "printf two", "printf three", "printf four", "printf five"}; strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("the room's step rows = %q, want %q exactly once and in order", got, want) } } diff --git a/internal/tui3/planrail.go b/internal/tui3/planrail.go index a4ccbacb7..8f00e64b9 100644 --- a/internal/tui3/planrail.go +++ b/internal/tui3/planrail.go @@ -22,9 +22,6 @@ import ( "hash/fnv" "sort" "strings" - "time" - - "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" ) @@ -254,114 +251,3 @@ func (a *app) planRailRoot(twig *planTwig, width int) []railLine { } return append(out, a.planRailLines(twig.kids, nil, false, width)...) } - -// ── THE PAGE A RUN'S ROW OPENS ───────────────────────────────────────────── - -// taskPlanTrail is the page's first row in the task room's shape -// ([app.roomTrailRow]): where this task sits — the conversation, the tasks a -// step into a part came through, and the task itself — with the way back at -// the row's far end. -// -// NONE OF ITS CRUMBS IS A DOOR. The way back is `esc`, named on the key line and -// at this row's end, and a crumb that lit under the hand without going anywhere -// would be a control that lies; so the crumbs are drawn inert and record no hit. -func (a *app) taskPlanTrail(width int) string { - crumbs := []roomCrumb{{word: a.chatCrumbWord(), kind: crumbOwner}} - for _, back := range a.taskSheet.planBack { - crumbs = append(crumbs, roomCrumb{word: strings.TrimSpace(back.Row.Title), kind: crumbAncestor}) - } - crumbs = append(crumbs, roomCrumb{word: strings.TrimSpace(a.taskSheet.plan.Row.Title), kind: crumbHere}) - back := " " + taskCardBackWord + " " - room := width - headLabelAt - ansi.StringWidth(back) - 3 - label, hits, _ := fitCrumbChain(crumbs, max(room, 0)) - if label == "" { - label, hits, _ = fitCrumbChain(crumbs, max(width-headLabelAt, 0)) - } - placed := make([]crumbHit, len(hits)) - for i, hit := range hits { - hit.span = hudSpan{from: hit.span.from + headLabelAt, to: hit.span.to + headLabelAt} - placed[i] = hit - } - line := strings.Repeat(" ", headLabelAt) + a.paintCrumbHits(label, headLabelAt, placed, crumbHit{}, false) - used := headLabelAt + ansi.StringWidth(label) - if used+2+ansi.StringWidth(back)+1 <= width { - from := width - ansi.StringWidth(back) - 1 - return line + strings.Repeat(" ", from-used) + a.pal.dim(back) + " " - } - return line -} - -// taskPlanFacts is the page's second row in the task room's shape -// ([app.roomGroupedFacts]): the rule, led by the state's own mark and word in -// the state's own ink — the spinner every working row on this surface wears — -// then how long it has run, how many steps it has taken and how many of its -// parts are running or queued, and at the far end what it has cost. -// -// EVERY FIGURE IS DROPPED WHEN THE STORE HAS NOT GOT IT, the room's own law per -// segment: a task that has not started has no clock, one that has taken no step -// no count, and one that has spent nothing no price — never `$0.00`. -func (a *app) taskPlanFacts(width int) string { - page := a.taskSheet.plan - row := page.Row - status := planStatus(row) - state := a.tierMark(status) - if word := planStateWord(row); word != "" { - state += " " + word - } - var activity []string - if clock := planClockWord(row, a.now()); clock != "" { - activity = append(activity, clock) - } - if steps := planStepWords(row.Steps); steps != "" { - activity = append(activity, steps) - } - running, queued := 0, 0 - for _, kid := range page.Children { - switch strings.TrimSpace(kid.Status) { - case "ready", "claimed", "running": - running++ - case "pending": - queued++ - } - } - if running > 0 { - activity = append(activity, itoa(running)+" running") - } - if queued > 0 { - activity = append(activity, itoa(queued)+" queued") - } - left := state - if len(activity) > 0 { - left += " " + strings.Join(activity, rowSep) - } - right := planSpendWord(row.USD) - lead := ansi.StringWidth(state) - ink := tierInk(a.pal, status) - paint := func(label string) string { - cols := ansi.StringWidth(label) - if lead >= cols { - return ink(label) - } - return ink(ansi.Cut(label, 0, lead)) + a.pal.muted(ansi.Cut(label, lead, cols)) - } - for _, try := range [][2]string{{left, right}, {left, ""}, {state, right}, {state, ""}} { - if line, _, ok := a.legendLinePainted(try[0], try[1], a.pal.muted(try[1]), width, paint); ok { - return line - } - } - return a.pal.dim(rule(width)) -} - -// planClockWord is how long a store task has run: to now while it is open, to -// its landing once it has one, and nothing when the store never said when it -// started. -func planClockWord(row session.PlanTaskRow, now time.Time) string { - if row.Started.IsZero() { - return "" - } - end := now - if !row.Ended.IsZero() { - end = row.Ended - } - return countUpWord(end.Sub(row.Started)) -} diff --git a/internal/tui3/planroom.go b/internal/tui3/planroom.go new file mode 100644 index 000000000..c3abd10f8 --- /dev/null +++ b/internal/tui3/planroom.go @@ -0,0 +1,607 @@ +package tui3 + +// planroom.go is A RUN'S TASK IN THE TASK ROOM. +// +// A task on the run engine is a row of the run's plan store and not a node of +// this window's graph: it has no journal and no live lane, and for a while it +// opened a page of its own that looked like the room and was not one. The owner +// ruled that the two should be the same page, so there is ONE PAGE TYPE and +// this file is its second source. A run's task opens the same [taskRoom], with +// the same head, the same transcript renderer, the same box and the same keys; +// what changes is where the page READS from. +// +// - The transcript is the worker's trajectory: each step is the conversation's +// own shell call, with its command and what came back, drawn by the same +// renderers every call is drawn by ([app.planRoomEntries]). +// - A person's note on the task is their correction, drawn where a +// correction is drawn, and what they type into the box becomes one +// ([app.planRoomSteer], the store's own note verb). +// - `x` stops it through the plan's door, which for the run's own task is the +// run's stop ([app.planRoomStopTarget]). +// - The head reads the store's own figures, and a figure the store has not +// got is left off rather than drawn as a zero ([app.planRoomNode]). +// +// THE STORE IS READ OFF THE LOOP AND ON A BEAT. A page on work that can still +// move is read again every [elsewhereEvery], the rail's own beat for the same +// store, and a page on work that has ended is read once and never again. + +import ( + "encoding/json" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// planRoom is what a room carries when its task is a row of a run's store. +type planRoom struct { + // id is the store's own id for the task, the one every plan door takes. + id string + // page is the store's last reading of the task, and node the task lent to + // the room's head as [planRailNode] lends one to the rail. + page session.PlanTaskPage + node *taskNode + // shape is the page's content as it was last drawn, so a read that brings + // back the same steps, notes and state leaves the page's rows alone. + shape string + // reading says a read of the page is out; at is when the page was last read. + reading bool + at time.Time + // sending is the corrections typed into this page whose note the store has + // not answered for yet. They are drawn under the page until the answer + // comes back, so a read that lands meanwhile does not take them away. + sending []*steerElbow + // work is the run's working copy as it was last read for the work tab, and + // workRead says a reading has come back. + work session.PlanTaskWork + workRead bool + working bool +} + +// roomPlan is the open room's store task, or nil on every other page. +func (a *app) roomPlan() *planRoom { + if a.room == nil { + return nil + } + return a.room.plan +} + +// openPlanRoom opens the task room over one store task's page. +func (a *app) openPlanRoom(page session.PlanTaskPage) tea.Cmd { + if a.startingChat() { + a.parkChatStart() + } + id := strings.TrimSpace(page.Row.ID) + title := strings.TrimSpace(page.Row.Title) + room := a.newRoom(planRailNodeID(id), title) + room.plan = &planRoom{id: id} + a.room = room + a.planRoomTake(page) + // THE BOX TALKS TO THIS TASK, under its own name, exactly as it does on a + // node's page ([app.openRoom]): what was being written elsewhere is stashed + // under its own reader and this page's own unsent line comes back. + a.retargetComposer(taskRecipient(room.id)) + a.sel = -1 + a.dropHover() + a.touch() + if room.done { + return nil + } + return planRoomTick(room.gen) +} + +// planRoomTake folds one reading of the page into the open room: the lent +// node the head is drawn from, whether the work is over, and the transcript. +func (a *app) planRoomTake(page session.PlanTaskPage) { + room := a.room + if room == nil || room.plan == nil { + return + } + plan := room.plan + plan.page = page + plan.node = planRoomNode(page) + plan.at = a.now() + if title := strings.TrimSpace(page.Row.Title); title != "" { + room.title = title + } + room.setDone(planEnded(page.Row)) + shape := planRoomShape(page) + if shape == plan.shape && room.entries != nil { + a.roomTouched() + return + } + plan.shape = shape + room.entries, room.turn = a.planRoomEntries(page) + for _, elbow := range plan.sending { + room.entries = append(room.entries, entry{kind: entrySteer, turn: room.turn, steer: elbow}) + } + a.roomTouched() +} + +// planRoomNode lends the page's task a node holding what the store knows about +// it, for the room's head, its state word, its clock and its price. +// +// THE MODEL AND THE TOKENS ARE THE LEDGER'S, and they are empty when it names +// none: a task that has written no spend row has no known model and no known +// count, and the head leaves both clauses off rather than drawing a blank or a +// zero ([session.PlanTaskRow.Model]). +func planRoomNode(page session.PlanTaskPage) *taskNode { + node := planRailNode(page.Row) + node.brief = strings.TrimSpace(page.Description) + node.report = strings.TrimSpace(page.Result) + node.model = strings.TrimSpace(page.Row.Model) + node.tokens = page.Row.Tokens + if !page.Row.Started.IsZero() && !page.Row.Ended.IsZero() && page.Row.Ended.After(page.Row.Started) { + node.elapsed = page.Row.Ended.Sub(page.Row.Started) + } + return node +} + +// planRoomShape is what the transcript is drawn from, as one string: a read +// that says the same thing draws nothing again. +func planRoomShape(page session.PlanTaskPage) string { + var b strings.Builder + b.WriteString(page.Row.Status) + b.WriteString("|" + page.Description + "|" + page.Result + "|") + b.WriteString(itoa(len(page.Steps)) + "|" + itoa(len(page.Notes)) + "|") + b.WriteString(itoa(page.Live.Step) + ":" + page.Live.Command + "|") + for _, kid := range page.Children { + b.WriteString(kid.ID + "=" + kid.Status + ";") + } + return b.String() +} + +// planRoomEntries is the page's transcript, built as a record and replayed +// through the one door every transcript becomes blocks through +// ([app.roomRecord]), so a step is drawn exactly as the conversation draws a +// shell call and a note as a correction. +// +// THE BRIEF COMES FIRST, the way a node's instruction opens its page. Then the +// steps, in the order the worker took them, then the step it is taking now. +// The notes follow the steps in the order they were left: the store keeps the +// moment of a note and not of a step, so the two cannot be interleaved without +// guessing, and a guess about order is the one thing a transcript may not make. +func (a *app) planRoomEntries(page session.PlanTaskPage) ([]entry, int) { + var record session.Record + add := func(e session.DisplayEntry) { record.Entries = append(record.Entries, e) } + if brief := strings.TrimSpace(page.Description); brief != "" { + add(session.DisplayEntry{Role: "user", Text: requestDisplayFor(brief)}) + } + call := func(step int, command, output string, answered bool) { + args, _ := json.Marshal(map[string]string{"command": command}) + add(session.DisplayEntry{ + Role: "tool", Tool: "bash", CallID: "step-" + itoa(step), + Args: string(args), Output: output, Answered: answered, + }) + } + for _, step := range page.Steps { + // A CALL THE ENGINE SAYS DID NOT RUN has a row only when it was an action + // a door refused, and that row says so in the session's own lane rather + // than as a call: nothing ran ([session.PlanStep.Refused]). + if step.NotRun { + if tried := planDisplayCommand(step.Command, step.Parts); step.Refused && tried != "" { + add(session.DisplayEntry{Role: "aside", Text: taskPlanRefusedWord + railSep + tried}) + } + continue + } + command := planDisplayCommand(step.Command, step.Parts) + if command == "" { + continue + } + // THE HEAD OF WHAT CAME BACK IS THE ROW'S OWN OR IT IS NOT DRAWN, the law + // #1420's page kept ([session.PlanStep.ObservationHeadWithheld]). + output := step.FullOutput + if output == "" { + output = step.Observation + } + if step.ObservationHeadWithheld { + output = "" + } + call(step.Step, command, output, true) + } + // THE LIVE STEP IS DRAWN ONE STEP EARLY, as a call in flight, and only + // where the store says there is one ([plandb.LiveStep.Empty]). + if live := page.Live; !live.Empty() && live.Step > 0 { + if command := planDisplayCommand(live.Command, page.Row.LiveParts); command != "" { + call(live.Step, command, "", false) + } + } + for _, note := range page.Notes { + body := strings.TrimSpace(note.Body) + if body == "" { + continue + } + if note.Person { + add(session.DisplayEntry{Role: "user", Text: body, Steer: &session.SteerMark{At: note.At, Consumed: true}}) + continue + } + add(session.DisplayEntry{Role: "assistant", Text: body}) + } + // A TASK THAT HAS LANDED SAYS WHAT IT CAME TO, last, where a node's report is. + if result := strings.TrimSpace(page.Result); result != "" && planEnded(page.Row) { + add(session.DisplayEntry{Role: "assistant", Text: result}) + } + return a.roomRecord(record, roomTail) +} + +// ── the beat ─────────────────────────────────────────────────────────────── + +// planRoomTickMsg is the page's own beat. +type planRoomTickMsg struct{ gen int } + +func planRoomTick(gen int) tea.Cmd { + return surfaceTick(elsewhereEvery, func(time.Time) tea.Msg { return planRoomTickMsg{gen: gen} }) +} + +// planRoomPoll reads the page again while its work can still move. +func (a *app) planRoomPoll(gen int) tea.Cmd { + room := a.room + if room == nil || room.gen != gen || room.plan == nil || room.done { + return nil + } + return tea.Batch(a.planRoomRead(), a.planRoomWorkRead(false), planRoomTick(gen)) +} + +// planRoomRead asks the store for the page OFF THE LOOP and folds the answer +// into the page that asked, and into no other. +func (a *app) planRoomRead() tea.Cmd { + agent, ok := a.planReader() + plan := a.roomPlan() + if !ok || plan == nil || plan.reading { + return nil + } + id, gen := plan.id, a.room.gen + plan.reading = true + return a.besideLine(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(id) + return func(here bool) tea.Cmd { + if a.room == nil || a.room.gen != gen || a.room.plan == nil { + return nil + } + a.room.plan.reading = false + if here && found { + a.planRoomTake(page) + } + return nil + } + }) +} + +// ── the box ──────────────────────────────────────────────────────────────── + +// planRoomSteer is enter on a run's task: the words become a note on the task +// in the person's own voice ([session.Agent.PlanNote]), which the task's worker +// is handed at its next step. The line is drawn on the page at once, as the +// correction it is, and the store's own answer settles it. +func (a *app) planRoomSteer(line string) tea.Cmd { + room := a.room + agent, ok := a.planReader() + if room == nil || room.plan == nil || !ok { + a.roomNote(roomUnavailableRefusal.line()) + return nil + } + // A TASK THAT HAS ENDED TAKES NO NOTE, and the room says so in the words its + // box and its foot already say, with the words left in the box. Sending them + // would only bring back the store's refusal under a placeholder that named + // another door: one fact, two sentences. + if room.done { + a.roomNote(a.roomDoneRefusal().line()) + return nil + } + words := a.pastesUnfolded(line) + id, gen := room.plan.id, room.gen + a.pastes = nil + a.input.reset() + a.endRecall() + a.closeLists() + room.collapseThought() + elbow := &steerElbow{words: line, at: a.now(), landing: steerSendingWord} + room.plan.sending = append(room.plan.sending, elbow) + a.roomSaid(entry{kind: entrySteer, turn: room.turn, steer: elbow}) + return tea.Batch(a.offLoop(func() func(bool) tea.Cmd { + err := agent.PlanNote(id, words) + page, found := agent.PlanTaskPage(id) + return func(here bool) tea.Cmd { + if a.room == nil || a.room.gen != gen || a.room.plan == nil { + return nil + } + plan := a.room.plan + for i, held := range plan.sending { + if held == elbow { + plan.sending = append(plan.sending[:i], plan.sending[i+1:]...) + break + } + } + if err != nil { + // THE STORE'S OWN SENTENCE, on the row that asked: a note the store + // refused is words nobody will read, and the row must not spin. + elbow.landing, elbow.stalled = err.Error(), true + a.roomTouched() + return nil + } + elbow.landing = "" + a.railStamp++ + if here && found { + plan.shape = "" + a.planRoomTake(page) + // WHEN IT IS READ, said once under the note: the worker is a loop of + // its own and reads the note at its next step, which is also the + // read that redraws the room and takes this line away. A task that + // has ended takes no next step, so it is not said there. + if !planEnded(page.Row) { + a.roomNote(taskPlanPickupWord) + } + } + return nil + } + }), fadeTicks()) +} + +// planRoomStopTarget is `x` on a run's task: the plan's own stop, for work +// that has not ended. The run's own task is the whole run, and the plan's door +// stops it as the run ([session.Agent.PlanCancel]). +func (a *app) planRoomStopTarget() stopTarget { + plan := a.roomPlan() + if plan == nil || planEnded(plan.page.Row) { + return stopTarget{} + } + if _, ok := a.planReader(); !ok { + return stopTarget{} + } + return stopTarget{plan: plan.id, noun: stopTaskNoun, detail: stopTaskDetail} +} + +// ── the parts under it ────────────────────────────────────────────────────── + +// planRoomPartsWord heads the section a task's parts are drawn in. +const planRoomPartsWord = "under it" + +// planPageKinWidth is the most a task's room spends on one row of its parts. A +// part's row is the rail's row, whose handle stands at the row's far end; on a +// page the width of the terminal that handle would sit a screen away from the +// title it belongs to, so the rows are drawn at a width a column could have. +const planPageKinWidth = 64 + +// planRoomRunning reports whether the room on screen is a run's task whose +// work can still move, which is what keeps the paint clock turning for it. +func (a *app) planRoomRunning() bool { + plan := a.roomPlan() + return plan != nil && !a.room.done +} + +// planRoomWaitsWord heads the section a task's waits are drawn in. +const planRoomWaitsWord = "waits" + +// planRoomPartRows is what the task hangs on and what hangs under it, under the +// transcript: the tasks it waits on and the tasks waiting on it, its own first, +// then its parts, each drawn as the rail draws a task ([app.planRailLines], the +// row shape #1420 settled on). A section with nothing in it is absent. Every +// row is a door: a press opens that task's room ([app.press]). +func (a *app) planRoomPartRows(width int) []row { + plan := a.roomPlan() + if plan == nil { + return nil + } + var out []row + if waits := plan.page.WaitRows; len(waits) > 0 { + out = append(out, row{entry: -1}, row{text: a.pal.dim(fit(planRoomWaitsWord, width)), entry: -1}) + own := map[string]bool{} + for _, id := range plan.page.Row.Waits { + own[id] = true + } + title := strings.TrimSpace(plan.page.Row.Title) + for _, wait := range waits { + sentence := strings.TrimSpace(wait.Title) + railSep + "waits: " + title + if own[wait.ID] { + sentence = title + railSep + "waits: " + strings.TrimSpace(wait.Title) + } + line := a.pal.ink(fit(sentence, max(width-18, 8))) + " " + a.pal.dim(planWaitFigure(a.pal, wait)) + out = append(out, row{text: line, entry: -1, plan: wait.ID}) + } + } + if len(plan.page.Children) > 0 { + out = append(out, row{entry: -1}, row{text: a.pal.dim(fit(planRoomPartsWord, width)), entry: -1}) + for _, line := range a.planRailLines(planTwigsOf(plan.page.Children), nil, false, min(width, planPageKinWidth)) { + out = append(out, row{text: line.text, entry: -1, plan: line.plan}) + } + } + return out +} + +// planWaitFigure is the related row's state cell and useful figure on a waits +// sentence. Active work carries its recorded step count; a row without one +// carries its state word, so the relationship never drops the row's state. +func planWaitFigure(pal palette, row session.PlanTaskRow) string { + figure := planStepWords(row.Steps) + if figure == "" { + figure = planStateWord(row) + } + return strings.TrimSpace(tierGlyph(pal, planStatus(row)) + " " + figure) +} + +// planRoomAncestors is the run's tasks this one hangs under, the run's own +// task first, each lent a node so its crumb is a door onto its own room. They +// are read off the rows the surface already holds ([app.heldPlanRows]): a +// frame never opens the store. +func (a *app) planRoomAncestors() []*taskNode { + plan := a.roomPlan() + if plan == nil { + return nil + } + rows, _ := a.heldPlanRows() + byID := make(map[string]session.PlanTaskRow, len(rows)) + for _, row := range rows { + byID[strings.TrimSpace(row.ID)] = row + } + seen := map[string]bool{plan.id: true} + var up []*taskNode + for parent := strings.TrimSpace(plan.page.Row.Parent); parent != "" && !seen[parent]; { + row, ok := byID[parent] + if !ok { + break + } + seen[parent] = true + up = append(up, planRailNode(row)) + parent = strings.TrimSpace(row.Parent) + } + for i, j := 0, len(up)-1; i < j; i, j = i+1, j-1 { + up[i], up[j] = up[j], up[i] + } + return up +} + +// ── the work tab ──────────────────────────────────────────────────────────── + +// planRoomWorkRead reads the run's working copy for the work tab, off the loop, +// while the tab is the one on screen. `now` asks for it even when a read has +// already come back, which is what switching to the tab does. +func (a *app) planRoomWorkRead(now bool) tea.Cmd { + plan := a.roomPlan() + if plan == nil || a.room.tab != roomTabWork || plan.working { + return nil + } + if plan.workRead && !now && a.room.done { + return nil + } + door, ok := a.agent.(session.PlanWorkAgent) + if !ok { + return nil + } + id, gen := plan.id, a.room.gen + plan.working = true + return a.besideLine(func() func(bool) tea.Cmd { + work, found := door.PlanTaskWork(id) + return func(bool) tea.Cmd { + if a.room == nil || a.room.gen != gen || a.room.plan == nil { + return nil + } + plan := a.room.plan + plan.working = false + if found || work.NoDoor { + plan.work, plan.workRead = work, true + } else { + plan.work, plan.workRead = session.PlanTaskWork{}, true + } + a.roomTouched() + return nil + } + }) +} + +// The work tab's own sentences, each the whole of what the tab can truthfully +// say when there is no difference to draw. +const ( + planWorkNoDoorWord = "this engine does not read the run's working copy" + planWorkReadingWord = "reading the run's working copy…" + planWorkGoneWord = "the run's working copy is not here any more" + planWorkNoneWord = "nothing in the run's working copy has changed yet" + planWorkCutWord = "the rest of the difference is in the working copy" + planWorkNewWord = "new " + planWorkHeadWord = "the run's working copy · every task of this run works in it" + nodeWorkHeadWord = "files this task changed" + nodeWorkYetWord = "the files this task changes are listed here when it lands" + nodeWorkNoneWord = "this task changed no files" +) + +// roomWorkRows is the work tab's body: what the task has changed. +// +// ON A RUN'S TASK it is the difference in the run's working copy, which every +// part of the run shares. ON A NODE it is the files the node's record says it +// wrote, which is what the engine keeps about a node's work. +func (a *app) roomWorkRows(width int) []row { + dim := func(text string) row { return row{text: a.pal.dim(fit(text, width)), entry: -1} } + if plan := a.roomPlan(); plan != nil { + if _, ok := a.agent.(session.PlanWorkAgent); !ok { + return []row{dim(planWorkNoDoorWord)} + } + if !plan.workRead { + return []row{dim(planWorkReadingWord)} + } + if plan.work.NoDoor { + return []row{dim(planWorkNoDoorWord)} + } + work := plan.work + if !work.Read { + return []row{dim(planWorkGoneWord)} + } + if strings.TrimSpace(work.Patch) == "" && len(work.Added) == 0 { + return []row{dim(planWorkNoneWord)} + } + out := []row{dim(planWorkHeadWord), {entry: -1}} + for _, path := range work.Added { + out = append(out, row{text: a.pal.add(fit(a.icon(tokens.GDiffAdd)+planWorkNewWord+drawableLine(path), width)), entry: -1}) + } + if len(work.Added) > 0 && work.Patch != "" { + out = append(out, row{entry: -1}) + } + for _, line := range a.planWorkLines(work.Patch, width) { + out = append(out, row{text: line, entry: -1}) + } + if work.Cut { + out = append(out, row{entry: -1}, dim(planWorkCutWord)) + } + return out + } + node := a.roomNode() + if node == nil || len(node.changed) == 0 { + if node != nil && roomRowDone(node) { + return []row{dim(nodeWorkNoneWord)} + } + return []row{dim(nodeWorkYetWord)} + } + out := []row{dim(nodeWorkHeadWord), {entry: -1}} + for _, path := range node.changed { + out = append(out, row{text: a.pal.ink(fit(drawableLine(path), width)), entry: -1}) + } + return out +} + +// planWorkLines paints a unified patch the way a diff in a question is painted +// ([app.questionDiffLines]): the mark from the vocabulary and the hue from the +// ramp, with each file's header in ink so the eye finds where one file ends. +// +// EVERY LINE IS SOMEBODY ELSE'S BYTES, whatever a worker wrote into a file, and +// goes through [drawableLine] as a call's output does: a carriage return from a +// file with Windows line ends or an escape inside one would repaint rows this +// surface owns. +func (a *app) planWorkLines(patch string, width int) []string { + lines := strings.Split(strings.TrimRight(patch, "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + line = drawableLine(line) + switch { + case strings.HasPrefix(line, "diff --git "): + if len(out) > 0 { + out = append(out, "") + } + out = append(out, a.pal.ink(fit(planPatchHeadPath(line), width))) + case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"), + strings.HasPrefix(line, "index "), strings.HasPrefix(line, "new file"), + strings.HasPrefix(line, "deleted file"), strings.HasPrefix(line, "similarity"), + strings.HasPrefix(line, "rename "), strings.HasPrefix(line, "old mode"), + strings.HasPrefix(line, "new mode"): + continue + case strings.HasPrefix(line, "@@"): + out = append(out, a.pal.dim(fit(line, width))) + case strings.HasPrefix(line, "+"): + out = append(out, a.pal.add(fit(a.icon(tokens.GDiffAdd)+strings.TrimPrefix(line, "+"), width))) + case strings.HasPrefix(line, "-"): + out = append(out, a.pal.del(fit(a.icon(tokens.GDiffDel)+strings.TrimPrefix(line, "-"), width))) + default: + out = append(out, a.pal.dim(fit(line, width))) + } + } + return out +} + +// planPatchHeadPath is the file a patch section is about, off its header. +func planPatchHeadPath(line string) string { + head := strings.TrimPrefix(line, "diff --git ") + if at := strings.LastIndex(head, " b/"); at >= 0 { + return head[at+3:] + } + return head +} diff --git a/internal/tui3/planroom_test.go b/internal/tui3/planroom_test.go new file mode 100644 index 000000000..3ec9c5b21 --- /dev/null +++ b/internal/tui3/planroom_test.go @@ -0,0 +1,463 @@ +package tui3 + +// A run's task in the task room (planroom.go): what the room reads from the +// store, what it draws of it, and the laws #1420's page kept that the room +// keeps now. + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// openPlanRoomNow opens one store task's room and answers the read it asks for. +func openPlanRoomNow(t *testing.T, a *app, id string) { + t.Helper() + spend(t, a, a.openRailPlan(id, nil)) +} + +// planRoomText is the whole frame while a room is open, without its inks. +func planRoomText(t *testing.T, a *app) string { + t.Helper() + frame, _, _ := a.frame() + return plain(frame) +} + +// errRefusedForTest is a store refusal a test puts in front of the room. +var errRefusedForTest = errors.New("that task has ended and takes no more notes") + +// planWorkFake is a plan agent that also reads the run's working copy. +type planWorkFake struct { + *planFake + work session.PlanTaskWork + reads int +} + +func (f *planWorkFake) PlanTaskWork(string) (session.PlanTaskWork, bool) { + f.reads++ + return f.work, true +} + +func TestTheRunsTaskRoomDrawsItsStepsAsTheRoomsShellCalls(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running", Started: taskFixtureNow.Add(-3 * 60e9)} + row.Live.Step, row.Live.Command = 3, "go test ./internal/config/..." + page := session.PlanTaskPage{ + Row: row, + Description: "make the loader read the new key", + Steps: []session.PlanStep{ + {Kind: "step", Step: 1, Command: "cat internal/config/load.go", Observation: "package config"}, + {Kind: "step", Step: 2, Command: "sed -i s/old/new/ internal/config/load.go"}, + }, + Live: row.Live, + } + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: page}) + a.height = 40 + openPlanRoomNow(t, a, row.ID) + if a.room == nil || a.room.plan == nil { + t.Fatal("the run's task opened no room") + } + text := planRoomText(t, a) + if !strings.Contains(text, "make the loader read the new key") { + t.Fatalf("the room does not open on the brief:\n%s", text) + } + want := []string{"cat internal/config/load.go", "sed -i s/old/new/ internal/config/load.go", "go test ./internal/config/..."} + if got := roomCommands(a); strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("the room's calls are %q, want %q", got, want) + } + // THE ROOM'S OWN HEAD: the state, the clock, the steps counted as calls. + if !strings.Contains(text, "working") || !strings.Contains(text, "3m") { + t.Fatalf("the room's head does not say the state and the clock:\n%s", text) + } +} + +// #1420's two laws: a live step with no command is no line, and a live step +// with no step number is not a live step. +func TestTheRunsTaskRoomDrawsNoLiveRowWithoutACommandOrAStep(t *testing.T) { + for _, live := range []plandb.LiveStep{{Step: 4}, {Command: "go vet ./..."}} { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running", Live: live} + page := session.PlanTaskPage{Row: row, Description: "the brief", Live: live} + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: page}) + openPlanRoomNow(t, a, row.ID) + for _, e := range a.room.entries { + if e.kind == entryTool { + t.Fatalf("a live step %+v drew a call row: %+v", live, e) + } + } + if text := planRoomText(t, a); strings.Contains(text, "◑ $") || strings.Contains(text, "go vet") { + t.Fatalf("a live step %+v drew a live row:\n%s", live, text) + } + } +} + +func TestTheRunsTaskRoomDrawsARefusedActionAsOneLineAndACorrectionAsNone(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + page := session.PlanTaskPage{Row: row, Description: "the brief", Steps: []session.PlanStep{ + {Kind: "step", Step: 1, Command: "rm -rf /", NotRun: true, Refused: true}, + {Kind: "step", Step: 2, Command: "reply in the form", NotRun: true}, + }} + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: page}) + openPlanRoomNow(t, a, row.ID) + text := planRoomText(t, a) + if !strings.Contains(text, taskPlanRefusedWord+railSep+"rm -rf /") { + t.Fatalf("the refused action is not one line:\n%s", text) + } + if strings.Contains(text, "reply in the form") { + t.Fatalf("a correction about the form of a reply was drawn:\n%s", text) + } +} + +// THE HEAD SAYS TOKENS AND MODEL WHERE THE STORE RECORDS THEM, AND NOTHING +// WHERE IT DOES NOT — never a blank and never a zero. +func TestTheRunsTaskRoomHeadDropsTokensAndModelTheStoreHasNot(t *testing.T) { + known := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "done", USD: 0.04, + Model: "deepseek/deepseek-v4-flash", Tokens: 12400, + Started: taskFixtureNow.Add(-120e9), Ended: taskFixtureNow.Add(-60e9)} + a, _ := planAppWith(t, []session.PlanTaskRow{known}, map[string]session.PlanTaskPage{known.ID: {Row: known, Description: "b"}}) + openPlanRoomNow(t, a, known.ID) + head := strings.Join(a.roomHeadRows(a.width), "\n") + for _, want := range []string{"$0.04", "12.4k tok", "deepseek-v4-flash", "1m"} { + if !strings.Contains(plain(head), want) { + t.Fatalf("the head does not say %q:\n%s", want, plain(head)) + } + } + unknown := session.PlanTaskRow{ID: "t-7", Title: "fix the loader", Status: "done"} + a, _ = planAppWith(t, []session.PlanTaskRow{unknown}, map[string]session.PlanTaskPage{unknown.ID: {Row: unknown, Description: "b"}}) + openPlanRoomNow(t, a, unknown.ID) + head = plain(strings.Join(a.roomHeadRows(a.width), "\n")) + for _, never := range []string{"$0", "0 tok", " tok", "task "} { + if strings.Contains(head, never) { + t.Fatalf("the head draws %q for a figure the store has not got:\n%s", never, head) + } + } +} + +// A TALL WINDOW DRAWS THE MODEL ON THE TITLE ROW. That is the header a person +// actually sits in front of, and it is the row that used to keep the price and +// the tokens and leave the model off. +func TestAnOrganizedRunsTaskRoomHeadNamesTheModel(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running", USD: 0.0017, + Model: "z-ai/glm-5.3-flash", Tokens: 21000, + Started: taskFixtureNow.Add(-20e9)} + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + a.width, a.height = 120, 40 + openPlanRoomNow(t, a, row.ID) + if !a.roomOrganized() { + t.Fatal("120×40 must be the organized layout the title row is drawn for") + } + head := plain(strings.Join(a.roomHeadRows(a.width), "\n")) + if !strings.Contains(head, "z-ai/glm-5.3-flash") { + t.Fatalf("the organized head does not name the model:\n%s", head) + } + if strings.Contains(head, " tok") && !strings.Contains(head, "21k tok") && !strings.Contains(head, "21.0k tok") { + t.Fatalf("the organized head lost the tokens beside the model:\n%s", head) + } +} + +// AN ENGINE THAT ANSWERS THE DOOR AND SAYS IT HAS NONE still draws the absence +// sentence. That is an older engine on the host road, not a copy that is gone. +func TestTheWorkTabDrawsTheAbsenceSentenceWhenTheEngineHasNoDoor(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + fake := &planWorkFake{work: session.PlanTaskWork{NoDoor: true}} + a, plan := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + fake.planFake = plan + a.agent = fake + openPlanRoomNow(t, a, row.ID) + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + text := planRoomText(t, a) + if !strings.Contains(text, planWorkNoDoorWord) { + t.Fatalf("the work tab did not say the engine has no door:\n%s", text) + } +} + +func TestTheWorkTabOfARunsTaskDrawsTheRunsWorkingCopyDifference(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + fake := &planWorkFake{work: session.PlanTaskWork{ + Dir: "/work/copy", Read: true, Added: []string{"notes.txt"}, + Patch: "diff --git a/load.go b/load.go\nindex 1..2 100644\n--- a/load.go\n+++ b/load.go\n@@ -1 +1 @@\n-old line\n+new line\n", + }} + a, plan := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + fake.planFake = plan + a.agent = fake + openPlanRoomNow(t, a, row.ID) + if fake.reads != 0 { + t.Fatalf("the transcript tab read the working copy %d times", fake.reads) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + if a.room.tab != roomTabWork || fake.reads != 1 { + t.Fatalf("tab did not open the work tab and read the copy once: tab=%v reads=%d", a.room.tab, fake.reads) + } + text := planRoomText(t, a) + for _, want := range []string{"load.go", "old line", "new line", "notes.txt"} { + if !strings.Contains(text, want) { + t.Fatalf("the work tab does not draw %q:\n%s", want, text) + } + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + if a.room.tab != roomTabTranscript { + t.Fatal("tab did not come back to the transcript") + } +} + +// `x` OVER AN EMPTY BOX STOPS A RUN'S TASK THROUGH THE PLAN'S DOOR, after the +// card; on a task that has ended it is the letter it is. +func TestStopInARunsTaskRoomGoesThroughThePlansDoor(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + openPlanRoomNow(t, a, row.ID) + drive(t, a, key("x")) + if !a.stopping() || a.stop.target.plan != row.ID { + t.Fatalf("x did not raise the stop card for the store task: %+v", a.stop) + } + spend(t, a, a.stopTake(0)) + if len(fake.cancelled) != 1 || fake.cancelled[0] != row.ID { + t.Fatalf("the card's stop did not reach the plan's door: %v", fake.cancelled) + } + + ended := session.PlanTaskRow{ID: "t-7", Title: "done task", Status: "done"} + a, fake = planAppWith(t, []session.PlanTaskRow{ended}, map[string]session.PlanTaskPage{ended.ID: {Row: ended, Description: "b"}}) + openPlanRoomNow(t, a, ended.ID) + drive(t, a, key("x")) + if a.stopping() || len(fake.cancelled) != 0 { + t.Fatalf("x on an ended task raised a card or cancelled: %v", fake.cancelled) + } +} + +// A NOTE THE STORE REFUSED SAYS SO ON ITS OWN ROW, in the store's words. +func TestANoteTheStoreRefusesSaysSoWhereItWasTyped(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + fake.refuse = errRefusedForTest + openPlanRoomNow(t, a, row.ID) + typeText(t, a, "keep it small") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if text := planRoomText(t, a); !strings.Contains(text, errRefusedForTest.Error()) { + t.Fatalf("the refusal is not on the page:\n%s", text) + } +} + +// KEYS TYPED WHILE THE ROOM IS ON ITS WAY GO INTO ITS BOX AND NOWHERE ELSE: a +// sentence that starts with the stop key raises no card, and enter sends +// nothing until the person has read the room. +func TestKeysTypedWhileARunsTaskRoomOpensAreTheBoxAndNothingElse(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + cmd := a.openRailPlan(row.ID, nil) + for _, r := range "x-axis" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + spend(t, a, cmd) + if a.stopping() || len(fake.cancelled) != 0 || len(fake.noted) != 0 { + t.Fatalf("keys typed in the gap acted: card=%v cancelled=%v noted=%v", a.stopping(), fake.cancelled, fake.noted) + } + if !a.roomOpen() || string(a.input.value) != "x-axis" { + t.Fatalf("the gap's keys are not in the room's box: room=%v box=%q", a.roomOpen(), string(a.input.value)) + } +} + +// roomCallText is the room as a reader could open it: the frame, and every +// call row's command and output, which a folded chip holds until it is opened. +func roomCallText(t *testing.T, a *app) string { + t.Helper() + parts := []string{planRoomText(t, a)} + if a.room != nil { + for _, e := range a.room.entries { + if e.kind == entryTool { + parts = append(parts, e.detail.Args, e.detail.Output) + } + } + } + return strings.Join(parts, "\n") +} + +// roomStepsWord is the step count the room's facts carry for its task. +func roomStepsWord(a *app) string { + if node := a.roomNode(); node != nil { + return a.roomFactsOf(node).calls.full + } + return "" +} + +// roomCommands is every call row in the open room, in order, by the command +// it carries. A call row can fold into a chip on screen, so a test that asks +// which steps the room holds reads its entries rather than its frame. +func roomCommands(a *app) []string { + var out []string + if a.room == nil { + return nil + } + for _, e := range a.room.entries { + if e.kind != entryTool { + continue + } + var args struct{ Command string } + _ = json.Unmarshal([]byte(e.detail.Args), &args) + out = append(out, args.Command) + } + return out +} + +// THE ROOM FOLLOWS A RUNNING TASK ON ITS OWN BEAT: a step the store records +// after the room opened is a row after the next beat, once. +func TestTheRunsTaskRoomFollowsAStepTheStoreRecordsLater(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + page := session.PlanTaskPage{Row: row, Description: "b", Steps: []session.PlanStep{ + {Kind: "step", Step: 1, Command: "cat load.go"}, + }} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: page}) + openPlanRoomNow(t, a, row.ID) + if got := roomCommands(a); len(got) != 1 || got[0] != "cat load.go" { + t.Fatalf("the room opened with calls %q", got) + } + page.Steps = append(page.Steps, session.PlanStep{Kind: "step", Step: 2, Command: "go test ./..."}) + fake.pages[row.ID] = page + drive(t, a, planRoomTickMsg{gen: a.room.gen}) + if got := roomCommands(a); len(got) != 2 || got[1] != "go test ./..." { + t.Fatalf("the beat did not bring the new step in once: %q", got) + } +} + +// A LIVE STEP LEAVES WHEN ITS TASK ENDS. The room of a task that has landed +// draws no call in flight, and says it is done. +func TestTheLiveStepLeavesTheRoomWhenTheTaskEnds(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + row.Live.Step, row.Live.Command = 2, "go test ./..." + page := session.PlanTaskPage{Row: row, Description: "b", Live: row.Live} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: page}) + openPlanRoomNow(t, a, row.ID) + if got := roomCommands(a); len(got) != 1 { + t.Fatalf("the running task's live step is not a call: %q", got) + } + row.Status, row.Live = "done", plandb.LiveStep{} + page.Row, page.Live = row, row.Live + page.Steps = []session.PlanStep{{Kind: "step", Step: 2, Command: "go test ./...", Observation: "ok"}} + fake.pages[row.ID] = page + drive(t, a, planRoomTickMsg{gen: a.room.gen}) + if !a.room.done { + t.Fatal("the room of a task that ended is not done") + } + for _, e := range a.room.entries { + if e.kind == entryTool && e.status == toolRunning { + t.Fatalf("an ended task's room still draws a call in flight: %+v", e) + } + } +} + +// A NOTE TYPED INTO A PART'S ROOM REACHES THAT PART, not the run above it. +func TestANoteInAPartsRoomReachesThePart(t *testing.T) { + root := session.PlanTaskRow{ID: "t-root", Title: "Root", Status: "running"} + part := session.PlanTaskRow{ID: "t-part", Parent: "t-root", Title: "Part", Status: "running"} + a, fake := planAppWith(t, []session.PlanTaskRow{root, part}, map[string]session.PlanTaskPage{ + root.ID: {Row: root, Description: "b", Children: []session.PlanTaskRow{part}}, + part.ID: {Row: part, Description: "the part's order"}, + }) + openPlanRoomNow(t, a, part.ID) + typeText(t, a, "check the edge case") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 1 || fake.noted[0] != (planCall{id: part.ID, text: "check the edge case"}) { + t.Fatalf("the part's note went to %+v", fake.noted) + } +} + +// A NOTE THE STORE TOOK SAYS WHEN IT IS READ, and on a task that has ended it +// does not, because that task takes no next step. +func TestANoteInARunningTasksRoomSaysWhenTheWorkerReadsIt(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + openPlanRoomNow(t, a, row.ID) + typeText(t, a, "keep it small") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if text := planRoomText(t, a); !strings.Contains(text, taskPlanPickupWord) { + t.Fatalf("the room does not say when the note is read:\n%s", text) + } +} + +// A PART UNDER A RUN'S TASK IS A DOOR: a press on its row in the room opens +// that part's room, the way its row on the side list does. +func TestAPressOnAPartRowInTheRoomOpensThePartsRoom(t *testing.T) { + root := session.PlanTaskRow{ID: "t-root", Title: "Root", Status: "running"} + part := session.PlanTaskRow{ID: "t-part", Parent: "t-root", Title: "Check the flaky parser", Status: "running"} + a, _ := planAppWith(t, []session.PlanTaskRow{root, part}, map[string]session.PlanTaskPage{ + root.ID: {Row: root, Description: "b", Children: []session.PlanTaskRow{part}}, + part.ID: {Row: part, Description: "the part's order"}, + }) + a.height = 40 + openPlanRoomNow(t, a, root.ID) + x, y := -1, -1 + for i, line := range strings.Split(planRoomText(t, a), "\n") { + if at := strings.LastIndex(line, railSeam); at >= 0 { + line = line[:at] + } + if col := strings.Index(line, part.Title); col >= 0 { + x, y = len([]rune(line[:col]))+1, i + } + } + if y < 0 { + t.Fatalf("the room does not draw its part:\n%s", planRoomText(t, a)) + } + drive(t, a, clickAt(x, y), releaseAt(x, y)) + if plan := a.roomPlan(); plan == nil || plan.id != part.ID { + t.Fatalf("a press on the part's row did not open its room: room %v", a.roomOpen()) + } +} + +// A TASK THAT HAS ENDED TAKES NO NOTE: the room says what its box already says, +// asks the store nothing, and leaves the words in the box. +func TestAnEndedTasksRoomRefusesANoteInItsOwnWords(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "done"} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + openPlanRoomNow(t, a, row.ID) + typeText(t, a, "one more thing") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 0 { + t.Fatalf("an ended task's room sent a note: %v", fake.noted) + } + if got := string(a.input.value); got != "one more thing" { + t.Fatalf("the box holds %q, want the words kept", got) + } + if text := planRoomText(t, a); !strings.Contains(text, roomFinishedRefusal.what) { + t.Fatalf("the room does not say the task has finished:\n%s", text) + } +} + +// THE WORK TAB DRAWS SOMEBODY ELSE'S BYTES, and they reach no frame unparsed. A +// file the work changed is whatever a worker wrote into it: a carriage return +// from a file with Windows line ends, a title escape, a screen clear. Drawn raw, +// each one repaints rows this surface owns; the transcript draws a call's +// output through [drawableLine] for the same reason, and so does this tab. +func TestTheWorkTabDrawsNoControlBytesFromTheDifference(t *testing.T) { + row := session.PlanTaskRow{ID: "t-6", Title: "fix the loader", Status: "running"} + fake := &planWorkFake{work: session.PlanTaskWork{ + Dir: "/work/copy", Read: true, Added: []string{"odd\x1b]0;named\x07.txt"}, + Patch: "diff --git a/load.go b/load.go\n--- a/load.go\n+++ b/load.go\n@@ -1 +1 @@\n" + + "-old line\r\n+new\x1b]0;title\x07 line\x1b[2J\r\n", + }} + a, plan := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: {Row: row, Description: "b"}}) + fake.planFake = plan + a.agent = fake + openPlanRoomNow(t, a, row.ID) + drive(t, a, tea.KeyPressMsg{Code: tea.KeyTab}) + rows := a.roomWorkRows(80) + var drawn strings.Builder + for _, r := range rows { + drawn.WriteString(r.text) + drawn.WriteByte('\n') + } + text := drawn.String() + for _, raw := range []string{"\r", "\a", "\x1b]", "\x1b[2J"} { + if strings.Contains(text, raw) { + t.Fatalf("the work tab drew the control bytes %q from the difference:\n%q", raw, text) + } + } + for _, want := range []string{"old line", "new", "line", "odd"} { + if !strings.Contains(text, want) { + t.Fatalf("the work tab lost the words %q:\n%q", want, text) + } + } +} diff --git a/internal/tui3/render.go b/internal/tui3/render.go index fa7fadda2..5f36dc2ad 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -108,6 +108,10 @@ type row struct { // Picture controls retain their index and original-file action through gutter layout. pictureIndex int pictureOpen hudSpan + // plan is the store task a row in a run's task room stands for, on the rows + // the room draws under its transcript (planroom.go): a press on one opens + // that task's room. + plan string // open is a [hitThread] row's message, by team and entry id. open string } diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 8ab5172e7..8e6a2bf89 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -208,7 +208,7 @@ func (a *app) taskModelDoors() (taskModelDoor, bool) { // Ordinary settled tasks save continuation settings through the same picker. // Adaptive runs and guest pages remain outside this task model door. func (a *app) roomModelMovable() bool { - if a.room == nil || a.room.orch != nil { + if a.room == nil || a.room.orch != nil || a.room.plan != nil { return false } // AND A PAGE READ THROUGH SOMEBODY ELSE'S CONVERSATION MOVES NOTHING. The door @@ -384,6 +384,16 @@ type taskRoom struct { // for both to BE a room. orch *orchRun + // plan is set when this page is A RUN'S TASK, read from the run's plan store + // rather than from a node's journal and lane (planroom.go). It is a field on + // this struct for [taskRoom.orch]'s reason: there is one page type, and + // everything a room promises has to hold for a run's task too. + plan *planRoom + + // tab is which of the page's two tabs is on screen: the transcript, or the + // work it changed (roomtabs.go). + tab roomTab + // harnessProgress is the design lane's one evolving thought inside the // design node's room. It is display-only: no journal line is minted for live // telemetry, and the next event replaces this string in place. @@ -1086,6 +1096,11 @@ func (a *app) roomStandingOn(node *taskNode) bool { if a.room == nil || node == nil || a.roomIsGuest() { return false } + // A RUN'S TASK IS MATCHED BY ITS STORE ID, which the run's own row and every + // row lent to one of its parts carry alike ([taskNode.planTask]). + if plan := a.room.plan; plan != nil { + return strings.TrimSpace(node.planTask) != "" && strings.TrimSpace(node.planTask) == plan.id + } if run := a.orchOf(); run != nil { if node.run == "" || node.run != run.id { return false @@ -1112,16 +1127,29 @@ func (a *app) openRoomFor(id uint64, title string) { // openRailRoom makes list selection idempotent. Repeated clicks must not close // the page or replace its draft, scroll position and live subscription. // -// A ROW WHOSE TASK HAS A STORED PAGE OPENS THAT PAGE, and the question is asked -// of the store at the gesture, off the loop ([app.taskSheetPlanAsk]). What the -// row opens when the store has no page for it is fixed HERE, from the row as it -// was pressed: the read may come back after the rail has been redrawn, and an +// EVERY TASK OPENS THE TASK ROOM. A row whose task has a stored page opens the +// room over that store task (planroom.go), and the question is asked of the +// store at the gesture, off the loop ([app.openRailPlan]). What the row opens +// when the store has no page for it is fixed HERE, from the row as it was +// pressed: the read may come back after the rail has been redrawn, and an // absent page still opens exactly what this gesture chose. func (a *app) openRailRoom(node *taskNode) tea.Cmd { if node == nil || a.roomStandingOn(node) { return nil } id, title, run, part := node.id, node.title, node.run, node.node + // THE STORE TASK IS THE ONE THE ROW NAMES, and a row that names none is asked + // for under its own number, which is the number a run's door gives the + // store task it seeds ([session.TaskNotice.PlanTask]). + store := strings.TrimSpace(node.planTask) + if store == "" { + store = strconv.FormatUint(id, 10) + } + // A ROW LENT TO A STORE TASK HAS NO NODE BEHIND IT, so a store with no page + // for it opens nothing rather than a room onto an id the engine never gave. + if node.planRow != nil { + return a.openRailPlan(store, nil) + } _, hasPlan := a.planReader() room := func() tea.Cmd { if run != "" && !hasPlan { @@ -1131,26 +1159,55 @@ func (a *app) openRailRoom(node *taskNode) tea.Cmd { } return a.takeRoomPump() } - return a.openRailPlan(strconv.FormatUint(id, 10), room) + return a.openRailPlan(store, room) } -// openRailPlan opens one task's stored page FROM THE CHAT, over the -// conversation, for a press on a rail row or on one of a run's own rows under -// it. `missing` is what the gesture does when the store has no such page. +// openRailPlan opens the task room over one store task FROM THE CHAT, for a +// press on a rail row, on one of a run's own rows under it, on a crumb, on the +// tasks place or on the run's tab. `missing` is what the gesture does when the +// store has no such page. +// +// THE KEYS TYPED WHILE THE READ IS OUT ARE THE ROOM'S ([railPlanPending]): they +// go into the room's box once it is up, and nowhere else. func (a *app) openRailPlan(id string, missing func() tea.Cmd) tea.Cmd { if a.railPlanPending.id == id { return nil } - a.beginRailPlan(id) - return a.taskSheetPlanAsk(id, nil, func() tea.Cmd { return a.finishRailPlan(id) }, func() tea.Cmd { - if a.railPlanPending.id != id { - return nil - } - a.railPlanPending = railPlanPending{} + agent, ok := a.planReader() + if !ok { if missing != nil { return missing() } return nil + } + a.beginRailPlan(id) + return a.offLoop(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(id) + return func(here bool) tea.Cmd { + // EVERY ENDING OF THE READ ENDS THE HOLD IT WAS MADE FOR. A second press + // replaced this one, or `esc` withdrew it, and then the answer opens + // nothing. + if a.railPlanPending.id != id { + return nil + } + keys := a.railPlanPending.keys + a.railPlanPending = railPlanPending{} + if !here { + return nil + } + if !found { + if missing != nil { + return missing() + } + return nil + } + cmd := a.openPlanRoom(page) + for _, key := range keys { + a.railPlanReplay(key) + } + a.touch() + return cmd + } }) } @@ -1427,6 +1484,11 @@ func (a *app) steer() tea.Cmd { if room.orch != nil { return a.orchSteer() } + // A RUN'S TASK TAKES THE LINE AS A NOTE ON ITS STORE TASK (planroom.go): the + // same box and the same enter, through the plan's own note door. + if room.plan != nil { + return a.planRoomSteer(line) + } if a.roomIsGuest() { a.roomNote(roomGuestReadingWord) return nil @@ -1939,8 +2001,16 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // input.go, which spends it on [app.navBack] over an empty box and on the // caret over a sentence. + case roomTabKey: + // THE OTHER TAB, over an empty box (roomtabs.go). A box with words in it + // keeps the key for what it does there. + if a.roomHasTabs() && a.input.empty() && !a.comp.open { + return a.roomTabNext(), true + } + return nil, false + case "enter": - if !a.roomIsGuest() && strings.TrimSpace(a.input.String()) == "" { + if !a.roomIsGuest() && a.room.plan == nil && strings.TrimSpace(a.input.String()) == "" { entry := a.roomRetryEntry() if a.taskCanRetry(entry) { return a.retryTask(entry), true @@ -1961,7 +2031,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // own card, bound here to the node whose page this is (taskeffort.go). // Everything that outranks the room outranks it, because it is read from // inside the room's own switch and never above it. - a.cycleTaskEffort() + if a.room.plan == nil { + a.cycleTaskEffort() + } return nil, true case "ctrl+b": @@ -2577,7 +2649,7 @@ func (a *app) roomTrailRow(width int) string { if a.roomOrganized() { left, hits = a.roomAncestorParts(width) } - a.crumbs, a.roomBackSpan = hits, hudSpan{} + a.crumbs, a.roomBackSpan, a.roomTabSpans = hits, hudSpan{}, nil line := strings.Repeat(" ", headLabelAt) + a.paintCrumbs(left, headLabelAt, a.pal.accent) leftWidth := headLabelAt + ansi.StringWidth(left) back := " " + roomBackWord + " " @@ -2589,6 +2661,16 @@ func (a *app) roomTrailRow(width int) string { if a.hoveringRoomBack() { shown = a.pal.cursor(shown, 0) } + // THE TABS STAND BESIDE THE WAY OUT, where they cost no row of the page, + // and only where the whole of both names fits (roomtabs.go). A frame too + // narrow for them keeps the trail and the way out, and `tab` still moves. + if tabs, cols, spans := a.roomTabsLabel(); tabs != "" && leftWidth+2+cols+2 <= from { + at := from - 2 - cols + for _, span := range spans { + a.roomTabSpans = append(a.roomTabSpans, hudSpan{from: at + span.from, to: at + span.to}) + } + return line + strings.Repeat(" ", at-leftWidth) + tabs + " " + shown + " " + } return line + strings.Repeat(" ", from-leftWidth) + shown + " " } return line + strings.Repeat(" ", max(0, width-leftWidth)) @@ -3130,6 +3212,9 @@ func (a *app) roomNode() *taskNode { if guest := a.room.guest; guest != nil { return guest.node } + if plan := a.room.plan; plan != nil { + return plan.node + } return a.tasks[a.room.id] } @@ -3469,7 +3554,22 @@ func (a *app) roomRows(width int) []row { // the same cost. A RUN'S PAGE IS NOT — a graph of cards is not a paragraph — // which is why the branch that returns one does so above this line. inner := gutterInner(width) + // THE WORK TAB IS WHAT THE TASK CHANGED, and it is the same page with a + // different body: the head, the box and the keys are the room's (roomtabs.go). + if room.tab == roomTabWork { + out := a.roomWorkRows(inner) + gutterPass(out, width) + a.hoverPass(out, width) + room.rows, room.width, room.height, room.dirty = out, width, height, false + return out + } out, closed := a.deckRows(room.deck(), inner) + // A RUN'S TASK SHOWS ITS PARTS UNDER ITS TRANSCRIPT, each drawn as the rail + // draws a task (planroom.go). + if parts := a.planRoomPartRows(inner); len(parts) > 0 { + out = append(out, parts...) + closed = false + } if room.harnessProgress != "" && !room.done { out = append(out, row{text: a.pal.dim(fit(room.harnessProgress, inner)), entry: -1}) closed = false @@ -3992,6 +4092,11 @@ func (a *app) roomSteerLaneRows(rows []string, width int) []string { // listening" is the question it exists to answer. lane = orchSteerLane + roomSteerBack } + if a.room.plan != nil { + // A RUN'S TASK TAKES THE WORDS AS A NOTE (planroom.go), and the box says + // so: the worker reads a note at its next step, not the instant it is sent. + lane = taskPlanNoteWord + roomSteerBack + } if a.roomIsGuest() { // A BORROWED PAGE DOES NOT OFFER A KEYBOARD IT DOES NOT HAVE. This window is // a second view onto a conversation another window is driving, and the diff --git a/internal/tui3/roomcrumbs.go b/internal/tui3/roomcrumbs.go index bf20d5199..37b8d8d57 100644 --- a/internal/tui3/roomcrumbs.go +++ b/internal/tui3/roomcrumbs.go @@ -215,6 +215,11 @@ func (a *app) roomAncestors() []*taskNode { if node == nil || a.roomIsGuest() { return nil } + // A RUN'S TASK HANGS UNDER THE RUN'S TASKS, which are rows of its store and + // not nodes of this graph (planroom.go). + if a.roomPlan() != nil { + return a.planRoomAncestors() + } seen := map[uint64]bool{node.id: true} var up []*taskNode for parent := node.ParentID(); parent != ""; { @@ -446,7 +451,7 @@ func (a *app) paintCrumbs(label string, at int, paint func(string) string) strin // paintCrumbHits is [app.paintCrumbs] over a trail the caller laid out, so a // page that draws the room's trail shape without being a room — a store task's -// page ([app.taskPlanTrail]) — paints its crumbs in the same inks. +// page — paints its crumbs in the same inks. func (a *app) paintCrumbHits(label string, at int, hits []crumbHit, hot crumbHit, hovering bool) string { width, cursor := ansi.StringWidth(label), 0 var out strings.Builder diff --git a/internal/tui3/roomfacts.go b/internal/tui3/roomfacts.go index 3d250c8cb..4737e2e06 100644 --- a/internal/tui3/roomfacts.go +++ b/internal/tui3/roomfacts.go @@ -9,25 +9,43 @@ import ( // Named facts feed both the roomy groups and the compact ranked fallback. // A different layout must not invent a second reading of task activity. type roomFactFields struct { - state, clock, spend, calls, live, model, effort rowField + state, clock, spend, calls, live, model, tokens, effort rowField } func (a *app) roomFactsOf(node *taskNode) roomFactFields { work := roomWorkOf(a.roomEntries()) model := strings.TrimSpace(node.model) + calls := roomCallField(work.calls) + // A RUN'S TASK COUNTS ITS STEPS AS THE STORE COUNTS THEM (planroom.go): a + // step the engine answered itself is in the record and in the count, and + // has no row on the page, so the page's own rows cannot be the count. + if plan := a.roomPlan(); plan != nil { + calls = rowSay(planStepWords(plan.page.Row.Steps)) + } return roomFactFields{ state: rowSay(a.roomStateWord(node)), clock: rowSay(a.roomClock(node)), spend: rowSay(a.roomSpend(node)), - calls: roomCallField(work.calls), + calls: calls, live: rowSay(a.roomLiveWord(node, work)), model: rowSay(model, modelBase(model)), + tokens: rowSay(a.roomTokensWord(node)), effort: rowSay(a.taskEffortClause(node)), } } +// roomTokensWord is the tokens a RUN'S TASK has read and written, off the +// store's ledger (planroom.go), and nothing where the ledger names none. A +// node's page does not say it here: a node's tokens are the live column's. +func (a *app) roomTokensWord(node *taskNode) string { + if a.roomPlan() == nil || node == nil || node.tokens <= 0 { + return "" + } + return tokenWord(node.tokens) + " tok" +} + func (f roomFactFields) ranked() []rowField { - return []rowField{f.state, f.clock, f.spend, f.calls, f.live, f.model, f.effort} + return []rowField{f.state, f.clock, f.spend, f.calls, f.live, f.model, f.tokens, f.effort} } // roomGroupedFacts gives the outcome, activity and setup distinct reading @@ -40,7 +58,7 @@ func (a *app) roomGroupedFacts(node *taskNode, width int, stop string) (string, if f.live.known() { left += a.pal.muted(rowSep + rowAll([]rowField{f.live})) } - right := a.pal.muted(rowAll([]rowField{f.clock, f.spend})) + right := a.pal.muted(rowAll([]rowField{f.clock, f.spend, f.tokens})) if stop != "" { right += " " + a.pal.ink(stop) } @@ -50,7 +68,7 @@ func (a *app) roomGroupedFacts(node *taskNode, width int, stop string) (string, } state := strings.TrimSpace(a.roomMark(node) + " " + rowAll([]rowField{f.state})) activity := rowAll([]rowField{f.clock, f.calls, f.live}) - setup := a.roomSetupInk(rowAll([]rowField{f.model, f.effort, f.spend}), node) + setup := a.roomSetupInk(rowAll([]rowField{f.model, f.tokens, f.effort, f.spend}), node) left := state if activity != "" { if left != "" { diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go index 84ff90ad6..2cc3b80be 100644 --- a/internal/tui3/roompanel.go +++ b/internal/tui3/roompanel.go @@ -290,7 +290,7 @@ func (a *app) roomTitleRow(width int) string { right, painted := "", "" if node := a.roomNode(); node != nil { f := a.roomFactsOf(node) - right = rowAll([]rowField{f.state, f.live, f.clock, f.spend}) + right = rowAll([]rowField{f.state, f.live, f.clock, f.spend, f.model, f.tokens}) state := rowAll([]rowField{f.state}) painted = a.taskStateInk(node)(state) + a.pal.muted(strings.TrimPrefix(right, state)) } @@ -348,7 +348,9 @@ func taskSetupLater(node *taskNode) bool { } func taskSetupAvailable(node *taskNode) bool { - if node == nil || node.run != "" { + // A ROW LENT TO A RUN'S TASK HAS NO NODE TO SET UP: its worker is the run's, + // and no door here reaches it (planroom.go). + if node == nil || node.run != "" || node.planRow != nil { return false } if node.state == session.TaskRunning || node.state == session.TaskQueued { diff --git a/internal/tui3/roomtabs.go b/internal/tui3/roomtabs.go new file mode 100644 index 000000000..8ae243099 --- /dev/null +++ b/internal/tui3/roomtabs.go @@ -0,0 +1,104 @@ +package tui3 + +// roomtabs.go is THE TASK ROOM'S TWO TABS: the transcript, which is what the +// work said and did, and the work, which is what it changed. +// +// EVERY TASK'S PAGE HAS BOTH, on either engine, because there is one page type +// (planroom.go). They are named at the right end of the trail row, beside the +// way out, where they cost no row of the page: `tab` over an empty box moves +// between them, and a press on either name opens it. + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +// roomTab is which of the room's two tabs is on screen. The zero value is the +// transcript, which is what every page opens on. +type roomTab int + +const ( + roomTabTranscript roomTab = iota + roomTabWork +) + +// The tabs' names, as the trail row draws them and the manual quotes them. +const ( + roomTabTranscriptWord = "transcript" + roomTabWorkWord = "work" + // roomTabKey is the key that moves between the tabs, over an empty box. A + // box with words in it keeps the key for what it already does there. + roomTabKey = "tab" +) + +// roomTabWords is the tabs in the order they are drawn. +var roomTabWords = []string{roomTabTranscriptWord, roomTabWorkWord} + +// roomHasTabs reports whether the page on screen is a task's page. A run's +// graph is a page of its own kind and draws no tabs. +func (a *app) roomHasTabs() bool { + return a.room != nil && a.room.orch == nil +} + +// roomTabTo opens one tab and asks for what it draws. +func (a *app) roomTabTo(tab roomTab) tea.Cmd { + if !a.roomHasTabs() || a.room.tab == tab { + return nil + } + a.room.tab = tab + a.room.offset, a.room.stick = 0, true + a.roomTouched() + a.touch() + return a.planRoomWorkRead(true) +} + +// roomTabNext is `tab` over an empty box: the other tab. +func (a *app) roomTabNext() tea.Cmd { + if !a.roomHasTabs() { + return nil + } + if a.room.tab == roomTabWork { + return a.roomTabTo(roomTabTranscript) + } + return a.roomTabTo(roomTabWork) +} + +// roomTabsLabel is the tabs as the trail row draws them, painted, and the +// columns each name takes in the label, from the label's own start. +func (a *app) roomTabsLabel() (string, int, []hudSpan) { + if !a.roomHasTabs() { + return "", 0, nil + } + var painted strings.Builder + spans := make([]hudSpan, 0, len(roomTabWords)) + at := 0 + for i, word := range roomTabWords { + if i > 0 { + painted.WriteString(a.pal.dim(railSep)) + at += ansi.StringWidth(railSep) + } + if roomTab(i) == a.room.tab { + painted.WriteString(a.pal.accent(word)) + } else { + painted.WriteString(a.pal.dim(word)) + } + spans = append(spans, hudSpan{from: at, to: at + ansi.StringWidth(word)}) + at += ansi.StringWidth(word) + } + return painted.String(), at, spans +} + +// roomTabPress opens the tab a press on the trail row landed on. +func (a *app) roomTabPress(x, y int) (tea.Cmd, bool) { + if !a.roomOpen() || a.headHeight() == 0 || y != a.roomHeadRow() { + return nil, false + } + for i, span := range a.roomTabSpans { + if span.holds(x) { + return a.roomTabTo(roomTab(i)), true + } + } + return nil, false +} diff --git a/internal/tui3/stepdisplay_test.go b/internal/tui3/stepdisplay_test.go index 8cfb7fd49..c9838564b 100644 --- a/internal/tui3/stepdisplay_test.go +++ b/internal/tui3/stepdisplay_test.go @@ -116,7 +116,10 @@ func TestTaskPageOmitsOwnRecordIDsAndRunCopyPath(t *testing.T) { t.Fatal("task place did not open") } drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - page := taskSheetText(a) + if a.roomPlan() == nil { + t.Fatal("enter on the task's row did not open its room") + } + page := roomCallText(t, a) // `files` IS ON THE NEVER LIST, AND IT USED TO BE WANTED. Step 1's row left // out a part addressed to the run's record, so the head cannot be told from // that part's print: one shell, one interleaved observation, and the head @@ -130,11 +133,11 @@ func TestTaskPageOmitsOwnRecordIDsAndRunCopyPath(t *testing.T) { t.Fatalf("page contains %q:\n%s", never, page) } } - shell := a.actionLead(session.ActionRun, true) - for _, want := range []string{shell + "ls", shell + "go test ./...", "ok"} { - if !strings.Contains(page, want) { - t.Fatalf("page lacks %q:\n%s", want, page) - } + if got, want := roomCommands(a), []string{"ls", "go test ./..."}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("the room's calls are %q, want %q", got, want) + } + if !strings.Contains(page, "ok") { + t.Fatalf("the room lacks step 3's output:\n%s", page) } } @@ -188,7 +191,7 @@ func TestTaskPageDrawsObservationHeadOnlyWhenOmittedPartsCannotWriteIt(t *testin t.Fatal("task place did not open") } drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - page := taskSheetText(a) + page := roomCallText(t, a) if strings.Contains(page, test.head) != test.wantHead { t.Fatalf("head presence = %v, want %v:\n%s", strings.Contains(page, test.head), test.wantHead, page) } diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go index b5077be5e..7f0927d00 100644 --- a/internal/tui3/stop.go +++ b/internal/tui3/stop.go @@ -466,6 +466,10 @@ func (a *app) stopHere() stopTarget { if a.roomIsGuest() { return stopTarget{} } + // A RUN'S TASK IS STOPPED THROUGH THE PLAN'S DOOR (planroom.go). + if a.room.plan != nil { + return a.planRoomStopTarget() + } return a.stopTaskTarget(a.tasks[a.room.id]) } // A HELD ROSTER IS STILL THE FIRST ANSWER OFF IT, because its cursor is where @@ -587,19 +591,13 @@ func (a *app) stopKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // card raised over a screen the frame is not drawing would be a question // nobody can see, answered by the next key they press. // - // A RUN TASK'S PAGE IS ON IT WHEREVER IT WAS OPENED FROM. The page has a box - // of its own and reads this same key itself, over that box when it is empty - // ([app.taskPlanKey]). Opened from the side list it sits over a conversation - // whose own box is empty, so nothing below stood down, and a note holding - // this letter raised the card mid-word and lost the rest of the sentence. - // - // AND SO IS A PAGE THAT IS ON ITS WAY. A hosted conversation reads the page + // AND SO IS A RUN TASK'S ROOM THAT IS ON ITS WAY. A hosted conversation reads the page // off the loop, and between the press and the answer the keys already belong // to the page ([railPlanPending]); read here first, this letter would raise // the card over a page that is not drawn yet and take the rest of the note. switch { case key == "ctrl+c", a.asking(), a.awaitingTask(), a.guarding(), - a.taskSheet.planOn, a.railPlanPending.id != "", + a.railPlanPending.id != "", a.at(pageSettings), a.at(pageTasks), a.at(pageHome), a.deckShowing(), a.pick.open, a.roster.open, a.copy.on, a.welcome.open, a.menu.open, a.comp.open, a.rew.on, a.rewSheet.open: diff --git a/internal/tui3/stoprun_page_test.go b/internal/tui3/stoprun_page_test.go index db640a150..6c8581d99 100644 --- a/internal/tui3/stoprun_page_test.go +++ b/internal/tui3/stoprun_page_test.go @@ -28,8 +28,8 @@ func answerStopCard(t *testing.T, a *app) { func TestStopOnTheRunsOwnPageRaisesTheCardAndTheCardStopsTheRun(t *testing.T) { a, counted := railTaskPageApp(t, true) clickRail(t, a, 0) - if !a.railTaskPlanOn { - t.Fatal("the rail row did not open its page") + if a.roomPlan() == nil { + t.Fatal("the rail row did not open its room") } drive(t, a, key(stopRaiseKey)) if len(counted.cancelled) != 0 { @@ -38,9 +38,6 @@ func TestStopOnTheRunsOwnPageRaisesTheCardAndTheCardStopsTheRun(t *testing.T) { if !a.stopping() { t.Fatal("stop on the run's own page raised no card") } - if a.railTaskPlanOn || a.taskSheet.planOn { - t.Fatal("the page still takes the frame whole, so the card it raised is drawn nowhere") - } if frame := strings.Join(frameOf(t, a), "\n"); !strings.Contains(frame, "Stop this task?") { t.Fatalf("the card is up and the frame does not draw it:\n%s", frame) } @@ -64,42 +61,39 @@ func TestKeepGoingOnTheRunsCardStopsNothing(t *testing.T) { } } -// A PART'S PAGE KEEPS THE STOP IT HAD: the store takes a cancel on a part, so -// nothing is asked twice for it. -func TestStopOnAPartsPageIsStillTheStoresOwnCancel(t *testing.T) { +// A PART'S ROOM STOPS IT THE WAY EVERY ROOM STOPS ITS TASK: the card, and +// then the store's own cancel for the part. +func TestStopOnAPartsRoomIsTheCardAndThenTheStoresOwnCancel(t *testing.T) { a, counted := railTaskPageApp(t, true) - clickRail(t, a, 0) - drive(t, a, key("down")) - drive(t, a, key("enter")) - if a.taskSheet.plan.Row.ID != "3" { - t.Fatalf("the part's page did not open: on %q", a.taskSheet.plan.Row.ID) - } counted.setStatus("3", "running") - a.taskSheet.plan.Row.Status = "running" + part := counted.planFake.pages["3"] + part.Row.Status = "running" + counted.planFake.pages["3"] = part + openPlanRoomNow(t, a, "3") + if plan := a.roomPlan(); plan == nil || plan.id != "3" { + t.Fatal("the part's room did not open") + } drive(t, a, key(stopRaiseKey)) - if a.stopping() || len(counted.cancelled) != 1 || counted.cancelled[0] != "3" { - t.Fatalf("stop on a part's page: card up %t, cancelled %v", a.stopping(), counted.cancelled) + if !a.stopping() || len(counted.cancelled) != 0 { + t.Fatalf("stop on a part's room: card up %t, cancelled %v", a.stopping(), counted.cancelled) + } + answerStopCard(t, a) + if len(counted.cancelled) != 1 || counted.cancelled[0] != "3" { + t.Fatalf("the card's stop reached %v, want the part", counted.cancelled) } } -// A RUN CANNOT BE HELD, SO ITS PAGE NEVER OFFERS TO. The store holds a part and -// everything under it and refuses the run's own task, so `p pause` under a run -// was an offer that could only be refused. The key is a letter there. -func TestPauseIsNeitherOfferedNorTakenOnTheRunsOwnPage(t *testing.T) { +// NOTHING IN A TASK'S ROOM HOLDS A TASK. The tasks place keeps its `p` on a +// part's row; in the room the letter is a letter in the box. +func TestPIsALetterInARunsTaskRoom(t *testing.T) { a, counted := railTaskPageApp(t, true) clickRail(t, a, 0) - if foot := a.taskPlanKeys(); strings.Contains(foot, tasksPlanPauseWord) || strings.Contains(foot, tasksPlanResumeWord) { - t.Fatalf("the run's own page offers a hold the store refuses: %q", foot) - } - if foot := a.taskPlanKeys(); !strings.Contains(foot, tasksPlanCancelWord) { - t.Fatalf("the run's own page does not offer its stop: %q", foot) - } drive(t, a, key("p")) if len(counted.paused)+len(counted.resumed) != 0 { - t.Fatalf("p on the run's own page asked the store to hold it: %v %v", counted.paused, counted.resumed) + t.Fatalf("p in the run's room asked the store to hold it: %v %v", counted.paused, counted.resumed) } - if got := a.taskSheet.planNote.String(); got != "p" { - t.Fatalf("p on the run's own page is a letter in the note, and the box holds %q", got) + if got := string(a.input.value); got != "p" { + t.Fatalf("p in the run's room is a letter in the box, and the box holds %q", got) } } @@ -127,8 +121,8 @@ func TestTheDoorIsReadBeforeAPageThatIsOnItsWayAndBeforeThePage(t *testing.T) { b, _ := railTaskPageApp(t, true) clickRail(t, b, 0) - if !b.railTaskPlanOn { - t.Fatal("the rail row did not open its page") + if b.roomPlan() == nil { + t.Fatal("the rail row did not open its room") } if b.key(key("ctrl+c")) == nil { t.Fatal("ctrl+c on an open page did nothing") @@ -154,8 +148,8 @@ func TestAnAnswerForAFrontThePersonLeftEndsTheHold(t *testing.T) { if a.railPlanPending.id != "" { t.Fatal("the read is over and the hold still takes every key") } - if a.railTaskPlanOn { - t.Fatal("an answer for a front the person left opened a page over where they are now") + if a.roomOpen() { + t.Fatal("an answer for a front the person left opened a room over where they are now") } } @@ -193,10 +187,10 @@ func TestStopTypedWhileTheRunsOwnPageOpensIsALetterInItsNote(t *testing.T) { if a.stopping() || len(counted.cancelled) != 0 { t.Fatalf("stop typed before the run's own page opened acted: card up %t, cancelled %v", a.stopping(), counted.cancelled) } - if !a.railTaskPlanOn { - t.Fatal("the answer did not open the run's own page") + if a.roomPlan() == nil { + t.Fatal("the answer did not open the run's own room") } - if got := a.taskSheet.planNote.String(); got != stopRaiseKey { - t.Fatalf("the page's box holds %q, want the letter typed while it opened", got) + if got := string(a.input.value); got != stopRaiseKey { + t.Fatalf("the room's box holds %q, want the letter typed while it opened", got) } } diff --git a/internal/tui3/taskpage_keys_test.go b/internal/tui3/taskpage_keys_test.go index 4e1574f26..3d48a8dde 100644 --- a/internal/tui3/taskpage_keys_test.go +++ b/internal/tui3/taskpage_keys_test.go @@ -10,82 +10,66 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// ── WHAT A PLAN PAGE'S KEYS DO WHEN THERE IS NOTHING LEFT TO STEER ────────── +// ── WHAT A RUN'S TASK'S ROOM DOES WITH ITS KEYS WHEN THERE IS NOTHING LEFT TO +// STEER ───────────────────────────────────────────────────────────────────── // -// The key line is the promise: a page names `x` and `p` only while the task -// can still be ended or held ([app.tasksPlanKeyWords]). These hold the keys to -// the same promise, and hold the keys typed before a page opens to the one -// receiver the page claims for them, its note box. +// The room offers `x` only while the task can still be ended (planroom.go's +// [app.planRoomStopTarget]). These hold the keys to that promise, and hold the +// keys typed before a room opens to the one receiver the room claims for them, +// its box. -// endPlanPage settles the task on the page that is open, in the store and on -// the surface both, so a re-read on the paint clock reads the same word. -func endPlanPage(a *app, counted *railPlanCounter, status string) { - id := a.taskSheet.plan.Row.ID +// endPlanTask settles one store task in the store, so the room's read wears it. +func endPlanTask(counted *railPlanCounter, id, status string) { counted.setStatus(id, status) if page, ok := counted.planFake.pages[id]; ok { page.Row.Status = status counted.planFake.pages[id] = page } - a.taskSheet.plan.Row.Status = status } -// AN ENDED RUN'S PAGE RAISES NO STOP CARD. `x` asked "Stop this task?" over a +// AN ENDED RUN'S ROOM RAISES NO STOP CARD. `x` asked "Stop this task?" over a // run that had already finished, and the stop the card then sent changed -// nothing and was reported as though it had. The key line names neither key -// there, so neither key acts: both are letters in the note. -func TestAnEndedRunsPageTakesNeitherTheStopNorTheHold(t *testing.T) { +// nothing and was reported as though it had. Both keys are letters in the box. +func TestAnEndedRunsRoomTakesNeitherTheStopNorTheHold(t *testing.T) { a, counted := railTaskPageApp(t, true) - clickRail(t, a, 0) - if !a.railTaskPlanOn { - t.Fatal("the rail row did not open its page") - } - endPlanPage(a, counted, "done") - if foot := a.taskPlanKeys(); strings.Contains(foot, tasksPlanCancelWord) { - t.Fatalf("the fixture's ended page still offers the stop: %q", foot) + endPlanTask(counted, "2", "done") + openPlanRoomNow(t, a, "2") + if a.roomPlan() == nil { + t.Fatal("the run's room did not open") } drive(t, a, key(stopRaiseKey)) - if a.stopping() { - t.Fatal("x on an ended run's page raised the stop card") - } - if len(counted.cancelled) != 0 { - t.Fatalf("x on an ended run's page asked the store to cancel %v", counted.cancelled) - } - if !a.railTaskPlanOn { - t.Fatal("x on an ended run's page closed the page") + if a.stopping() || len(counted.cancelled) != 0 { + t.Fatalf("x on an ended run's room raised a card (%t) or cancelled %v", a.stopping(), counted.cancelled) } drive(t, a, key("p")) if len(counted.paused)+len(counted.resumed) != 0 { - t.Fatalf("p on an ended run's page asked the store to hold it: %v %v", counted.paused, counted.resumed) + t.Fatalf("p on an ended run's room asked the store to hold it: %v %v", counted.paused, counted.resumed) } - if got := a.taskSheet.planNote.String(); got != stopRaiseKey+"p" { - t.Fatalf("the two letters on an ended page are letters in the note, and the box holds %q", got) + if got := string(a.input.value); got != stopRaiseKey+"p" { + t.Fatalf("the two letters on an ended room are letters in the box, and the box holds %q", got) } } -// AN ENDED PART'S PAGE ASKS THE STORE NOTHING. `x` sent the store's cancel for -// a part that had finished, and the page drew the store's raw refusal, -// `task "3" is already terminal`, for a key its own foot never offered. -func TestAnEndedPartsPageTakesNeitherTheStopNorTheHold(t *testing.T) { +// AN ENDED PART'S ROOM ASKS THE STORE NOTHING, and draws no refusal for a key +// it never offered. +func TestAnEndedPartsRoomTakesNeitherTheStopNorTheHold(t *testing.T) { a, counted := railTaskPageApp(t, true) - clickRail(t, a, 0) - drive(t, a, key("down")) - drive(t, a, key("enter")) - if a.taskSheet.plan.Row.ID != "3" { - t.Fatalf("the part's page did not open: on %q", a.taskSheet.plan.Row.ID) + openPlanRoomNow(t, a, "3") + if plan := a.roomPlan(); plan == nil || plan.id != "3" { + t.Fatal("the part's room did not open") } - endPlanPage(a, counted, "done") counted.refuse = errors.New(`task "3" is already terminal`) drive(t, a, key(stopRaiseKey)) drive(t, a, key("p")) if len(counted.cancelled)+len(counted.paused)+len(counted.resumed) != 0 { - t.Fatalf("keys on an ended part's page reached the store: cancel %v pause %v resume %v", + t.Fatalf("keys on an ended part's room reached the store: cancel %v pause %v resume %v", counted.cancelled, counted.paused, counted.resumed) } - if strings.Contains(a.pageMsg, "terminal") { - t.Fatalf("an ended part's page drew the store's refusal: %q", a.pageMsg) + if strings.Contains(planRoomText(t, a), "terminal") { + t.Fatalf("an ended part's room drew the store's refusal:\n%s", planRoomText(t, a)) } - if got := a.taskSheet.planNote.String(); got != stopRaiseKey+"p" { - t.Fatalf("the box on an ended part's page holds %q, want the two letters", got) + if got := string(a.input.value); got != stopRaiseKey+"p" { + t.Fatalf("the box on an ended part's room holds %q, want the two letters", got) } } @@ -108,13 +92,13 @@ func TestAnEndedPlanRowInTheListTakesNeitherKey(t *testing.T) { } } -// KEYS TYPED WHILE A PAGE IS ON ITS WAY GO TO ITS NOTE BOX AND NOWHERE ELSE. -// The held keys were replayed through the page's whole keyboard, so a note -// that began with the stop key, typed before the page was drawn, cancelled a -// running part with nothing asked. What a person types at a page they cannot -// see yet is a note, and a note typed blind is left in the box, unsent, for -// them to read before they send it. -func TestKeysTypedWhileAPartsPageOpensAreTheNoteAndNothingElse(t *testing.T) { +// KEYS TYPED WHILE A ROOM IS ON ITS WAY GO TO ITS BOX AND NOWHERE ELSE. The +// held keys were replayed through the page's whole keyboard, so a note that +// began with the stop key, typed before the page was drawn, cancelled a running +// part with nothing asked. What a person types at a room they cannot see yet is +// a note, and a note typed blind is left in the box, unsent, for them to read +// before they send it. +func TestKeysTypedWhileAPartsRoomOpensAreTheBoxAndNothingElse(t *testing.T) { a, counted := railTaskPageApp(t, true) counted.setStatus("3", "running") part := counted.planFake.pages["3"] @@ -135,48 +119,25 @@ func TestKeysTypedWhileAPartsPageOpensAreTheNoteAndNothingElse(t *testing.T) { close(held.release) drive(t, a, <-answer) - if !a.railTaskPlanOn || a.taskSheet.plan.Row.ID != "3" { - t.Fatalf("the answer did not open the part's page: on %v, task %q", a.railTaskPlanOn, a.taskSheet.plan.Row.ID) + if plan := a.roomPlan(); plan == nil || plan.id != "3" { + t.Fatalf("the answer did not open the part's room: room %v", a.roomOpen()) } if len(counted.cancelled) != 0 || a.stopping() { - t.Fatalf("a note typed before the page opened ended the part: cancelled %v, card up %t", counted.cancelled, a.stopping()) + t.Fatalf("a note typed before the room opened ended the part: cancelled %v, card up %t", counted.cancelled, a.stopping()) } if len(counted.noted) != 0 { - t.Fatalf("a note typed blind was sent before its page was seen: %v", counted.noted) + t.Fatalf("a note typed blind was sent before its room was seen: %v", counted.noted) } - if got := a.taskSheet.planNote.String(); got != typed { - t.Fatalf("the page's box holds %q, want every key typed while it opened: %q", got, typed) - } -} - -// CTRL+O MEASURES THE BRIEF AT THE WIDTH THE PAGE DRAWS IT. It counted at the -// conversation's body width, which is narrower by the rail, so a brief the -// page drew whole in three rows still toggled a fold nobody could see (#1289). -func TestCtrlOCountsTheBriefAtThePagesOwnWidth(t *testing.T) { - a, _ := railTaskPageApp(t, true) - clickRail(t, a, 0) - if !a.railTaskPlanOn { - t.Fatal("the rail row did not open its page") - } - desc := strings.TrimSpace(strings.Repeat("word ", 110)) - // THE PAGE DRAWS ITS BODY ONE CELL IN FROM EACH EDGE OF THE WHOLE FRAME. - drawn, narrow := planBriefRows(desc, a.width-2), planBriefRows(desc, a.bodyWidth()) - if len(drawn) > briefFoldLines || len(narrow) <= briefFoldLines { - t.Fatalf("fixture: the brief wraps to %d rows on the page and %d at the body width; want at most %d and more than %d", - len(drawn), len(narrow), briefFoldLines, briefFoldLines) - } - a.taskSheet.plan.Description = desc - drive(t, a, tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl}) - if a.taskSheet.planBriefFull { - t.Fatal("ctrl+o toggled a fold on a brief the page draws whole") + if got := string(a.input.value); got != typed { + t.Fatalf("the room's box holds %q, want every key typed while it opened: %q", got, typed) } } // ── THE NOTE'S RECEIPT ────────────────────────────────────────────────────── -// noteTwoPages is a list with two ordinary tasks, the first one's page open and -// a note typed into its box. -func noteTwoPages(t *testing.T) (*app, *planFake, []session.PlanTaskRow) { +// noteTwoRooms is a list with two ordinary tasks, the first one's room open +// from the tasks place and a note typed into its box. +func noteTwoRooms(t *testing.T) (*app, *planFake, []session.PlanTaskRow) { t.Helper() rows := []session.PlanTaskRow{ {ID: "t-alpha", Parent: "t-run", Title: "Alpha", Status: "claimed"}, @@ -191,8 +152,8 @@ func noteTwoPages(t *testing.T) (*app, *planFake, []session.PlanTaskRow) { t.Fatal("the place refused to open over a plan") } drive(t, a, key("enter")) - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != "t-alpha" { - t.Fatalf("enter did not open alpha's page: on %v, task %q", a.taskSheet.planOn, a.taskSheet.plan.Row.ID) + if plan := a.roomPlan(); plan == nil || plan.id != "t-alpha" || a.at(pageTasks) { + t.Fatalf("enter did not open alpha's room over the conversation: room %v, tasks %v", a.roomOpen(), a.at(pageTasks)) } for _, r := range "a note" { drive(t, a, key(string(r))) @@ -200,10 +161,10 @@ func noteTwoPages(t *testing.T) (*app, *planFake, []session.PlanTaskRow) { return a, fake, rows } -// ENTER TWICE SENDS A NOTE ONCE. The box was emptied only when the store -// answered, so a second enter pressed before then sent the same words again. +// ENTER TWICE SENDS A NOTE ONCE. The box is emptied the instant the words +// leave, so a second enter has nothing to send. func TestEnterTwiceSendsANoteOnce(t *testing.T) { - a, fake, _ := noteTwoPages(t) + a, fake, _ := noteTwoRooms(t) _, first := a.Update(key("enter")) _, second := a.Update(key("enter")) drain(t, a, tea.Batch(first, second)) @@ -212,22 +173,23 @@ func TestEnterTwiceSendsANoteOnce(t *testing.T) { } } -// A NOTE'S RECEIPT LANDS ON ITS OWN PAGE OR NOWHERE. The reply set the open -// page to the page the note was sent from, whichever page a person had moved -// to while the store was answering. -func TestANoteReplyNeverOverwritesAnotherPage(t *testing.T) { - a, fake, rows := noteTwoPages(t) +// A NOTE'S RECEIPT LANDS ON ITS OWN ROOM OR NOWHERE. A person can move to +// another task's room while the store is answering, and the answer must not +// touch the room they moved to. +func TestANoteReplyNeverOverwritesAnotherRoom(t *testing.T) { + a, fake, rows := noteTwoRooms(t) _, sent := a.Update(key("enter")) // THE PERSON MOVES ON BEFORE THE STORE ANSWERS. - a.taskSheet.plan = fake.pages["t-beta"] - a.taskSheet.planNote.reset() - a.taskSheet.planNote.insert("for beta") + openPlanRoomNow(t, a, "t-beta") + for _, r := range "for beta" { + drive(t, a, key(string(r))) + } drain(t, a, sent) - if got := a.taskSheet.plan.Row.ID; got != rows[1].ID { - t.Fatalf("the note's reply put %q's page over the page the person had open", got) + if plan := a.roomPlan(); plan == nil || plan.id != rows[1].ID { + t.Fatal("the note's reply moved the person off beta's room") } - if got := a.taskSheet.planNote.String(); got != "for beta" { - t.Fatalf("the note's reply emptied another page's box: it holds %q", got) + if got := string(a.input.value); got != "for beta" { + t.Fatalf("the note's reply emptied another room's box: it holds %q", got) } if len(fake.noted) != 1 || fake.noted[0].id != "t-alpha" { t.Fatalf("the note went to %v, want alpha once", fake.noted) diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index d05f99671..16b21ba1f 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -22,7 +22,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" @@ -660,128 +659,20 @@ func planStoreDraws(rows []tasksMineRow, plan []session.PlanTaskRow) []tasksMine // ── THE PAGE ONE PLAN ROW OPENS ───────────────────────────────────────────── -// taskSheetPlan opens the page over one plan row: the description the worker was -// given, every note left on the task with its author and moment, and the -// trajectory its worker recorded. -// -// IT IS THE SHEET'S OWN MACHINERY AND NOT A FOURTH SURFACE. Enter over a record -// row opens the card through [app.taskSheetInside]; this is the same latch, -// [tasksPlace.detailOn], the same full frame and the same `esc` that backs out -// one layer to the list — so a person who has learned the card has learned this -// page, and the foot of either names the same way out. The one thing that is not -// reused is the CONTENT, because a plan task has no record row to read: the page -// is built from the store's own read ([session.Agent.PlanTaskPage]). -// -// A PAGE THE ENGINE WILL NOT ANSWER FOR IS NOT OPENED. A task this chat did not -// spawn, or one whose store has gone, leaves the list where it was rather than -// raising a page of blanks. -func (a *app) taskSheetPlan(id string) tea.Cmd { return a.taskSheetPlanAsk(id, nil, nil, nil) } - -// taskSheetPlanFrom is [app.taskSheetPlan] for a step INTO one of a page's -// parts: `from` is the page stepped out of, and it goes on the way back when -// the part's page has opened and not before. -func (a *app) taskSheetPlanFrom(id string, from *session.PlanTaskPage) tea.Cmd { - return a.taskSheetPlanAsk(id, from, nil, nil) -} - -// taskSheetPlanAsk is the ONE door onto a stored page, for every gesture that -// opens one: enter in the list, a step into a part, a press on a rail row. The -// read leaves the loop ([app.offLoop]) and what happens next is decided when it -// comes back: `opened` runs once the page is up, and `missing` is the gesture's -// own answer for a task the store has no page for, so a rail row whose run's -// store is gone still opens what it always opened. -func (a *app) taskSheetPlanAsk(id string, from *session.PlanTaskPage, opened func() tea.Cmd, missing func() tea.Cmd) tea.Cmd { - agent, ok := a.planReader() - if !ok { - if missing != nil { - return missing() - } - return nil - } - return a.offLoop(func() func(bool) tea.Cmd { - page, found := agent.PlanTaskPage(id) - return func(here bool) tea.Cmd { - if !here { - // EVERY ENDING OF THE READ ENDS THE HOLD IT WAS MADE FOR. The keys - // are held for as long as this read is out and no longer, and the - // read has its own bound: over the wire a call gives up at its - // deadline and answers no page (internal/remote's callDeadline), - // which is the `missing` road below. An answer for a front the - // person has left opens nothing, and used to leave the hold taking - // every key until `esc`. - if opened != nil && a.railPlanPending.id == id { - a.railPlanPending = railPlanPending{} - } - return nil - } - if opened != nil && a.railPlanPending.id != id { - return nil - } - if !found { - if missing != nil { - return missing() - } - return nil - } - // THE PAGE STEPPED OUT OF GOES ON THE WAY BACK ONLY WHEN THE NEW ONE - // OPENED, and only if the person is still on it: a part with no page - // leaves `esc` exactly one step from the list, as it was. - if from != nil { - if !a.taskSheet.planOn || a.taskSheet.plan.Row.ID != from.Row.ID { - return nil - } - a.taskSheet.planBack = append(a.taskSheet.planBack, *from) - } - a.taskSheet.plan, a.taskSheet.planOn, a.taskSheet.detailOn = page, true, true - a.taskSheet.planPageAt = a.now() - a.taskSheet.planBriefFull = false - a.taskSheet.planAt = -1 - a.taskSheet.detailTop = 0 - // A PAGE OPENS AT THE LIVE EDGE. The newest step is the reason the page - // follows at all, so it opens stuck to the bottom and a scroll is what - // releases it ([app.taskPlanTopFor], [app.taskPlanScroll]). - a.taskSheet.planStick = true - a.taskSheet.planNote.reset() - // The card's recovery band belongs to the row the CARD was opened from, and - // this page is not that row ([app.taskSheetInside] clears it at the one other - // door for the same reason). - a.taskSheet.awayOwner = tasksAwayOwner{} - var cmd tea.Cmd - if opened != nil { - cmd = opened() - } - a.touch() - return cmd - } - }) -} - -// closeTaskPlan backs out one layer to the list, which is the card's own `esc`. -func (a *app) closeTaskPlan() { - a.taskSheet.plan, a.taskSheet.planOn, a.taskSheet.detailOn = session.PlanTaskPage{}, false, false - a.taskSheet.planBriefFull = false - a.taskSheet.detailTop, a.taskSheet.planStick = 0, false - // A half-typed note does not survive the page it was typed on, which is the - // box's own law everywhere here ([app.placeHomeGesture] resets the box it - // empties for the same reason). - a.taskSheet.planNote.reset() - a.touch() -} - // ── the steering verbs ────────────────────────────────────────────────────── // The words the plan keys say, each quoted in the manual exactly as it is // spelled here. const ( - // taskPlanNoteWord is what the page's composer says with nothing typed in - // it: the one thing a person can type on a plan task's page, and the reason - // the box is there at all. + // taskPlanNoteWord is what the room's box says with nothing typed in it on + // a run's task: what the words become, which is a note on the task + // (planroom.go's [app.planRoomSteer]). taskPlanNoteWord = "a note for this task" - // taskPlanPickupWord is the page's one sentence about WHEN a note is read. A - // worker is a separate loop, so a note waits in the store until the worker - // asks for its next step — the manual's own account of a note (worker-harness.md, - // "Steering a task"), said on the page because the page is where the note is - // typed. + // taskPlanPickupWord is WHEN a note is read. A worker is a separate loop, so + // a note waits in the store until the worker asks for its next step — the + // manual's own account of a note (worker-harness.md, "Steering a task"), + // said under the note in the task's room once the store has it + // (planroom.go's [app.planRoomSteer]). taskPlanPickupWord = "the worker reads a note at its next step" // taskPlanRefusedWord leads the line a refused action draws in a step's // place. It is the permissions page's own word for a call that was refused, @@ -870,17 +761,15 @@ func planOwnTask(row session.PlanTaskRow) bool { return strings.TrimSpace(row.ID) != "" && strings.TrimSpace(row.Parent) == "" } -// taskPlanStop is `x` on a plan row or its page. A part is ended by the store's -// own cancel, at once, as it always was. THE RUN'S OWN TASK IS THE WHOLE RUN, -// and ending that is the act the stop card exists to confirm: the card is -// raised, aimed at the run through the plan's own door, and nothing is ended by -// one keystroke. +// taskPlanStop is `x` on a plan row in the tasks place. A part is ended by the +// store's own cancel, at once, as it always was. THE RUN'S OWN TASK IS THE +// WHOLE RUN, and ending that is the act the stop card exists to confirm: the +// card is raised, aimed at the run through the plan's own door, and nothing is +// ended by one keystroke. // -// THE PAGE STEPS ASIDE FOR THE CARD, the way a background job's page does -// (stop.go's [app.raiseStop] says why): it takes the frame whole and the block -// draws every question above the message box, so a card raised over it would be -// a question nobody could see, answered by the next key they pressed. The run's -// row is still on the side list and opens the page again. +// THE PLACE STEPS ASIDE FOR THE CARD (stop.go's [app.raiseStop] says why): it +// takes the frame whole and the block draws every question above the message +// box, so a card raised over it would be a question nobody could see. func (a *app) taskPlanStop(row session.PlanTaskRow) tea.Cmd { if !planOwnTask(row) { return a.taskPlanCancel(row.ID) @@ -888,8 +777,6 @@ func (a *app) taskPlanStop(row session.PlanTaskRow) tea.Cmd { if _, ok := a.planReader(); !ok { return nil } - a.closeTaskPlan() - a.railTaskPlanOn = false a.closeTaskSheet() a.raiseStop(stopTarget{plan: row.ID, noun: stopTaskNoun, detail: stopTaskDetail}) return nil @@ -942,72 +829,6 @@ func (a *app) taskPlanToggle(id string) tea.Cmd { }) } -// taskPlanNoteSend writes what is typed in the page's composer as a person-note -// on the plan task — the store's own note verb, in the person's voice, which the -// task's worker is handed between its own steps ([session.Agent.PlanNote], and -// internal/run's note channel carries it). IT IS NOT A CHAT -// TURN: the words go to the store and never to the model, so nothing here starts -// one. -// -// ONE NOTE IS ONE SEND. The box keeps the words until the store answers, so a -// second `enter` pressed before then sent them again; while a note is on its way -// ([taskSheet.planSending]) `enter` sends nothing. -// -// AND THE ANSWER LANDS ON THE PAGE IT WAS SENT FROM OR NOWHERE. A person can -// move to another task's page while the store is answering, and the receipt used -// to put the sent-from page over whichever page was open and empty that page's -// box (#1240). So the answer is folded only while the same task's page is still -// the one up, and the box is emptied only if it still holds exactly what was -// sent, so words typed after the send are not taken with it. -func (a *app) taskPlanNoteSend() tea.Cmd { - raw := a.taskSheet.planNote.String() - text := strings.TrimSpace(raw) - if text == "" || a.taskSheet.planSending { - return nil - } - agent, ok := a.planReader() - if !ok { - return nil - } - id := a.taskSheet.plan.Row.ID - a.taskSheet.planSending = true - return a.offLoop(func() func(bool) tea.Cmd { - err := agent.PlanNote(id, text) - page, found := agent.PlanTaskPage(id) - return func(here bool) tea.Cmd { - a.taskSheet.planSending = false - if !here { - return nil - } - onPage := a.taskSheet.planOn && a.taskSheet.plan.Row.ID == id - if err != nil { - if onPage { - a.pageMsg = err.Error() - } - a.touch() - return nil - } - a.pageMsg = "" - a.railStamp++ - if !onPage { - a.touch() - return nil - } - if a.taskSheet.planNote.String() == raw { - a.taskSheet.planNote.reset() - } - // Read the page again so the note a person just left is on the screen, which - // is the receipt the store cannot draw itself. - if found { - a.taskSheet.plan = page - a.taskSheet.planPageAt = a.now() - } - a.touch() - return nil - } - }) -} - // taskSheetPlanKey is a plan row's own keys in the LIST, over an empty box the // way the roster takes its bare letters (stop.go's `x IS TAKEN OVER AN EMPTY // BOX`): `x` ends the task through the store's cancel — the key that cancels a @@ -1079,614 +900,6 @@ func planEnded(row session.PlanTaskRow) bool { return false } -// taskPlanKey is the page's keyboard: `esc` and the chord out, the four reading -// keys the card also spends (the page is read down, so the wheel and the arrows -// move an offset rather than a cursor), the two verbs a plan row has — `x` and -// `p`, taken over an EMPTY composer — and the note itself, where every printable -// key goes into the box and `enter` sends it ([app.taskPlanNoteSend]) rather -// than a chat turn. -func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { - key := msg.String() - // The caret's own chords first, the route every box on this surface takes - // (place_tasks.go's filter, the conversation's composer). - if editorMotion(&a.taskSheet.planNote, key) || - editorUndo(&a.taskSheet.planNote, key) || - editorWordKill(&a.taskSheet.planNote, key) { - a.touch() - return nil - } - // A letter is a letter the moment there is a note to type, so the row's own - // keys are read over an empty box and never over a sentence (the list's own - // law, [app.taskSheetPlanKey]). - // - // AND ON A PAGE WHOSE TASK HAS ENDED THEY ARE LETTERS TOO, because the key - // line names neither there ([app.tasksPlanKeyWords]). `x` on a finished run - // asked "Stop this task?" and the stop it sent changed nothing, and on a - // finished part it drew the store's own refusal; a key the line does not - // offer is the letter it is (#1240). - if a.taskSheet.planNote.empty() && !planEnded(a.taskSheet.plan.Row) { - switch key { - case stopRaiseKey: - return a.taskPlanStop(a.taskSheet.plan.Row) - case "p": - // NOTHING HOLDS A WHOLE RUN, so on the run's own page this key is the - // letter it is and starts a note ([planOwnTask]). - if !planOwnTask(a.taskSheet.plan.Row) { - return a.taskPlanToggle(a.taskSheet.plan.Row.ID) - } - } - } - switch key { - case "esc", "left": - if n := len(a.taskSheet.planBack); n > 0 { - a.taskSheet.plan = a.taskSheet.planBack[n-1] - a.taskSheet.planBack = a.taskSheet.planBack[:n-1] - a.taskSheet.planAt = -1 - a.touch() - } else { - a.closeTaskPlan() - } - return nil - case taskSheetKey: - a.closeTaskSheet() - return nil - case "up", "ctrl+p": - if a.taskSheet.planAt >= 0 { - a.taskSheet.planAt-- - } else { - a.taskPlanScroll(-1) - } - return nil - case "down", "ctrl+n": - if a.taskSheet.planAt+1 < len(a.taskSheet.plan.Children) { - a.taskSheet.planAt++ - } else { - a.taskPlanScroll(1) - } - return nil - case "pgup": - a.taskPlanScroll(-taskSheetRows) - return nil - case "pgdown": - a.taskPlanScroll(taskSheetRows) - return nil - case "ctrl+o": - // THE FOLD IS COUNTED AT THE WIDTH THE PAGE DRAWS AT ([app.taskPlanBodyWidth]), - // never at the conversation's body width: the page takes the whole frame, - // and a brief counted narrower than it is drawn could toggle a fold the - // page never showed (#1289). - if len(planBriefRows(a.taskSheet.plan.Description, a.taskPlanBodyWidth())) > briefFoldLines { - a.taskSheet.planBriefFull = !a.taskSheet.planBriefFull - a.taskSheet.detailTop = 0 - a.taskSheet.planStick = false - a.touch() - } - return nil - case "enter": - if a.taskSheet.planNote.empty() && a.taskSheet.planAt >= 0 && a.taskSheet.planAt < len(a.taskSheet.plan.Children) { - old := a.taskSheet.plan - id := old.Children[a.taskSheet.planAt].ID - return a.taskSheetPlanFrom(id, &old) - } - return a.taskPlanNoteSend() - case "backspace": - a.taskSheet.planNote.deleteBackward() - case "ctrl+u": - a.taskSheet.planNote.killToStart() - case "ctrl+k": - a.taskSheet.planNote.killToEnd() - case "ctrl+w": - a.taskSheet.planNote.deleteWord() - default: - // EVERY OTHER PRINTABLE KEY IS THE NOTE. A space types a space here — the - // card pages with it, but a page with a box types spaces. - if text := msg.Key().Text; text != "" { - a.taskSheet.planNote.insert(text) - } - } - a.touch() - return nil -} - -// taskPlanHead is what the page spends above its body: the trail, the facts -// rule and the air under it — three rows, the task room's own head -// ([app.taskPlanTrail], [app.taskPlanFacts]). -const taskPlanHead = 3 - -// taskPlanFoot is what the page spends under its body: the closing rule, the -// note composer, the one sentence saying when a note is read, and the key line, -// in that order — the card's own foot grew three rows for the box the page types -// into and the sentence that says what happens to what is typed in it. -const taskPlanFoot = 4 - -// taskPlanWindow is the page's body, the rows it is drawn in and the rows its -// foot spends, resolved from the frame once: the draw and the scroll both read -// the bottom off this, so the two cannot disagree about where the bottom is. -func (a *app) taskPlanWindow(width, height int) ([]string, int, int) { - if height < 1 { - height = 1 - } - foot := taskPlanFoot - if height-taskPlanHead-foot < 1 { - foot = 0 - } - room := height - taskPlanHead - foot - if room < 1 { - room = 1 - } - return a.taskPlanBody(planBodyWidth(width)), room, foot -} - -// planNoteWho is the one word a note's author is drawn as, on the page and on -// the work tab alike: `you` for a note a person left, and nothing for every -// other author, because the store holds those as ids (the run's number, a -// worker's handle) and no internal name goes on a person's screen. -func planNoteWho(note session.PlanTaskNote) string { - if note.Person { - return "you" - } - return "" -} - -// planBodyWidth is the page's body width inside a frame `width` cells wide: one -// cell of margin on each side, the indent the frame draws every body row with. -func planBodyWidth(width int) int { return width - 2 } - -// taskPlanBodyWidth is the width the page's body is drawn at in this window, -// which is what a key that measures the body must measure at ([app.taskPlanWindow] -// is handed the whole frame, as [app.taskPlanScroll] reads it). -func (a *app) taskPlanBodyWidth() int { - width, _ := a.size() - return planBodyWidth(width) -} - -// taskPlanTopFor resolves the page's scroll position, sticking to the live edge -// exactly as the room follows its own ([app.roomOffsetFor]) and the conversation -// follows its ([app.offsetFor]): while the page is stuck, or while the offset -// says it is past the bottom, the newest step is what is on screen. It is a -// resolver and not [clampTop] alone because a PINNED page must FOLLOW — a clamp -// holds the number it was given and lets the newest line fall off the bottom. -func (a *app) taskPlanTopFor(count, room int) int { - bottom := count - room - if bottom < 0 { - bottom = 0 - } - if a.taskSheet.planStick || a.taskSheet.detailTop > bottom { - return bottom - } - return clampTop(a.taskSheet.detailTop, count, room) -} - -// taskPlanScroll moves the page's own offset, which is the only thing that -// moves: there is no cursor to walk in a page that is read rather than listed. -// -// IT RE-DECIDES WHETHER THE PAGE IS FOLLOWING, the room's own bargain -// ([app.roomScroll]): a step up off the bottom releases the pin, and a step back -// onto the bottom takes it again, so a person who returns to the live edge -// resumes following without pressing anything. -func (a *app) taskPlanScroll(delta int) { - width, height := a.size() - body, room, _ := a.taskPlanWindow(width, height) - bottom := len(body) - room - if bottom < 0 { - bottom = 0 - } - at := a.taskPlanTopFor(len(body), room) + delta - switch { - case at >= bottom: - a.taskSheet.detailTop, a.taskSheet.planStick = bottom, true - case at <= 0: - a.taskSheet.detailTop, a.taskSheet.planStick = 0, false - default: - a.taskSheet.detailTop, a.taskSheet.planStick = at, false - } - a.touch() -} - -// taskPlanFrame is the whole screen while the page is up: a head, the body, and -// the foot. It is drawn in the card's slot and in the card's own shape — one -// frame, one rule, one foot — so the two pages of this place read as one. The -// one thing the card has not got and this page has is the note composer: the box -// a person types into, in the foot, under the rule. -func (a *app) taskPlanFrame(width, height int) ([]string, int, int) { - pal := a.pal - if height < 1 { - height = 1 - } - lines := make([]string, 0, height) - add := func(text string) { lines = append(lines, text) } - - // THE HEAD IS THE TASK ROOM'S HEAD: the trail with the way back at its end, - // and under it the facts rule that leads with the state's own mark. A run's - // task and a task of the old road are one kind of thing, and a person who - // opened one of each used to meet two different pages. - add(a.taskPlanTrail(width)) - add(a.taskPlanFacts(width)) - add("") - // THE FOOT IS THE LAST FOUR ROWS, and a frame too short for the body under - // it gives the body up rather than the way out — the page's own trim, and the - // one [app.taskPlanWindow] resolves so the draw and the scroll agree ([app.taskPlanScroll]). - body, room, foot := a.taskPlanWindow(width, height) - // THE PAGE RESOLVES ITS OFFSET, it does not hold it: a stuck page reads the - // bottom where the body now is, so a step appended between frames is on - // screen at the next draw ([app.taskPlanTopFor]). - top := a.taskPlanTopFor(len(body), room) - drawn := room - if len(body) < drawn { - drawn = len(body) - } - for i := 0; i < drawn; i++ { - add(" " + fit(body[top+i], width-1)) - } - for len(lines) < height-foot { - add("") - } - caretX, caretY := 0, 0 - if foot > 0 { - // A refusal the store answered rides on the closing rule, which is the - // pane's one line for a place that is not home ([app.pageMsg]); this page - // draws its own frame and so draws it here. - legend := []string{} - if a.pageMsg != "" { - legend = append(legend, " "+pal.dim(a.pageMsg)) - } - add(placeNoteRule(legend, width, pal)) - if a.taskSheet.planNote.empty() { - add(" " + pal.dim(fit(prompt+taskPlanNoteWord, width-1))) - } else { - add(" " + fit(prompt+a.taskSheet.planNote.String(), width-1)) - } - caretX, caretY = ansi.StringWidth(prompt)+1, len(lines)-1 - // AND WHEN THE WORKER READS IT, under the box that writes it: the worker - // is a separate loop, so a note waits in the store until it asks for its - // next step — the one thing a person needs to know about the box they are - // typing into (taskPlanPickupWord). - // - // A TASK THAT HAS ENDED TAKES NO NEXT STEP, so the sentence is absent - // there rather than false. Its row stays, empty, because the foot's - // height is fixed and the caret is placed against it. - if planEnded(a.taskSheet.plan.Row) { - add("") - } else { - add(" " + pal.dim(fit(taskPlanPickupWord, width-1))) - } - add(" " + paintHint(hintFit(a.taskPlanKeys(), width-2), pal, pal.dim)) - } - if len(lines) > height { - lines = lines[:height] - if caretY >= height { - caretX, caretY = 0, 0 - } - } - return lines, caretX, caretY -} - -// taskPlanKeys is the page's key line: the reading keys the card also spends, -// the send, and the two verbs a plan task has, over the way back. It is fitted -// by [hintFit], so `esc back` is kept last and the clause a narrow frame drops -// first is the scroll. -func (a *app) taskPlanKeys() string { - parts := []string{"↑↓ scroll", "enter send"} - parts = append(parts, a.tasksPlanKeyWords(a.taskSheet.plan.Row)...) - parts = append(parts, taskCardBackWord) - return strings.Join(parts, railSep) -} - -// taskPlanBody is what a person reads: the work order, the notes, and the steps. -// -// EVERY SECTION WITH NOTHING BEHIND IT IS ABSENT, along with the air that would -// have separated it — the emptiness law, applied to a page. A task that has left -// no note and run no step draws its description and stops. -func (a *app) taskPlanBody(width int) []string { - if width < 1 { - width = 1 - } - page, pal := a.taskSheet.plan, a.pal - var out []string - add := func(text string) { out = append(out, text) } - addWrapped := func(text string, ink func(string) string) { - for _, para := range strings.Split(text, "\n") { - if strings.TrimSpace(para) == "" { - continue - } - for _, line := range wrap(strings.TrimSpace(para), width) { - add(ink(line)) - } - } - } - section := func(word string) { - if len(out) > 0 { - add("") - } - add(pal.dim(word)) - } - - // THE WAY BACK AND THE FIGURES ARE THE HEAD'S NOW. The task a step into a - // part came from is on the trail ([app.taskPlanTrail]), and the state, the - // clock, the steps and the money are on the facts rule ([app.taskPlanFacts]), - // where the task room has always said them. - if waits := page.WaitRows; len(waits) > 0 { - section("waits") - own := map[string]bool{} - for _, id := range page.Row.Waits { - own[id] = true - } - for _, row := range waits { - var sentence string - if own[row.ID] { - sentence = strings.TrimSpace(page.Row.Title) + railSep + "waits: " + strings.TrimSpace(row.Title) - } else { - sentence = strings.TrimSpace(row.Title) + railSep + "waits: " + strings.TrimSpace(page.Row.Title) - } - add(pal.ink(padTo(sentence, 51)) + pal.dim(planWaitFigure(pal, row))) - } - } - if desc := strings.TrimSpace(page.Description); desc != "" { - section("brief") - // THE BRIEF IS DRAWN THROUGH THE READER THE TRANSCRIPT ALREADY USES - // ([requestDisplayFor]). A plan task's description can be the generated - // work order a run hands its workers, and a page that drew it raw opened - // on the machinery addressed to the model — the shouted scaffold heading, - // the rule under it, and only then the person's ask. The reader reshapes - // that document into plain headings with this task's own work first, the - // same service the conversation's own transcript gives the same text; - // a brief that is not the generated document passes through unchanged, so - // a person's own typed brief draws exactly as it always did. THE STORED - // TEXT IS NEVER TOUCHED: only what is drawn changes. - lines := planBriefRows(desc, width) - if !a.taskSheet.planBriefFull && len(lines) > briefFoldLines { - for _, line := range lines[:briefFoldLines] { - add(pal.ink(line)) - } - add(pal.dim(bandFoldWord(len(lines)-briefFoldLines, briefFoldWhat, true) + railSep + briefFoldKey)) - } else { - for _, line := range lines { - add(pal.ink(line)) - } - } - } - if len(page.Checks) > 0 { - section("checks") - for _, check := range page.Checks { - if check = strings.TrimSpace(check); check != "" { - addWrapped(check, pal.ink) - } - } - } - if len(page.Notes) > 0 { - section("notes") - for _, note := range page.Notes { - // AN AUTHOR IS DRAWN ONLY AS A WORD A PERSON WOULD RECOGNISE. `you` is - // one. Every other author the store holds is an id of its own, the - // run's number or a worker's handle, and this page has no word for the - // kind of task that left the note; a page headed `1 · now` or - // `2ytmh2 · now` names nobody. The moment is kept and the id is never - // drawn, which is the owner's ruling on this surface: no internal name - // on a person's screen ([planNoteWho]). - who := planNoteWho(note) - when := sinceAt(note.At, a.now()) - switch { - case who != "" && when != "": - add(pal.dim(who + railSep + when)) - case who != "": - add(pal.dim(who)) - case when != "": - add(pal.dim(when)) - } - addWrapped(note.Body, pal.ink) - } - } - if len(page.Steps) > 0 || !page.Live.Empty() { - section("steps") - // THE STEPS ARE DRAWN AS THE TASK ROOM DRAWS A TASK'S WORK: one row per - // command, led by the still mark of its family — the shell's, through the - // vocabulary's one door ([app.actionLead]) — with what came back dim under - // it in the same gutter. The mark never moves and never says how a step - // went; the facts rule above says whether the task is running. - lead := a.actionLead(session.ActionRun, true) - under := a.actionLead(session.ActionRun, false) - for _, step := range page.Steps { - // A CALL THE ENGINE SAYS DID NOT RUN IS ONE OF TWO THINGS, and the - // engine says which. A correction about the FORM of the worker's reply - // was addressed to the worker and nothing was attempted: it stays in - // the record and in the head's count, and has no row. AN ACTION THE - // WORKER ATTEMPTED AND A DOOR REFUSED is something a person steering - // the run wants to see, so it draws as one dim line in the step's - // place: the word the permissions page already uses for a refused call - // and what was tried, with no shell mark, because nothing ran. Both - // facts are fields set where the event is known; this surface never - // reads the answer's sentence, which was written for the worker, and a - // record without the fields draws as before. - if step.NotRun { - if tried := planDisplayCommand(step.Command, step.Parts); step.Refused && tried != "" { - add(pal.dim(under + taskPlanRefusedWord + railSep + tried)) - } - continue - } - // A STEP WITH NOTHING OF THE WORK IN IT HAS NO ROW. - command := planDisplayCommand(step.Command, step.Parts) - if command == "" { - continue - } - add(pal.muted(lead) + pal.ink(command)) - // THE HEAD IS THE ROW'S OWN OR IT IS NOT DRAWN. The engine says when - // the row left out a part that could have written it - // ([session.PlanStep.ObservationHeadWithheld]); this surface reads - // that fact and never the words that came back. - if !step.ObservationHeadWithheld { - if head := planObservationHead(step.Observation); head != "" { - add(pal.dim(under + head)) - } - } - } - // THE LIVE STEP IS DRAWN ONE STEP EARLY: the command whose end line has - // not reached the trajectory yet, as the newest row, with the call's own - // clock — the same ten-second clock the rail counts ([taskToolFloor]) — - // dim under it. The moment its command ends the store clears the live row - // and the next re-read draws it as an ordinary step (internal/plandb's - // live.go states the law, and a live step's zero value draws nothing). - if live := page.Live; !live.Empty() { - if command := planDisplayCommand(live.Command, page.Row.LiveParts); command != "" { - add(pal.muted(lead) + pal.ink(command)) - if !live.Since.IsZero() { - if age := a.now().Sub(live.Since); age >= taskToolFloor { - add(pal.dim(under + "running " + countUpWord(age))) - } - } - } - } - } - // A TASK WITH CHILDREN SHOWS THEM UNDER ITS STEPS, AND EACH ONE IS DRAWN AS - // THE RAIL DRAWS A TASK: through the node renderer, with the running spinner, - // its `#id`, the old tree's connectors and — while it runs — its call and - // its clock and money line under it ([app.planRailLines]). One kind of row for - // one kind of thing, on the column and on the page alike. The note composer - // and its receipt below are untouched by the tree. - if kids := page.Children; len(kids) > 0 { - section("under it") - for _, line := range a.planRailLines(planTwigsOf(kids), nil, false, min(width, planPageKinWidth)) { - add(line.text) - } - } - return out -} - -// planPageKinWidth is the most a task's page spends on one row of its parts. A -// part's row is the rail's row, whose handle stands at the row's far end; on a -// page the width of the terminal that handle would sit a screen away from the -// title it belongs to, so the rows are drawn at a width a column could have. -const planPageKinWidth = 64 - -func planBriefLines(text string, width int) []string { - var lines []string - for _, para := range strings.Split(text, "\n") { - if para = strings.TrimSpace(para); para != "" { - lines = append(lines, wrap(para, width)...) - } - } - return lines -} - -// planBriefRows is the brief section's own read of a stored description: the -// work order drawn through the reader the conversation's transcript already -// uses ([requestDisplayFor]) before the page wraps it. Every surface that -// holds this text gives a person the same reading of it — plain headings, the -// task's own work first, no machinery addressed to a worker — and the door -// counts the lines this function draws, so the fold and the count it names can -// never disagree. A description that is not the generated document is wrapped -// as it was always wrapped. -func planBriefRows(desc string, width int) []string { - return planBriefLines(requestDisplayFor(strings.TrimSpace(desc)), width) -} - -// taskPlanFollow re-reads the page while it stands on a task that is still -// running, so the newest step walks in at the live edge as the worker takes it. -// -// IT IS THE PAINT CLOCK'S OWN READ, bounded by two facts: the page must be -// opposite a running task (a settled page is a still page, and the clock that -// carries this stops with it), and the page must be up. It re-reads the whole -// page — the same store read [app.taskSheetPlan] made once on the way in — -// because that is what the room does with its rows on the same clock -// ([app.room.dirty]), and a page that followed only its steps would miss a note -// or a state change that arrived beside them. Whether the newest line is ON -// SCREEN is the resolver's question and not this one's ([app.taskPlanTopFor]): -// a stuck page reads the bottom, a person who scrolled up stays where they -// were. -// -// AND IT IS TAKEN ON A BEAT, NOT ON EVERY TICK. The paint clock offers this read -// many times a second, and holding back only while one was out meant a fast -// engine was asked again the moment it answered: 509 reads over the wire in -// ninety seconds on a real screen, for one open page. A worker lands a step -// every few seconds at best, so the page learns of it the way the rail learns -// of the run, once every [elsewhereEvery] and only while the task can still -// move ([app.taskPlanFollows], place_tasks.go's [tasksPlace.planDue]). The beat -// is counted from the last time the page was read for any reason, so a page -// just opened, or just re-read for a note, is not read again at once. -// -// IT STAYS IN THE ORDERED LINE although nobody pressed for it ([app.besideLine] -// says who may leave). Its FOLD replaces the page, and a note a person sends -// re-reads the page too: outside the line, a follow asked before the note and -// answered after it would put back a page without the note on it. -func (a *app) taskPlanFollow() tea.Cmd { - if !a.taskPlanFollows() || a.taskSheet.planFollowing { - return nil - } - if a.now().Sub(a.taskSheet.planPageAt) < elsewhereEvery { - return nil - } - agent, ok := a.planReader() - if !ok { - return nil - } - id := a.taskSheet.plan.Row.ID - a.taskSheet.planFollowing = true - a.taskSheet.planPageAt = a.now() - return a.offLoop(func() func(bool) tea.Cmd { - page, found := agent.PlanTaskPage(id) - return func(here bool) tea.Cmd { - a.taskSheet.planFollowing = false - // THE ANSWER IS FOR THE PAGE THAT ASKED. A person who opened another - // task, or closed the page, while this read was out is not handed the - // page they left. - if here && found && a.taskSheet.planOn && a.taskSheet.plan.Row.ID == id { - a.taskSheet.plan = page - } - return nil - } - }) -} - -// taskPlanRunning reports whether the page is open on a task that is still -// running — the one condition under which the paint clock has to keep turning -// for the page's own sake, because the page follows a live edge -// ([app.taskPlanFollow]). It reads the row's own state WORD, so a task that has -// ended, or one a person has held, takes the page off the clock: a held task is -// dispatching nothing and a settled one never will again. -// taskPlanFollows reports whether the open page is on a task that can still -// move: queued or running, by the ONE state word its row already wears. A page -// on a task that has ended, or one a person is holding, is a still page and is -// never read again; the read that opened it was the last. -func (a *app) taskPlanFollows() bool { - if !a.taskSheet.detailOn || !a.taskSheet.planOn { - return false - } - switch planStateWord(a.taskSheet.plan.Row) { - case "queued", "running": - return true - } - return false -} - -func (a *app) taskPlanRunning() bool { - if !a.taskSheet.detailOn || !a.taskSheet.planOn { - return false - } - return planStateWord(a.taskSheet.plan.Row) == "running" -} - -// planObservationHead is the head of one step's observation: the first line that -// says anything, which is as much of what came back as a step line can carry. -// The whole of it is on disk behind the row's trajectory ([PlanTaskRow.TrajectoryPath]). -func planObservationHead(observation string) string { - for _, line := range strings.Split(observation, "\n") { - if line = strings.TrimSpace(line); line != "" { - return line - } - } - return "" -} - -// planWaitFigure is the related row's state cell and useful figure on a waits -// sentence. Active work carries its recorded step count; a row without one -// carries its state word, so the relationship never drops the row's state. -func planWaitFigure(pal palette, row session.PlanTaskRow) string { - figure := planStepWords(row.Steps) - if figure == "" { - figure = planStateWord(row) - } - return strings.TrimSpace(tierGlyph(pal, planStatus(row)) + " " + figure) -} - // planDisplayCommand is the one display rule for a task step on the page, rail, // and tree. The record remains untouched. The session marks each quote-aware // part that belongs only to the run record or changes into the run copy; this @@ -1764,42 +977,23 @@ func planFirstLine(command string) string { return first } -// planWithoutOwnFolder drops a leading change into the task's own folder, which -// the page's head names once, and leaves every other directory change as typed. -func planWithoutOwnFolder(command, folder string) string { - if command == "" || folder == "" { - return command - } - quotedSingle := "'" + strings.ReplaceAll(folder, "'", "'\\''") + "'" - quotedDouble := `"` + strings.ReplaceAll(strings.ReplaceAll(folder, `\`, `\\`), `"`, `\"`) + `"` - for _, path := range []string{folder, quotedSingle, quotedDouble} { - prefix := "cd " + path + " && " - if strings.HasPrefix(command, prefix) { - if rest := strings.TrimSpace(strings.TrimPrefix(command, prefix)); rest != "" { - return rest - } - } - } - return command -} - -// railPlanPending is the gap between a press on a run's row in the side list -// and that task's page being drawn. +// railPlanPending is the gap between a press on a run's task and that task's +// room being drawn. // -// THE KEYS TYPED IN THE GAP ARE THE PAGE'S. The page is read off the update -// loop ([app.taskSheetPlanAsk]), and on a hosted conversation the answer took -// 2.4 seconds on a real screen. Until it folds back the conversation is still -// what is drawn, and its box used to take whatever was typed: a note meant for -// a task was sent to the model as a message. A person types at what they +// THE KEYS TYPED IN THE GAP ARE THE ROOM'S. The page is read off the update +// loop ([app.openRailPlan]), and on a hosted conversation the answer took 2.4 +// seconds on a real screen. Until it folds back the conversation is still what +// is drawn, and its box used to take whatever was typed: a note meant for a +// task was sent to the model as a message. A person types at what they // pressed, so from the press on, every key is held here, in order, and typed -// into the page's note box the moment the page is up ([app.finishRailPlan]) — -// into the box and nowhere else ([app.railPlanReplay]). +// into the room's box the moment the room is up — into the box and nowhere +// else ([app.railPlanReplay]). // // THREE WAYS OUT, and none of them reaches the conversation: the answer opens -// the page and types the keys into its box; the answer says there is no page, the row's -// room opens as it always did and the keys are dropped, because a room's box -// is a different receiver again; `esc` withdraws the press. A second press -// replaces the first and starts with no keys. +// the room and types the keys into its box; the answer says there is no page, +// the row's room opens as it always did and the keys are dropped; `esc` +// withdraws the press. A second press replaces the first and starts with no +// keys. type railPlanPending struct { id string keys []tea.KeyPressMsg @@ -1807,22 +1001,8 @@ type railPlanPending struct { func (a *app) beginRailPlan(id string) { a.railPlanPending = railPlanPending{id: id} } -func (a *app) finishRailPlan(id string) tea.Cmd { - if a.railPlanPending.id != id { - return nil - } - keys := a.railPlanPending.keys - a.railPlanPending = railPlanPending{} - a.railTaskPlanOn = true - for _, key := range keys { - a.railPlanReplay(key) - } - a.touch() - return nil -} - -// railPlanReplay puts one key typed in the gap into the page's NOTE BOX, and -// does nothing else with it. +// railPlanReplay puts one key typed in the gap into the room's BOX, and does +// nothing else with it. // // THE GAP'S KEYS ARE THE NOTE AND NEVER THE PAGE'S VERBS. They were replayed // through the page's whole keyboard, so a sentence that began with the stop key @@ -1833,7 +1013,7 @@ func (a *app) finishRailPlan(id string) tea.Cmd { // other key is dropped, `enter` included: a note typed blind waits in the box, // unsent, until the person has read the page it is about to go to. func (a *app) railPlanReplay(key tea.KeyPressMsg) { - note := &a.taskSheet.planNote + note := &a.input switch key.String() { case "backspace": note.deleteBackward() diff --git a/internal/tui3/taskplan_test.go b/internal/tui3/taskplan_test.go index ac9127fb1..57f021440 100644 --- a/internal/tui3/taskplan_test.go +++ b/internal/tui3/taskplan_test.go @@ -13,10 +13,8 @@ import ( "testing" "time" - tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" - "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -215,9 +213,7 @@ func firstRowsReadHome(t *testing.T, a *app) { // openWorkTabNow opens the run's tab and answers the page read it asks for. func openWorkTabNow(t *testing.T, a *app) { t.Helper() - if cmd := a.openWorkTab(); cmd != nil { - drive(t, a, cmd()) - } + spend(t, a, a.openWorkTab()) } // planLine is the drawn line a row's title is on, and whether there is one. @@ -275,91 +271,6 @@ func TestThePaneDrawsThisChatsPlanRowsWithTheirStateWords(t *testing.T) { } } -// ENTER OPENS THE PAGE THE STORE KEEPS: the description, the notes with their -// moment and `you` on the person's own, and the trajectory's steps, each command -// on its own line. -func TestEnterOnAPlanRowDrawsItsPage(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{ - "t-alpha": { - Row: rows[0], - Description: "the work order", - Notes: []session.PlanTaskNote{ - {Author: "worker-1", Body: "a handoff", At: taskFixtureNow}, - {Author: "7", Body: "landed on work: 2 files", At: taskFixtureNow}, - {Author: "person-handle", Person: true, Body: "mind the vault", At: taskFixtureNow}, - }, - Steps: []session.PlanStep{ - {Step: 1, Command: "$ echo one", Observation: "one"}, - {Step: 2, Command: "$ echo two", Observation: "two"}, - {Step: 3, Command: "$ echo three", Observation: "three"}, - }, - }, - } - a, _ := planAppWith(t, rows, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - if item, ok := a.taskSheetCurrent(); !ok || item.plan == nil { - t.Fatalf("the cursor is not on a plan row: %+v", item.entry) - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over a plan row did not open its page") - } - page := taskSheetText(a) - for _, want := range []string{"the work order", "a handoff", "landed on work: 2 files", "mind the vault", "you", "$ echo one", "$ echo two", "$ echo three"} { - if !strings.Contains(page, want) { - t.Fatalf("the plan page is missing %q:\n%s", want, page) - } - } - // AN AUTHOR IS A WORD A PERSON WOULD RECOGNISE OR IT IS NOT DRAWN. The person - // is `you`; a worker's handle, the run's own number and the person's store - // handle are ids, and no id of the store's is anywhere on the page. - for _, never := range []string{"worker-1", "person-handle", "t-alpha", "7 " + strings.TrimSpace(railSep)} { - if strings.Contains(page, never) { - t.Fatalf("the plan page draws the store's own id %q:\n%s", never, page) - } - } - // THE HEAD OPENS ON THE TASK AS GIVEN TO THE PERSON: title, a folded brief, - // and declared checks. Worker-only addressing, the run copy, and ids never leak. - a.taskSheet.plan = session.PlanTaskPage{ - Row: rows[0], - Description: "first line of the brief\nsecond line\nthird line\nfourth line with t-store-secret and node 47", - Checks: []string{"go test ./internal/tui3"}, - Folder: "/tmp/the-run-copy", - Steps: []session.PlanStep{{Step: 1, Command: "cd /tmp/the-run-copy && printf worker-bytes", Parts: []session.PlanCommandPart{ - displayPart("cd /tmp/the-run-copy", " && ", false, true), displayPart("printf worker-bytes", "", false, false), - }}}, - } - page = taskSheetText(a) - for _, want := range []string{"Alpha", "first line of the brief", "third line", "more lines", "checks", "go test ./internal/tui3", "printf worker-bytes"} { - if !strings.Contains(page, want) { - t.Fatalf("the plan page head is missing %q:\n%s", want, page) - } - } - for _, forbidden := range []string{"t-alpha", "t-store-secret", "node 47", "is your task in the plan"} { - if strings.Contains(page, forbidden) { - t.Fatalf("the person-facing page leaked %q:\n%s", forbidden, page) - } - } - if strings.Contains(page, "/tmp/the-run-copy") { - t.Fatalf("page drew the run copy path:\n%s", page) - } - if strings.Contains(page, "cd /tmp/the-run-copy") { - t.Fatalf("the page repeated its own folder in a command row:\n%s", page) - } - if got := a.taskSheet.plan.Steps[0].Command; got != "cd /tmp/the-run-copy && printf worker-bytes" { - t.Fatalf("drawing changed the recorded command to %q", got) - } - - // AND esc BACKS OUT ONE LAYER to the list, the card's own bargain. - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) - if a.taskSheet.planOn || !a.at(pageTasks) { - t.Fatal("esc did not back out of the plan page to the list") - } -} - // THE CANCEL KEY A NODE ROW HAS ENDS A PLAN TASK, through the store's own // cancel verb rather than the engine's (plandb_steer.go). It is the roster's // `x`, taken over an empty box exactly as a node row takes it, so the same key @@ -414,41 +325,6 @@ func TestPOnAPlanRowPausesThenResumes(t *testing.T) { } } -// TYPING ON THE PAGE IS A NOTE AND NOT A CHAT TURN: the words go to the store's -// note verb and never to the model, the composer says what typing there does, -// and a note is sent with enter. -func TestSendingOnThePlanPageWritesANoteAndStartsNoTurn(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{ - "t-alpha": {Row: rows[0], Description: "the work order"}, - } - a, fake := planAppWith(t, rows, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over a plan row did not open its page") - } - if !strings.Contains(taskSheetText(a), taskPlanNoteWord) { - t.Fatalf("the page's composer does not say what typing there does:\n%s", taskSheetText(a)) - } - for _, r := range "a note" { - drive(t, a, key(string(r))) - } - if got := a.taskSheet.planNote.String(); got != "a note" { - t.Fatalf("the composer holds %q, want %q", got, "a note") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - want := planCall{id: "t-alpha", text: "a note"} - if len(fake.noted) != 1 || fake.noted[0] != want { - t.Fatalf("enter wrote %v, want one note %+v", fake.noted, want) - } - if len(fake.sent) != 0 { - t.Fatalf("sending a note started a chat turn: %v", fake.sent) - } -} - // A REFUSAL FROM A VERB IS THE PANE'S ONE LINE, the sentence the store // answered — never a card, which a place cannot draw over itself. It is read on // the list, where the router's line rides beside the hint. @@ -471,28 +347,6 @@ func TestAPlanVerbRefusalIsSpokenOnThePanesLine(t *testing.T) { } } -// AND THE DEFAULT PAGE DRAWS IT TOO, on its closing rule, because the page draws -// its own frame and the router's line has no place on it. -func TestAPlanVerbRefusalIsSpokenOnThePage(t *testing.T) { - // AN ORDINARY TASK HANGS UNDER ITS RUN, as every row the engine answers for - // one does; a row under nothing is the run's own task, whose stop is the - // card's and which nothing holds (stoprun_page_test.go). - rows := []session.PlanTaskRow{{ID: "t-alpha", Parent: "t-run", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{ - "t-alpha": {Row: rows[0], Description: "the work order"}, - } - a, fake := planAppWith(t, rows, pages) - fake.refuse = errors.New("a task that has finished cannot be paused") - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - drive(t, a, key("p")) - if !strings.Contains(taskSheetText(a), "cannot be paused") { - t.Fatalf("the page does not draw the store's refusal:\n%s", taskSheetText(a)) - } -} - // A CONVERSATION WITH NO PLAN DRAWS THE PAGE IT HAS ALWAYS DRAWN. // // The plan is an authority that is ABSENT rather than empty for a chat that @@ -694,178 +548,6 @@ func appendPlanStep(fake *planFake, id string, n int) { fake.pages[id] = page } -// openPlanPage opens the tasks place over the fixture and enters the plan page -// under the cursor, the two keys a person presses. -func openPlanPage(t *testing.T, a *app) { - t.Helper() - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over a plan row did not open its page") - } -} - -// A PAGE OPEN ON A RUNNING TASK FOLLOWS ITS LIVE EDGE: a step the worker takes -// while somebody reads is at the bottom of the page at the next frame, exactly -// as the room follows its own live edge. It is that same reading-on-the-beat, -// spent on the store rather than a journal ([app.taskPlanFollow]). -func TestThePlanPageFollowsAStepAppendedWhileItIsOpen(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{"t-alpha": planPageWithSteps(rows[0], 30)} - a, fake := planAppWith(t, rows, pages) - openPlanPage(t, a) - - if !strings.Contains(taskSheetText(a), planStepCommand(30)) { - t.Fatalf("a page stuck to the live edge did not draw its newest step:\n%s", taskSheetText(a)) - } - // THE STORE MOVES UNDER IT, the way it does while a worker runs. - appendPlanStep(fake, "t-alpha", 31) - planBeat(t, a) - if !strings.Contains(taskSheetText(a), planStepCommand(31)) { - t.Fatalf("the page did not follow the step appended while it was open:\n%s", taskSheetText(a)) - } -} - -// planBeat is the paint clock turning once, one beat after the page was last -// read: the follow is taken on the rail's own beat and not on every tick -// ([app.taskPlanFollow]), so a fixture that wants the page to have caught up -// moves its clock a beat on and then offers the frame. -func planBeat(t *testing.T, a *app) { - t.Helper() - at := a.now().Add(elsewhereEvery) - a.clock = func() time.Time { return at } - drive(t, a, frameMsg{}) -} - -// AND A SCROLL UP RELEASES THE PIN, so the newest step no longer walks in from -// under the reader — until they reach the bottom again, which takes the pin -// back. It is the room's own bargain ([app.roomScroll], [app.taskPlanScroll]). -func TestScrollUpOnThePlanPageStopsTheFollow(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{"t-alpha": planPageWithSteps(rows[0], 30)} - a, fake := planAppWith(t, rows, pages) - openPlanPage(t, a) - - for i := 0; i < 3; i++ { - drive(t, a, key("up")) - } - if a.taskSheet.planStick { - t.Fatal("scrolling up left the page pinned to the live edge") - } - appendPlanStep(fake, "t-alpha", 31) - planBeat(t, a) - if strings.Contains(taskSheetText(a), planStepCommand(31)) { - t.Fatalf("the page followed a step after somebody scrolled up off the edge:\n%s", taskSheetText(a)) - } - // AND REACHING THE BOTTOM AGAIN RESUMES IT. - for i := 0; i < 16; i++ { - drive(t, a, key("down")) - } - if !a.taskSheet.planStick { - t.Fatal("scrolling back to the bottom did not take the pin again") - } - appendPlanStep(fake, "t-alpha", 32) - planBeat(t, a) - if !strings.Contains(taskSheetText(a), planStepCommand(32)) { - t.Fatalf("the page did not resume following at the bottom:\n%s", taskSheetText(a)) - } -} - -// A NOTE LEFT ON THE PAGE IS THE PAGE'S OWN RECEIPT: the words go to the store, -// the page is read again, and the note is drawn under `notes` with its author — -// `you` for the person — and its moment. It starts no chat turn (it is not a -// message to the model), which the note test next door already holds. -func TestANoteLeftOnThePlanPageAppearsWithYouAndItsMoment(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{"t-alpha": {Row: rows[0], Description: "the work order"}} - a, fake := planAppWith(t, rows, pages) - openPlanPage(t, a) - - for _, r := range "a longer sleep" { - drive(t, a, key(string(r))) - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if len(fake.noted) != 1 || fake.noted[0] != (planCall{id: "t-alpha", text: "a longer sleep"}) { - t.Fatalf("enter wrote %v, want one note on t-alpha", fake.noted) - } - page := taskSheetText(a) - for _, want := range []string{"notes", "you", "now", "a longer sleep"} { - if !strings.Contains(page, want) { - t.Fatalf("the note's receipt is missing %q:\n%s", want, page) - } - } -} - -// THE PAGE ALSO SAYS WHEN A NOTE IS READ — the worker is a separate loop, so a -// note waits in the store until the worker asks for its next step. That sentence -// is on the page, not only in the manual, because the page is where the box is. -func TestThePlanPageSaysWhenANoteIsRead(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed"}} - pages := map[string]session.PlanTaskPage{"t-alpha": {Row: rows[0], Description: "the work order"}} - a, _ := planAppWith(t, rows, pages) - openPlanPage(t, a) - if !strings.Contains(taskSheetText(a), taskPlanPickupWord) { - t.Fatalf("the page does not say when a note is read:\n%s", taskSheetText(a)) - } -} - -// A SENTENCE THAT HAS STOPPED BEING TRUE IS ABSENT. A task that has ended takes -// no further step, so its page never says a worker reads a note at its next -// one; a task that can still move keeps the sentence. -func TestAnEndedTasksPageNeverPromisesANextStep(t *testing.T) { - for _, tc := range []struct { - status string - said bool - }{ - {"claimed", true}, - {"paused", true}, - {"done", false}, - {"failed", false}, - {"cancelled", false}, - } { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: tc.status}} - pages := map[string]session.PlanTaskPage{"t-alpha": {Row: rows[0], Description: "the work order"}} - a, _ := planAppWith(t, rows, pages) - openPlanPage(t, a) - if got := strings.Contains(taskSheetText(a), taskPlanPickupWord); got != tc.said { - t.Fatalf("a %s task's page says %q: %v, want %v:\n%s", tc.status, taskPlanPickupWord, got, tc.said, taskSheetText(a)) - } - } -} - -// THE LIVE STEP IS DRAWN ONE STEP EARLY and leaves the page when the task ends: -// the running glyph in place of the number, the command in ink, and the call's -// own clock dim under it — and the next read after the store cleared the live -// row draws none of it (taskPlanBody, internal/plandb's live.go). -func TestTheLiveStepLeavesThePlanPageWhenTheTaskEnds(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Title: "Alpha", Status: "claimed", Steps: 3}} - page := planPageWithSteps(rows[0], 3) - page.Live = plandb.LiveStep{Step: 4, Command: "go test ./...", Since: taskFixtureNow.Add(-41 * time.Second)} - pages := map[string]session.PlanTaskPage{"t-alpha": page} - a, fake := planAppWith(t, rows, pages) - openPlanPage(t, a) - - text := taskSheetText(a) - if !strings.Contains(text, "$ go test ./...") { - t.Fatalf("the live step's command is not on the page:\n%s", text) - } - if !strings.Contains(text, "running 41s") { - t.Fatalf("the live step's clock is not under it:\n%s", text) - } - // THE TASK ENDS: the store clears the live row and the root lands. - ended := fake.pages["t-alpha"] - ended.Live = plandb.LiveStep{} - ended.Row.Status = "done" - fake.pages["t-alpha"] = ended - planBeat(t, a) - text = taskSheetText(a) - if strings.Contains(text, "go test ./...") { - t.Fatalf("the live step outlived the task that was running it:\n%s", text) - } -} - // planDrawnKin is the connector cell the place draws each plan row with, in draw // order and keyed by the row's own id — the layout the place actually paints, so // a test reads the same string the person does. @@ -991,78 +673,6 @@ func TestThePlanTreeDrawsTheLiveStepAtTheNodesIndentation(t *testing.T) { } } -// STEERING IS UNCHANGED BY THE TREE: a note left on a CHILD from its page still -// lands through PlanNote, on the child's own id. -func TestANoteOnAPlanChildStillLandsThroughPlanNote(t *testing.T) { - alpha := session.PlanTaskRow{ID: "t-alpha", Title: "Alpha", Parent: "t-root", Status: "claimed"} - rows := []session.PlanTaskRow{{ID: "t-root", Title: "Root", Status: "claimed"}, alpha} - pages := map[string]session.PlanTaskPage{"t-alpha": {Row: alpha, Description: "the work order"}} - a, fake := planAppWith(t, rows, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - var want session.TaskIndexEntry - for _, item := range a.tasksFiltered().items { - if item.plan != nil && item.entry.ID == "t-alpha" { - want = item.entry - } - } - if want.ID == "" { - t.Fatal("the child row was not on the page to point at") - } - a.taskSheetPointAt(want) - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over the child row did not open its page") - } - for _, r := range "a longer sleep" { - drive(t, a, key(string(r))) - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - wantNote := planCall{id: "t-alpha", text: "a longer sleep"} - if len(fake.noted) != 1 || fake.noted[0] != wantNote { - t.Fatalf("enter wrote %v, want one note %+v", fake.noted, wantNote) - } -} - -// THE TASK'S PAGE SHOWS ITS CHILDREN UNDER ITS STEPS, each with its live line, -// the way the rail draws a family — and leaves the note composer and its receipt -// exactly where they were. -func TestThePlanPageShowsChildrenUnderItsSteps(t *testing.T) { - root := session.PlanTaskRow{ID: "t-root", Title: "Root", Status: "running", Steps: 2} - child := session.PlanTaskRow{ID: "t-alpha", Title: "Alpha", Parent: "t-root", Status: "running", Steps: 3} - child.Live.Step = 3 - child.Live.Command = "go test ./internal/api" - rows := []session.PlanTaskRow{root, child} - pages := map[string]session.PlanTaskPage{ - "t-root": { - Row: root, - Steps: []session.PlanStep{{Step: 1, Command: "git status"}}, - Children: []session.PlanTaskRow{child}, - }, - } - a, _ := planAppWith(t, rows, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over the root row did not open its page") - } - text := taskSheetText(a) - if !strings.Contains(text, "Alpha") { - t.Fatalf("the page does not draw the task's child:\n%s", text) - } - // THE CHILD IS THE RAIL'S ROW, and the rail names a call in flight the way a - // node row names one. - if !strings.Contains(text, "bash go test ./internal/api") { - t.Fatalf("the page does not draw the child's live line:\n%s", text) - } - if !strings.Contains(text, taskPlanNoteWord) { - t.Fatalf("the tree changed the page's note composer:\n%s", text) - } -} - // THE RUN'S PLAN IS READ AGAIN ON ITS OWN BEAT, WHATEVER ELSE THE WINDOW KNOWS. // A run's workers move the store and publish nothing, so a part the run added is // on the rail only once the plan has been read again. That read rode on the @@ -1104,91 +714,3 @@ func TestPlanRailAndTreeOmitTheNamedFolderFromLiveCommands(t *testing.T) { t.Fatalf("rail/tree command display did not omit only the named folder:\n%s", text) } } - -// A TASK THAT HAS ENDED IS OFFERED NEITHER VERB. The store refuses to stop or -// hold work that is done or incomplete, so naming both keys under a finished -// task was two offers that could only be refused. -func TestAFinishedTasksPageOffersNoVerbItWouldRefuse(t *testing.T) { - for _, tc := range []struct { - status string - want []string - never []string - }{ - {"running", []string{tasksPlanCancelWord, tasksPlanPauseWord}, []string{tasksPlanResumeWord}}, - {"paused", []string{tasksPlanCancelWord, tasksPlanResumeWord}, []string{tasksPlanPauseWord}}, - {"done", nil, []string{tasksPlanCancelWord, tasksPlanPauseWord, tasksPlanResumeWord}}, - {"failed", nil, []string{tasksPlanCancelWord, tasksPlanPauseWord, tasksPlanResumeWord}}, - {"cancelled", nil, []string{tasksPlanCancelWord, tasksPlanPauseWord, tasksPlanResumeWord}}, - } { - row := session.PlanTaskRow{ID: "t-1", Parent: "t-run", Title: "Alpha", Status: tc.status} - a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"t-1": {Row: row}}) - a.taskSheet.plan = session.PlanTaskPage{Row: row} - keys := a.taskPlanKeys() - for _, word := range tc.want { - if !strings.Contains(keys, word) { - t.Fatalf("a %s task's page does not offer %q: %s", tc.status, word, keys) - } - } - for _, word := range tc.never { - if strings.Contains(keys, word) { - t.Fatalf("a %s task's page offers %q, which the store would refuse: %s", tc.status, word, keys) - } - } - if !strings.Contains(keys, taskCardBackWord) { - t.Fatalf("a %s task's page lost its way back: %s", tc.status, keys) - } - } -} - -// THE BRIEF SECTION DRAWS THE WORK ORDER THROUGH THE READER THE TRANSCRIPT -// ALREADY USES. A store task's description can be the generated work order a -// run hands its workers — the same document the conversation's own transcript -// reshapes through [requestDisplayText] — and a page that drew it raw opened on -// the machinery addressed to the model: the shouted scaffold heading, the rule -// under it, and only then the person's ask. A person must never read machinery, -// so the page draws the brief through the reader's own reshaping: plain -// headings, this task's own work first. THE STORE'S TEXT IS THE STORE'S: only -// the drawing changes, and a brief that is not the generated document draws -// exactly as it always did. -func TestThePlanPageDrawsItsBriefThroughTheRequestReader(t *testing.T) { - rows := []session.PlanTaskRow{{ID: "t-alpha", Parent: "t-run", Title: "Alpha", Status: "claimed"}} - // The generated opening and the shouted headings under it, with the - // person's own sentence carried in the work order's own work section. - brief := strings.Join([]string{ - taskRequestAsk + "\nThis is the message the whole job came out of, and this task is ONE PIECE of it: preserve the scope.\n\nthe page a person opens for one task of a run draws that task's brief plainly", - "THE WORK\n\nthe page a person opens for one task of a run draws that task's brief through the reader the transcript already uses", - "DONE WHEN\n\nthe drawn lines carry the person's own sentence and no scaffold heading", - }, "\n\n") - pages := map[string]session.PlanTaskPage{ - "t-alpha": {Row: rows[0], Description: brief}, - } - a, _ := planAppWith(t, rows, pages) - if !openTaskPlaceWithRows(a) { - t.Fatal("the place refused to open over a plan") - } - if item, ok := a.taskSheetCurrent(); !ok || item.plan == nil { - t.Fatalf("the cursor is not on a plan row: %+v", item.entry) - } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !a.taskSheet.planOn { - t.Fatal("enter over a plan row did not open its page") - } - page := taskSheetText(a) - // THE DRAWN LINES CARRY THE PERSON'S OWN SENTENCE, under the reader's plain - // heading for the work, and none of the machinery the work order opens with. - for _, want := range []string{"brief", "Task request", "the page a person opens for one task of a run draws that task's brief through the reader"} { - if !strings.Contains(page, want) { - t.Fatalf("the plan page is missing %q:\n%s", want, page) - } - } - for _, never := range []string{taskRequestAsk, "ONE PIECE", "THE WORK", "DONE WHEN"} { - if strings.Contains(page, never) { - t.Fatalf("the plan page drew the work order's machinery %q:\n%s", never, page) - } - } - // THE STORED BRIEF DOES NOT CHANGE: the page drew through the reader and the - // sheet still holds the work order byte for byte. - if a.taskSheet.plan.Description != brief { - t.Fatalf("drawing changed the stored brief:\n%s", a.taskSheet.plan.Description) - } -} diff --git a/internal/tui3/view.go b/internal/tui3/view.go index 6159e56a7..7cc5ac441 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -288,17 +288,9 @@ func (a *app) frameBody() (string, int, int) { // nothing to type into (home at rest). Set here so every path below starts // from the same answer and only the ones that hide it say so. a.caret = true - if a.railTaskPlanOn { - lines, caretX, caretY := a.taskPlanFrame(width, height) - return strings.Join(lines, "\n"), caretX, caretY - } if a.wall.on { return strings.Join(a.wallFrame(width, height), "\n"), 0, 0 } - if a.workTabOn { - lines := a.workTabFrame(width, height) - return strings.Join(lines, "\n"), 2, max(len(lines)-1, 0) - } // AND THERE IS NO TAB BAR UNTIL A FRAME DRAWS ONE. Every place goes through // [placeFrame], which records the row it put the bar on; the frames that do // not — home's phone inbox and sheet, the task record card — draw something diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 00f05e4e4..4761d8c81 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -4,8 +4,6 @@ import ( "strings" tea "charm.land/bubbletea/v2" - - "github.com/Agent-Field/codeaf/internal/session" ) func (a *app) workTab() (chatTab, bool) { @@ -31,7 +29,7 @@ func (a *app) workTab() (chatTab, bool) { if word == "" { return chatTab{}, false } - return chatTab{key: a.frontTabKey() + "#work", file: a.file, word: word, full: word, here: a.workTabOn, held: true, work: true}, true + return chatTab{key: a.frontTabKey() + "#work", file: a.file, word: word, full: word, here: a.workTabHere(rows[0].ID), held: true, work: true}, true } func (a *app) workTabStable() bool { @@ -46,86 +44,27 @@ func (a *app) workTabStable() bool { now := sig.String() stable := now != "" && now == a.workTabSettled a.workTabSettled = now - if stable { - a.workTabOn = false - } return stable } -// openWorkTab opens the run's tab on the rows the surface holds and asks for -// the run's page OFF THE LOOP. The tab is only offered while rows are held +// openWorkTab opens the run's own task in the task room, the one page every +// task has (planroom.go). The tab is only offered while rows are held // ([app.workTab]), so there is nothing to read before it can open; the page // arrives through the one door every stored page arrives through -// ([app.taskSheetPlanAsk]), and until it does the pane draws the run's own row. +// ([app.openRailPlan]). func (a *app) openWorkTab() tea.Cmd { rows, ok := a.heldPlanRows() if !ok || len(rows) == 0 { return nil } - // THE READING IS TAKEN AT THE OPENING, ONCE, the way the tasks place takes - // it ([app.showTaskPlace]): the disk is walked here and the frames that - // follow draw what the place holds, refreshed on the paint clock through - // [tasksPlace.regroup]. A frame that took its own reading would read the - // disk on every paint (framedisk_law_test.go). - a.refreshElsewhere() - a.taskSheet = a.takeTaskReading() - a.workTabOn, a.taskSheet.planOn, a.taskSheet.detailOn = true, true, true - a.taskSheet.plan = session.PlanTaskPage{Row: rows[0]} - a.taskSheet.planNote.reset() a.chatTabBar = tabBar{} a.touch() - return a.taskSheetPlanAsk(rows[0].ID, nil, nil, nil) -} - -func (a *app) workTabKey(msg tea.KeyPressMsg) tea.Cmd { - if cmd, taken := a.hopKey(msg); taken { - return cmd - } - if msg.String() == "esc" { - a.workTabOn = false - a.closeTaskPlan() - a.chatTabBar = tabBar{} - return nil - } - return a.taskPlanKey(msg) + return a.openRailPlan(rows[0].ID, nil) } -// workTabFrame reuses the tasks place reading and painter; there is no second -// work-row renderer. THE FRAME NEVER TAKES THE READING: [app.takeTaskReading] -// walks the disk and stamps the look, which is an opening's work, so the frame -// draws the reading the place already holds through [app.tasksFiltered], the -// one door the rail and the tasks place read through (framedisk_law_test.go). -func (a *app) workTabFrame(width, height int) []string { - a.workTabStable() - out := a.headRows(width, a.tabsRow(width), a.pal) - reading := a.tasksFiltered() - reading.unfolded = true - rows := reading.rows(width, a.pal) - room := height - len(out) - 3 - if room < 0 { - room = 0 - } - if len(rows) > room { - rows = rows[:room] - } - out = append(out, rows...) - for len(out) < height-3 { - out = append(out, "") - } - for _, note := range a.taskSheet.plan.Notes { - // AN AUTHOR IS DRAWN ONLY AS A WORD A PERSON WOULD RECOGNISE, the page's - // own rule ([planNoteWho]): every other author the store holds is an id, - // and this row used to draw it (`2ytmh2 · …`). A note with no word for its - // author is its body alone, with no separator left hanging before it. - line := a.pal.ink(note.Body) - if who := planNoteWho(note); who != "" { - line = a.pal.dim(who+railSep) + line - } - out = append(out, line) - } - text := a.taskSheet.planNote.String() - if strings.TrimSpace(text) == "" { - text = taskPlanNoteWord - } - return append(out, prompt+a.pal.dim(text)) +// workTabHere reports whether the run's tab is the page on screen: the room is +// open on the run's own task. +func (a *app) workTabHere(root string) bool { + plan := a.roomPlan() + return plan != nil && plan.id == strings.TrimSpace(root) } diff --git a/internal/tui3/worktab_test.go b/internal/tui3/worktab_test.go index c1828f6ca..5a2a5a44b 100644 --- a/internal/tui3/worktab_test.go +++ b/internal/tui3/worktab_test.go @@ -42,15 +42,23 @@ func TestWorkTabAppearsAfterConversationOnlyForALiveRun(t *testing.T) { } } -func TestWorkTabDrawsTheTasksPlacesOwnRows(t *testing.T) { +// THE RUN'S TAB OPENS THE RUN'S TASK ROOM, the one page every task opens, with +// the run's parts under it. +func TestWorkTabOpensTheRunsTaskRoom(t *testing.T) { a, _ := workTabFixture(t) openWorkTabNow(t, a) - text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")) - for _, want := range []string{"Root", "Fix the flake", "$ go test ./internal/tui3", "queued · waits: Root"} { + if plan := a.roomPlan(); plan == nil || plan.id != "t-root" { + t.Fatalf("the run's tab did not open the run's room: room %v", a.roomOpen()) + } + text := planRoomText(t, a) + for _, want := range []string{"Root", "Fix the flake"} { if !strings.Contains(text, want) { - t.Fatalf("work tab missing %q:\n%s", want, text) + t.Fatalf("the run's room is missing %q:\n%s", want, text) } } + if tabs := a.tabList(); len(tabs) != 2 || !tabs[1].here { + t.Fatalf("the run's tab is not the one here while its room is open: %+v", tabs) + } } func TestWorkTabNoteUsesPlanNoteAndShowsThePageReceipt(t *testing.T) { @@ -63,27 +71,30 @@ func TestWorkTabNoteUsesPlanNoteAndShowsThePageReceipt(t *testing.T) { if len(fake.noted) != 1 || fake.noted[0] != (planCall{id: "t-root", text: "keep the middleware order"}) { t.Fatalf("work tab note calls = %+v", fake.noted) } - if text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")); !strings.Contains(text, "you") || !strings.Contains(text, "keep the middleware order") { - t.Fatalf("work tab lacks note receipt:\n%s", text) + if text := planRoomText(t, a); !strings.Contains(text, "keep the middleware order") { + t.Fatalf("the run's room lacks the note:\n%s", text) } } -// THE WORK TAB NAMES NO AUTHOR BY A STORE ID. A note another author left was -// drawn under the store's own id for it (`2ytmh2 · …`), which the page itself -// has never done: an author is `you` or nothing (#1240). -func TestWorkTabDrawsNoAuthorAsAStoreID(t *testing.T) { - a, _ := workTabFixture(t) - openWorkTabNow(t, a) - a.taskSheet.plan.Notes = []session.PlanTaskNote{ +// THE ROOM NAMES NO AUTHOR BY A STORE ID. A note another author left was drawn +// under the store's own id for it (`2ytmh2 · …`): an author is the person or +// nothing (#1240). +func TestTheRunsRoomDrawsNoAuthorAsAStoreID(t *testing.T) { + a, fake := workTabFixture(t) + page := fake.pages["t-root"] + page.Notes = []session.PlanTaskNote{ {Author: "2ytmh2", Body: "the worker's own note"}, {Person: true, Body: "keep the middleware order"}, } - text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")) + fake.pages["t-root"] = page + a.height = 40 + openWorkTabNow(t, a) + text := planRoomText(t, a) if strings.Contains(text, "2ytmh2") { - t.Fatalf("the work tab drew a store id as a note's author:\n%s", text) + t.Fatalf("the room drew a store id as a note's author:\n%s", text) } - if !strings.Contains(text, "the worker's own note") || !strings.Contains(text, "you"+railSep+"keep the middleware order") { - t.Fatalf("the work tab lost a note or its person's word:\n%s", text) + if !strings.Contains(text, "the worker's own note") || !strings.Contains(text, "keep the middleware order") { + t.Fatalf("the room lost a note:\n%s", text) } } @@ -91,8 +102,8 @@ func TestWorkTabEscReturnsToConversationAndLandingCardRemains(t *testing.T) { a, fake := workTabFixture(t) openWorkTabNow(t, a) drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) - if a.workTabOn { - t.Fatal("esc left the work tab open") + if a.roomOpen() { + t.Fatal("esc left the run's room open") } drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Root", session.TaskDone, session.TaskNotice{Report: "the importer landed"})}) fake.plan[0].Status, fake.plan[1].Status = "done", "done"