From 4d503a1c4bc45db6e86d6eb1d9d469cebbc40eab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:45:24 -0400 Subject: [PATCH 01/10] tui3, pool: tests that pin the task-page defects before the fixes --- cmd/codeaf/poolsweep_test.go | 67 +++++++++ internal/tui3/tabsignal_test.go | 66 +++++++++ internal/tui3/taskpage_keys_test.go | 211 ++++++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 internal/tui3/taskpage_keys_test.go diff --git a/cmd/codeaf/poolsweep_test.go b/cmd/codeaf/poolsweep_test.go index 20d4a2d895..dc71e7481e 100644 --- a/cmd/codeaf/poolsweep_test.go +++ b/cmd/codeaf/poolsweep_test.go @@ -7,7 +7,10 @@ package main // pending file. import ( + "context" + "encoding/json" "os" + "strings" "testing" "time" @@ -125,3 +128,67 @@ func TestPoolJudgeSweepToleratesATornPendingLine(t *testing.T) { t.Fatal("the torn row was judged; it should have been skipped") } } + +// pendingIDs reads the landing ids a pending file or a claim holds, the way the +// sweep reads them: one row a line, a torn line counted as nothing. +func pendingIDs(t *testing.T, path string) map[uint64]bool { + t.Helper() + ids := map[uint64]bool{} + data, err := os.ReadFile(path) + if err != nil { + return ids + } + for _, line := range strings.Split(string(data), "\n") { + var row pendingLanding + if json.Unmarshal([]byte(strings.TrimSpace(line)), &row) == nil { + ids[row.Landing.ID] = true + } + } + return ids +} + +// TestPoolJudgeSweepCutShortKeepsALeftoverClaimsRows: a sweep that stops +// before it has reached every row of a leftover claim leaves that claim for the +// next start. The fresh pending file must not then be renamed over it, which +// replaced the leftover's unjudged rows with the new ones and lost them for good. +func TestPoolJudgeSweepCutShortKeepsALeftoverClaimsRows(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_MODEL_POOL", "on") + t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", "http://127.0.0.1:1/submit") + restoreOwnCells(t) + + profileDir := t.TempDir() + poolDir := config.ProfilePath(profileDir, "pool") + settings := config.Config{APIKey: "k"} + + // A LEFTOVER CLAIM, from a sweep a process death cut short. + leftover := poolTestLanding() + leftover.ID = 101 + if err := writePendingLanding(profileDir, "do", leftover); err != nil { + t.Fatalf("write leftover row: %v", err) + } + claim := pendingPath(poolDir) + ".sweeping" + if err := os.Rename(pendingPath(poolDir), claim); err != nil { + t.Fatalf("leave the claim behind: %v", err) + } + // AND A ROW A DOOR WROTE SINCE. + fresh := poolTestLanding() + fresh.ID = 202 + if err := writePendingLanding(profileDir, "do", fresh); err != nil { + t.Fatalf("write fresh row: %v", err) + } + + // THE CLOSE HAS ALREADY CANCELLED THE SWEEP. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var asked []string + sweepPendingContext(ctx, settings, profileDir, poolDir, poolTestCatalog, poolTestAsk(settings, &asked), time.Now, time.Now().Add(time.Minute)) + + inClaim, inPending := pendingIDs(t, claim), pendingIDs(t, pendingPath(poolDir)) + if !inClaim[101] { + t.Fatalf("the leftover claim lost its unjudged row 101: the claim holds %v, pending holds %v", inClaim, inPending) + } + if !inClaim[202] && !inPending[202] { + t.Fatalf("the fresh row 202 is in neither file: the claim holds %v, pending holds %v", inClaim, inPending) + } +} diff --git a/internal/tui3/tabsignal_test.go b/internal/tui3/tabsignal_test.go index e3479aa0fe..c5410d900c 100644 --- a/internal/tui3/tabsignal_test.go +++ b/internal/tui3/tabsignal_test.go @@ -469,3 +469,69 @@ func TestTheWatcherFoldsTaskNoticesOffTheLaneItAlreadyDrains(t *testing.T) { lane <- signalSettled(21, session.TaskDone) await(t, false, "the work behind the turn ended") } + +// ── ONE PREDICATE ON BOTH SIDES OF THE STRIP (#1316) ───────────────────────── + +// askingAgent answers the engine's one question about a person from a flag a +// test can move while a watcher is reading it, and counts every time it is +// asked, which is how a frame that asks the agent is caught. +type askingAgent struct { + *fakeAgent + waits atomic.Bool + asked atomic.Int64 +} + +func (g *askingAgent) NeedsPerson() bool { + g.asked.Add(1) + return g.waits.Load() +} + +// THE SAME CONVERSATION WEARS THE SAME MARK IN FRONT AND BEHIND. The tab beside +// the one in front read [session.Agent.NeedsPerson], which counts a landed +// `your call`, the model's own blocking question, the sub-harness intake card +// and a running sub-harness's question; the tab in front read the surface's own +// short list, which counted none of them. So a `?` stood on the tab beside, and +// went away the moment a person brought that conversation forward to answer it. +// This drives ONE agent in one state through both readings, and the answer is +// the engine's on both sides, with the frame still asking the agent nothing. +func TestOneConversationWearsTheSameMarkInFrontAndBehind(t *testing.T) { + agent := &askingAgent{fakeAgent: &fakeAgent{model: "m"}} + agent.waits.Store(true) + + out := make(chan behindStirMsg, stirDepth) + watch := startBehindWatch("/tmp/lab/two.jsonl", agent, out) + deadline := time.Now().Add(2 * time.Second) + for !watch.waits.Load() && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + watch.stop() + behind := watch.signal() + if behind != tabNeedsPerson { + t.Fatalf("the held tab reads %v for a conversation the engine says is waiting on a person", behind) + } + + a := newTestApp(agent) + a.file, a.workspace = "/tmp/lab/two.jsonl", "/tmp/lab" + emptyMachine(a) + drive(t, a, frameMsg{}) + if front := a.tabSignalFor(a.file, true); front != behind { + t.Fatalf("one conversation reads %v in front and %v behind", front, behind) + } + + // THE FRAME READS WHAT THE LOOP WROTE DOWN. The strip is laid out on every + // frame, and the agent's predicate takes its lock. + before := agent.asked.Load() + for i := 0; i < 200; i++ { + a.tabSignalFor(a.file, true) + } + if n := agent.asked.Load() - before; n != 0 { + t.Fatalf("two hundred front marks asked the agent %d times", n) + } + + // AND AN ANSWER TAKES THE MARK OFF THE FRONT TAB AT THE NEXT MESSAGE. + agent.waits.Store(false) + drive(t, a, frameMsg{}) + if front := a.tabSignalFor(a.file, true); front == tabNeedsPerson { + t.Fatal("the front tab kept its question after the engine stopped waiting") + } +} diff --git a/internal/tui3/taskpage_keys_test.go b/internal/tui3/taskpage_keys_test.go new file mode 100644 index 0000000000..da78a11d9e --- /dev/null +++ b/internal/tui3/taskpage_keys_test.go @@ -0,0 +1,211 @@ +package tui3 + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── WHAT A PLAN PAGE'S KEYS DO 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. + +// 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 + 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 +// 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) { + 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) + } + 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") + } + 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) + } + 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) + } +} + +// 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) { + 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) + } + 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", + 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 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) + } +} + +// AND AN ENDED ROW IN THE LIST TAKES NEITHER KEY, which is the list's half of +// the same law: its foot names neither ([app.tasksPlanKeyWords]). +func TestAnEndedPlanRowInTheListTakesNeitherKey(t *testing.T) { + rows := []session.PlanTaskRow{{ID: "t-alpha", Parent: "t-run", Title: "Alpha", Status: "done"}} + a, fake := planAppWith(t, rows, nil) + 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, key("x")) + drive(t, a, key("p")) + if len(fake.cancelled)+len(fake.paused)+len(fake.resumed) != 0 { + t.Fatalf("keys on an ended row reached the store: cancel %v pause %v resume %v", + fake.cancelled, fake.paused, fake.resumed) + } +} + +// 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) { + a, counted := railTaskPageApp(t, true) + counted.setStatus("3", "running") + part := counted.planFake.pages["3"] + part.Row.Status = "running" + counted.planFake.pages["3"] = part + + held := &heldRailPlan{railPlanCounter: counted, started: make(chan struct{}), release: make(chan struct{})} + a.agent = held + cmd := a.openRailPlan("3", nil) + answer := make(chan tea.Msg, 1) + go func() { answer <- cmd() }() + <-held.started + const typed = "x-axis labels are wrong" + for _, r := range typed { + drive(t, a, key(string(r))) + } + drive(t, a, key("enter")) + 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 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()) + } + if len(counted.noted) != 0 { + t.Fatalf("a note typed blind was sent before its page 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) + } +} + +// ── 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) { + t.Helper() + rows := []session.PlanTaskRow{ + {ID: "t-alpha", Parent: "t-run", Title: "Alpha", Status: "claimed"}, + {ID: "t-beta", Parent: "t-run", Title: "Beta", Status: "claimed"}, + } + pages := map[string]session.PlanTaskPage{ + "t-alpha": {Row: rows[0], Description: "alpha's work order"}, + "t-beta": {Row: rows[1], Description: "beta's work order"}, + } + a, fake := planAppWith(t, rows, pages) + if !openTaskPlaceWithRows(a) { + 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) + } + for _, r := range "a note" { + drive(t, a, key(string(r))) + } + 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. +func TestEnterTwiceSendsANoteOnce(t *testing.T) { + a, fake, _ := noteTwoPages(t) + _, first := a.Update(key("enter")) + _, second := a.Update(key("enter")) + drain(t, a, tea.Batch(first, second)) + if len(fake.noted) != 1 { + t.Fatalf("two presses of enter wrote %d notes, want one: %v", len(fake.noted), fake.noted) + } +} + +// 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) + _, sent := a.Update(key("enter")) + // THE PERSON MOVES ON BEFORE THE STORE ANSWERS. + a.taskSheet.plan = fake.pages["t-beta"] + a.taskSheet.planNote.insert("for beta") + 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 got := a.taskSheet.planNote.String(); got != "for beta" { + t.Fatalf("the note's reply emptied another page'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) + } +} From 4b24622e7007167c602e94a4e72f9e76a4e38091 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:48:22 -0400 Subject: [PATCH 02/10] tui3: name the front-tab test agent apart from the keeper sweep's --- internal/tui3/tabsignal_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/tui3/tabsignal_test.go b/internal/tui3/tabsignal_test.go index c5410d900c..0cd024053a 100644 --- a/internal/tui3/tabsignal_test.go +++ b/internal/tui3/tabsignal_test.go @@ -472,16 +472,16 @@ func TestTheWatcherFoldsTaskNoticesOffTheLaneItAlreadyDrains(t *testing.T) { // ── ONE PREDICATE ON BOTH SIDES OF THE STRIP (#1316) ───────────────────────── -// askingAgent answers the engine's one question about a person from a flag a +// frontAskAgent answers the engine's one question about a person from a flag a // test can move while a watcher is reading it, and counts every time it is // asked, which is how a frame that asks the agent is caught. -type askingAgent struct { +type frontAskAgent struct { *fakeAgent waits atomic.Bool asked atomic.Int64 } -func (g *askingAgent) NeedsPerson() bool { +func (g *frontAskAgent) NeedsPerson() bool { g.asked.Add(1) return g.waits.Load() } @@ -495,7 +495,7 @@ func (g *askingAgent) NeedsPerson() bool { // This drives ONE agent in one state through both readings, and the answer is // the engine's on both sides, with the frame still asking the agent nothing. func TestOneConversationWearsTheSameMarkInFrontAndBehind(t *testing.T) { - agent := &askingAgent{fakeAgent: &fakeAgent{model: "m"}} + agent := &frontAskAgent{fakeAgent: &fakeAgent{model: "m"}} agent.waits.Store(true) out := make(chan behindStirMsg, stirDepth) From b4a856ca938c061293a41f79733b3be1547cc40e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:50:32 -0400 Subject: [PATCH 03/10] tui3: pin the work tab's authors, ctrl+o's width and the gap's keys; hold only a page's first read --- internal/tui3/c295_rail_task_page_test.go | 12 ++++++++++-- internal/tui3/stoprun_page_test.go | 22 +++++++++++++--------- internal/tui3/taskpage_keys_test.go | 22 ++++++++++++++++++++++ internal/tui3/worktab_test.go | 19 +++++++++++++++++++ 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/internal/tui3/c295_rail_task_page_test.go b/internal/tui3/c295_rail_task_page_test.go index 76a546e3b3..996a5d6067 100644 --- a/internal/tui3/c295_rail_task_page_test.go +++ b/internal/tui3/c295_rail_task_page_test.go @@ -2,6 +2,7 @@ package tui3 import ( "strings" + "sync" "testing" tea "charm.land/bubbletea/v2" @@ -229,15 +230,22 @@ func TestANoteTypedOnARailTaskPageNeverRaisesTheStopCard(t *testing.T) { // heldRailPlan holds the page's read open until the test lets it go, which is // the gap a person types into on a hosted conversation. +// +// ONLY THE FIRST READ IS HELD. A later read — the page following its task, or +// the receipt a note's send reads back — answers at once, rather than closing +// the start signal a second time. type heldRailPlan struct { *railPlanCounter started chan struct{} release chan struct{} + once sync.Once } func (h *heldRailPlan) PlanTaskPage(id string) (session.PlanTaskPage, bool) { - close(h.started) - <-h.release + h.once.Do(func() { + close(h.started) + <-h.release + }) return h.railPlanCounter.PlanTaskPage(id) } diff --git a/internal/tui3/stoprun_page_test.go b/internal/tui3/stoprun_page_test.go index 51fa7978ef..db640a1504 100644 --- a/internal/tui3/stoprun_page_test.go +++ b/internal/tui3/stoprun_page_test.go @@ -171,10 +171,12 @@ func TestAReadThatGivesUpEndsTheHold(t *testing.T) { } } -// STOP TYPED WHILE THE RUN'S OWN PAGE OPENS REACHES A STOP THAT WORKS. The key -// is kept for the page and replayed onto it, and on the run's own page that key -// raises the card. -func TestStopTypedWhileTheRunsOwnPageOpensReachesTheCard(t *testing.T) { +// STOP TYPED WHILE THE RUN'S OWN PAGE OPENS IS A LETTER IN ITS NOTE. The keys +// typed in the gap are the page's note and never its verbs (#1244): a person +// typing at a page they cannot see yet is writing to its box, and a stop raised +// by a key typed blind would be a question about a page nobody has read. The +// stop is one key away once the page is drawn. +func TestStopTypedWhileTheRunsOwnPageOpensIsALetterInItsNote(t *testing.T) { a, counted := railTaskPageApp(t, true) held := &heldRailPlan{railPlanCounter: counted, started: make(chan struct{}), release: make(chan struct{})} a.agent = held @@ -188,11 +190,13 @@ func TestStopTypedWhileTheRunsOwnPageOpensReachesTheCard(t *testing.T) { } close(held.release) drive(t, a, <-answer) - if !a.stopping() { - t.Fatal("stop typed while the run's own page opened raised no card once it had") + 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) } - answerStopCard(t, a) - if len(counted.cancelled) != 1 || counted.cancelled[0] != "2" { - t.Fatalf("the card's stop reached %v, want the run's own task", counted.cancelled) + if !a.railTaskPlanOn { + t.Fatal("the answer did not open the run's own page") + } + if got := a.taskSheet.planNote.String(); got != stopRaiseKey { + t.Fatalf("the page'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 da78a11d9e..cad24ce2f9 100644 --- a/internal/tui3/taskpage_keys_test.go +++ b/internal/tui3/taskpage_keys_test.go @@ -149,6 +149,28 @@ func TestKeysTypedWhileAPartsPageOpensAreTheNoteAndNothingElse(t *testing.T) { } } +// 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)) + drawn, narrow := planBriefRows(desc, a.taskPlanBodyWidth()), 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") + } +} + // ── THE NOTE'S RECEIPT ────────────────────────────────────────────────────── // noteTwoPages is a list with two ordinary tasks, the first one's page open and diff --git a/internal/tui3/worktab_test.go b/internal/tui3/worktab_test.go index fc97a1a1cc..47515f30a5 100644 --- a/internal/tui3/worktab_test.go +++ b/internal/tui3/worktab_test.go @@ -68,6 +68,25 @@ func TestWorkTabNoteUsesPlanNoteAndShowsThePageReceipt(t *testing.T) { } } +// 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{ + {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")) + if strings.Contains(text, "2ytmh2") { + t.Fatalf("the work tab 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) + } +} + func TestWorkTabEscReturnsToConversationAndLandingCardRemains(t *testing.T) { a, fake := workTabFixture(t) openWorkTabNow(t, a) From af6e969305878dc7ae6af366f3dcd32f6616e461 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:50:38 -0400 Subject: [PATCH 04/10] tui3: measure the page's width in the ctrl+o test from the frame itself --- internal/tui3/taskpage_keys_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/tui3/taskpage_keys_test.go b/internal/tui3/taskpage_keys_test.go index cad24ce2f9..4b48f7d902 100644 --- a/internal/tui3/taskpage_keys_test.go +++ b/internal/tui3/taskpage_keys_test.go @@ -159,7 +159,8 @@ func TestCtrlOCountsTheBriefAtThePagesOwnWidth(t *testing.T) { t.Fatal("the rail row did not open its page") } desc := strings.TrimSpace(strings.Repeat("word ", 110)) - drawn, narrow := planBriefRows(desc, a.taskPlanBodyWidth()), planBriefRows(desc, a.bodyWidth()) + // 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) From d27fd342c8f205a320f6d9a9e823537030e13336 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:56:09 -0400 Subject: [PATCH 05/10] tui3, chat, pool: the task page's keys, its note, its tab mark and three unowned warms The tab in front and the tab beside it now read one predicate: the engine's NeedsPerson, asked on the loop once per message and cached for the frame, the same answer each held tab's watcher caches. A landed your-call, the model's own blocking question and a sub-harness's question no longer lose their mark when their conversation comes forward. x and p act only where the key line names them: on an ended task's page they are letters in the note, and on an ended row in the list they are letters too. Keys typed while a run task's page is opening go into its note box and nowhere else; an enter typed blind is dropped, so nothing is stopped, held or sent before the page is seen. A note's send is one send while it is in flight, and its reply lands only on the page it was sent from. ctrl+o measures the brief at the width the page draws it. The work tab draws a note's author as `you` or nothing, never a store id. A restart sweep that stops before finishing a leftover pending claim no longer renames the fresh pending file over it. The model catalog warms a conversation opens for a direct service, a standing pass opens and the host door opens are now cancelled and joined by their owners. The sweep-home goroutine row is classified as joined. The first-run model chooser uses the shared fuzzy matcher. The unread config keys notice shows with hints off and has a manual section; the manual says which fenced languages are coloured, and no longer promises a folder on a run task's page. --- cmd/codeaf/chatv3.go | 9 +- cmd/codeaf/chatv3_beside.go | 24 ++++ cmd/codeaf/chatv3_guard_class_test.go | 8 +- cmd/codeaf/chatv3_host.go | 4 + cmd/codeaf/chatv3_process.go | 48 ++++++- cmd/codeaf/chatv3_standing.go | 34 +++-- cmd/codeaf/poolrecord.go | 13 ++ cmd/codeaf/tick.go | 5 +- internal/manual/chat/commands.md | 18 +++ internal/manual/chat/reading-a-task-page.md | 13 +- internal/manual/chat/screen.md | 39 +++++- internal/manual/chat/task-controls.md | 13 +- internal/manual/chat/worker-harness.md | 4 +- internal/tui3/app.go | 13 ++ internal/tui3/notice.go | 8 +- internal/tui3/notice_test.go | 15 +++ internal/tui3/onboarding.go | 26 +++- internal/tui3/onboarding_test.go | 24 ++++ internal/tui3/place_tasks.go | 5 + internal/tui3/tabsignal.go | 45 ++++--- internal/tui3/taskplan.go | 135 +++++++++++++++----- internal/tui3/worktab.go | 12 +- 22 files changed, 428 insertions(+), 87 deletions(-) diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 5e8ce97ac3..296bab83a4 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -884,7 +884,14 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { // has a good answer without it, and an unknown window leaves the session on // its conservative default. models := proc.Models - activeModels, activeModel, activeListsModels := v3CatalogForModel(context.Background(), settings, chosen, models) + // A DIRECT SERVICE'S OWN CATALOG IS A WARM OF ITS OWN, and it is owned the + // way the process's is (#1274): it runs under the process lifetime and + // closeAll cancels and joins it, so no cache write can land after the + // process has closed or in a later home. + activeModels, activeModel, activeListsModels := v3CatalogForModel(proc.lifetime(), settings, chosen, models) + if activeModels != models { + proc.ownCatalog(activeModels) + } harnesses := proc.Harnesses // The typed programs this conversation can reach, and the two stores they are diff --git a/cmd/codeaf/chatv3_beside.go b/cmd/codeaf/chatv3_beside.go index a4f3de4749..deb7ea3cbd 100644 --- a/cmd/codeaf/chatv3_beside.go +++ b/cmd/codeaf/chatv3_beside.go @@ -144,6 +144,21 @@ type engineFleet struct { mu sync.Mutex conns []*engineConn + // closers are what this window opened beside its connections and must join + // when it lets them go — the model catalog [hostOptions] warms, whose warm + // writes a cache when it lands (#1274). [engineFleet.closeAll] runs them. + closers []func() +} + +// own hands one close to [engineFleet.closeAll]. A nil fleet or a nil close is +// nothing to own. +func (f *engineFleet) own(shut func()) { + if f == nil || shut == nil { + return + } + f.mu.Lock() + f.closers = append(f.closers, shut) + f.mu.Unlock() } // machineReadings is the handful of per-window facts a beside conversation needs @@ -303,6 +318,15 @@ func (f *engineFleet) closeAll() { } } _ = f.boot.close() + // AND WHAT THE WINDOW OPENED BESIDE THEM, joined last: nothing it warmed may + // write after the door has let go. + f.mu.Lock() + closers := f.closers + f.closers = nil + f.mu.Unlock() + for _, shut := range closers { + shut() + } } // takeAll hands over every connection this window opened and empties the list, diff --git a/cmd/codeaf/chatv3_guard_class_test.go b/cmd/codeaf/chatv3_guard_class_test.go index 65628a448a..628bef613b 100644 --- a/cmd/codeaf/chatv3_guard_class_test.go +++ b/cmd/codeaf/chatv3_guard_class_test.go @@ -15,9 +15,9 @@ import ( // v3 process or launch. A long-lived writer must identify both its stop and the // closeAll join. chatv3/models and engine/models name the process waiter, joined // since #1179, and the internal/catalog warm each one starts, which closeAll now -// cancels and joins through Models.Close. -// The remaining known-open row (chatv3/sweep-home) names the later cell that owns it; -// deleting a row is part of landing that cell. +// cancels and joins through Models.Close. chatv3/sweep-home is joined too, by +// stopPlaceSweep since #1276. No row is known-open now; a new one that is must +// name the later cell that owns it, and deleting it is part of landing that cell. func TestV3ProcessGuardGoClass(t *testing.T) { type class struct { kind, stop, join, owner string @@ -28,7 +28,7 @@ func TestV3ProcessGuardGoClass(t *testing.T) { "pool/judge-sweep": {kind: "joined writer", stop: "pool errand context", join: "v3Process.closeAll calls stopPoolErrands"}, "chatv3/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"}, "engine/models": {kind: "joined writer", stop: "warmModels waiter on the pool errand context, and the catalog's own warm context", join: "v3Process.closeAll calls stopPoolErrands for the waiter (since #1179) and Models.Close, which cancels and joins the internal/catalog warm"}, - "chatv3/sweep-home": {kind: "KNOWN-OPEN writer", owner: "later home-sweep ownership cell (class target three)"}, + "chatv3/sweep-home": {kind: "joined writer", stop: "v3Process.sweepCancel cancels the walk's context, checked between entries and before every destructive operation", join: "v3Process.closeAll calls stopPlaceSweep, which waits on sweepDone (since #1276)"}, "chatv3/background": {kind: "one-shot", stop: "repairBackgroundChecks returns after one bounded Drift/Install pass"}, "chatv3/once-questions": {kind: "one-shot", stop: "agent.Close closes the WatchQuestions channel consumed by the range"}, "chatv3/close-agent": {kind: "joined one-shot", stop: "Agent.Close is bounded", join: "v3Process.closeAll waits on waiting"}, diff --git a/cmd/codeaf/chatv3_host.go b/cmd/codeaf/chatv3_host.go index 5a32f63ccb..c565ba8380 100644 --- a/cmd/codeaf/chatv3_host.go +++ b/cmd/codeaf/chatv3_host.go @@ -614,6 +614,10 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op } discovery := catalog.Options{BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: profileDir} models := catalog.LoadLazy(context.Background(), discovery) + // THE WARM IS THE WINDOW'S, and the window's fleet joins it when the door + // lets go (#1274): its fetch writes a cache when it lands, and one nobody + // joined could write after this window had closed. + fleet.own(models.Close) // A tier row that says auto is answered from this catalog (config.AutoModels): // the same non-blocking read, never a fetch, and set once at start-up. config.AutoModels = models.ModelsNow diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 310025ed69..834b1491ba 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -113,7 +113,48 @@ type v3Process struct { standingDone chan struct{} sweepCancel context.CancelFunc sweepDone chan struct{} - closed bool + // catalogs are the lazy catalogs this process opened beside Models — a + // direct service's own listing, asked for when a conversation is opened on + // one of its models ([v3Process.ownCatalog]). closeAll cancels and joins each. + catalogs []*catalog.Catalog + closed bool +} + +// lifetime is the context background work owned by this process runs under, +// and it ends when closeAll begins. A process built without one (a test's bare +// literal) hands out the plain background, which is what it had before. +func (p *v3Process) lifetime() context.Context { + if p == nil || p.processCtx == nil { + return context.Background() + } + return p.processCtx +} + +// ownCatalog hands a lazy catalog this process opened to closeAll, which +// cancels and joins its warm the way it does Models' (#1274). A catalog handed +// over after the close has begun is closed at once, so none is left unowned. +func (p *v3Process) ownCatalog(models *catalog.Catalog) { + if p == nil || models == nil { + return + } + p.mu.Lock() + if p.closed { + p.mu.Unlock() + models.Close() + return + } + p.catalogs = append(p.catalogs, models) + p.mu.Unlock() +} + +// takeCatalogs hands over every catalog [v3Process.ownCatalog] was given and +// empties the list, so the joins run after the lock is let go. +func (p *v3Process) takeCatalogs() []*catalog.Catalog { + p.mu.Lock() + defer p.mu.Unlock() + owned := p.catalogs + p.catalogs = nil + return owned } // openV3Process builds the once-only half of a v3 launch. @@ -428,6 +469,11 @@ func (p *v3Process) closeAll() { if p.Models != nil { p.Models.Close() } + // AND EVERY OTHER CATALOG THIS PROCESS OPENED, for the same promise: a direct + // service's own listing warms under the same lifetime and is joined here. + for _, models := range p.takeCatalogs() { + models.Close() + } // Cancellation is checked between entries and before destructive operations. // Joining therefore waits only for the current bounded filesystem operation, diff --git a/cmd/codeaf/chatv3_standing.go b/cmd/codeaf/chatv3_standing.go index 22553699a2..9d0aa1c36b 100644 --- a/cmd/codeaf/chatv3_standing.go +++ b/cmd/codeaf/chatv3_standing.go @@ -121,17 +121,22 @@ func standingWatch(store *standing.Store) standing.Watch { // where a machine that has none says so on that item's own row — `could not // check: no API key: this session has not been given one yet` — and the rest of // the walk goes on. -func v3StandingTicker(store *standing.Store) (*standing.Ticker, error) { +// +// IT ANSWERS A RELEASE BESIDE THE PASS, which the caller runs when the pass is +// over: the posture's model catalog warms in the background and writes a cache +// when it lands, and the release cancels and joins it (#1274). It is never nil +// when the error is. +func v3StandingTicker(store *standing.Store) (*standing.Ticker, func(), error) { if store == nil { - return nil, fmt.Errorf("standing: no store") + return nil, nil, fmt.Errorf("standing: no store") } settings, err := config.LoadKeyless() if err != nil { - return nil, err + return nil, nil, err } - posture, err := v3StandingPosture(settings) + posture, models, err := v3StandingPosture(settings) if err != nil { - return nil, err + return nil, nil, err } idle := session.StandingIdle() return &standing.Ticker{ @@ -149,7 +154,7 @@ func v3StandingTicker(store *standing.Store) (*standing.Ticker, error) { // seam nil and the pass absent. Tidy: session.NewMemoryTidy(posture, v3MemoryPath(settings.ProfileDir), store.Root(), idle), DailyRailUSD: v3StandingDailyRail(settings.ProfileDir), - }, nil + }, models.Close, nil } // v3MemoryPath is the brain's file when the memory row is on, and the empty @@ -172,7 +177,10 @@ func v3MemoryPath(profileDir string) string { // // The workspace here is only where the rows are read from. Every firing runs in // its own item's workspace, which the runner sets before it opens anything. -func v3StandingPosture(settings config.Config) (session.Config, error) { +// +// It answers the lazy catalog it opened as well, which the caller closes when +// the pass is over; closing it does not take the rows it has already read. +func v3StandingPosture(settings config.Config) (session.Config, *catalog.Catalog, error) { root, err := os.UserHomeDir() if err != nil || root == "" { root = os.TempDir() @@ -206,13 +214,18 @@ func v3StandingPosture(settings config.Config) (session.Config, error) { // have. cfg, err = applyV3Governance(cfg, settings.ProfileDir, false, false) if err != nil { - return session.Config{}, err + return session.Config{}, nil, err } // The media pair, resolved the way a conversation resolves it (chatv3.go): // a firing briefed to draw a diagram needs the hand that draws it, and the // resolver is what says which model does. The catalog is LAZY and is never // waited for — a pass whose catalog has not resolved simply has no media // verbs on its belt, which is the same absence a cold conversation has. + // + // THE CATALOG IS THE PASS'S TO CLOSE (#1274). Its warm writes a cache when it + // lands, and a pass is rebuilt every five minutes; a warm nobody joined could + // write after the window had closed. So it is handed back to the one caller, + // [v3StandingTicker], whose release closes it when the pass is over. models := catalog.LoadLazy(context.Background(), catalog.Options{ BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, }) @@ -223,7 +236,7 @@ func v3StandingPosture(settings config.Config) (session.Config, error) { cfg.NearestModels = v3NearestModels(models) // AskConsent stays false and Standing stays nil: nobody is watching a // firing, and nothing that fires may arm anything else. - return cfg, nil + return cfg, models, nil } // startStandingTicks runs a pass every [standing.Interval] for as long as this @@ -327,11 +340,12 @@ func runStandingTick(ctx context.Context, store *standing.Store) { // whole contribution to the ambient side, and a goroutine that unwound out // of it would leave a window that looks like it is keeping watch and is not. defer guard.Recover("standing tick") - pass, err := v3StandingTicker(store) + pass, release, err := v3StandingTicker(store) if err != nil { noteStanding("could not start a pass: " + err.Error()) return } + defer release() // The pass's ceiling is a CHILD of the caller's ctx, so closing the ticker // (which cancels that ctx) ends an in-flight pass at once, and the 120s // TickWindow stays the pass's own upper bound when nobody is quitting. diff --git a/cmd/codeaf/poolrecord.go b/cmd/codeaf/poolrecord.go index b5ed9196eb..a463039294 100644 --- a/cmd/codeaf/poolrecord.go +++ b/cmd/codeaf/poolrecord.go @@ -582,6 +582,19 @@ func sweepPending(settings config.Config, profileDir, poolDir string, models fun func sweepPendingContext(ctx context.Context, settings config.Config, profileDir, poolDir string, models func() []catalog.Model, ask func(model string) judge.Ask, now func() time.Time, deadline time.Time) (judged, left int) { claim := pendingPath(poolDir) + ".sweeping" judged, left = sweepClaimContext(ctx, settings, profileDir, poolDir, claim, models, ask, now, deadline) + // A CLAIM THAT IS STILL THERE WAS NOT FINISHED, and the fresh file must not be + // renamed over it. The sweep removes a claim only when it reached every row; + // one a cancel or the deadline cut short keeps its unjudged rows for the next + // start, and renaming the pending file onto the same name replaced those rows + // with the new ones and lost them for good (#1267). The pending file waits + // where it is, and the next start takes the leftover first, as this one did. + // Its rows are waiting too, so they are counted among the ones left. + if _, err := os.Lstat(claim); err == nil { + if data, err := os.ReadFile(pendingPath(poolDir)); err == nil { + left += countUnjudged(poolDir, strings.Split(string(data), "\n")) + } + return judged, left + } if err := os.Rename(pendingPath(poolDir), claim); err != nil { return judged, left } diff --git a/cmd/codeaf/tick.go b/cmd/codeaf/tick.go index b6489b7aff..dc2ee90c9f 100644 --- a/cmd/codeaf/tick.go +++ b/cmd/codeaf/tick.go @@ -32,10 +32,13 @@ func runTick(args []string) error { if err != nil { return err } - ticker, err := v3StandingTicker(store) + ticker, release, err := v3StandingTicker(store) if err != nil { return err } + // The pass's model catalog is joined before this command returns, so its + // warm cannot write a cache after the process has said it is done. + defer release() ctx, cancel := context.WithTimeout(context.Background(), standing.TickWindow) defer cancel() if _, err := ticker.Tick(ctx); err != nil { diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 7ff0f33345..874279f18f 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1616,6 +1616,24 @@ Refusals inside the panel, exactly as written: - A search that matches nothing says `nothing matches`. The `Connections` tab has its own sentences. +## config.json keys are not read — why codeaf says a setting I wrote is ignored + +codeaf reads the top-level keys of your profile's `config.json` that a settings row or +the model-service setup owns. A key nothing reads — a hand-written `models` object, a +spelling from another tool — does nothing, and the defaults apply in its place. (A key +codeaf itself retired is passed over quietly rather than named.) So the conversation says so once, as a note: + +``` +config.json keys are not read: models, tiers; anything set under them is ignored and defaults apply. +``` + +It names every unread key, sorted. It is said **once per profile for that set of keys**: +the next launch with the same keys says nothing, and a set that changes — a key added +or taken away — is said again. It is not a tip, so turning tips off in the Display +tab's `hints` row does not hide it, and a conversation over `--host` says it about the +profile on the machine running the work. Nothing is rewritten: to act on it, move the +value to the key a settings row names (`settings` lists every one), or delete the key. + ## The nine settings tabs The tabs, in order: diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index 995e18f7ff..0c37b28984 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -18,9 +18,10 @@ finished tasks can recover their pages too. ## What is on this task page — everything a task's page shows, in order A task opened from the rail starts with its full title, then the opening three lines of -its brief with a key to unfold the rest, then its declared checks, then the folder where -it works. A part with nothing in it is absent. The folder is said once in this head and -is not repeated on every step. +its brief with a key to unfold the rest, then its declared checks. A part with nothing in +it is absent. **The folder a run's task works in is not shown**: it is the run's own +working copy, not a place you go, and each step's command is drawn without the change +into it, so the rows read as the work itself. The brief stays in the head, folded to three lines with a key to unfold it. The work it did is folded into chips it counts. The paragraphs it wrote as it went, each standing above the chip that @@ -346,7 +347,7 @@ Three things that look like the same picture and are not: nothing more coming; scroll up to read what it did, and the foot names where the words in your box can still go, because the box is still there and the worker is not. - **A task that has not started** — one still queued behind the running ones — opens - with its title, brief, declared checks, and folder before any steps have been written. The + with its title, brief and declared checks before any steps have been written. The page then says `nothing on this page yet — it fills in as the task works`. The roster's row for it says `queued`; the page fills when it starts. - **A row that was never a task.** A background job — a server, a build, a watch, a video @@ -384,7 +385,7 @@ opened the second it starts has journaled nothing yet — its first message is s written — so there is no transcript to replay for a few seconds. What the page draws in that gap is what it already holds: **the full title, the folded -brief, the declared checks, and the folder**, followed by a note about what the task is doing now when there is one. Then it says why there are no steps yet. +brief, and the declared checks**, followed by a note about what the task is doing now when there is one. Then it says why there are no steps yet. ``` Widen the import pipe so the nightly run stops timing out. @@ -394,7 +395,7 @@ nothing on this page yet — it fills in as the task works Three things worth knowing about that page: -- **The head stays.** The title, brief, declared checks, and folder remain when the first +- **The head stays.** The title, brief and declared checks remain when the first step arrives. Only the note about there being no steps leaves the page. - **Opening it starts nothing.** The page is a reader onto work that is already running; pressing the row again closes the page rather than starting anything, and no task is diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index afb8815bc4..f2867974d6 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -408,7 +408,7 @@ circle — and it carries at most one: | Mark | Means | | --- | --- | -| `?` | That conversation is **waiting on you** — an approval, a sign-in, a proposal with no clock on it, or work out of fuel | +| `?` | That conversation is **waiting on you** — an approval, a sign-in, a proposal with no clock on it, a question the model asked and is waiting on, a program's offer or question, a finished task that is `your call`, or work out of fuel | | `◐` | A queued or running piece of work, a turn, or a background job is **running** in it | | nothing | At rest, or nothing is known about it | @@ -417,6 +417,12 @@ the same width in all three states, so a name never moves sideways when a turn s terminal with no box characters `◐` is drawn `*`; `?` is already plain text, so the three stay apart with color off. +**A conversation wears the same mark in front and behind.** The tab you are on and the tabs +beside it read one answer to "is this waiting on a person", so a `?` does not vanish the +moment you bring that conversation forward to answer it; it goes when the question is +answered. A `your call` that has already been accepted and is still settling is not a +question, and wears no `?` on either side. + **The `alt+k` switcher rows carry the same two marks from the same reading.** A tab and its row cannot disagree, including the row for the conversation you are standing on. A queued or running piece of work, a turn, or a background job wears `◐` in both places. @@ -1480,8 +1486,10 @@ another. What is supported: two visible marks. Where the terminal has no raised plane (16 colours and below), the **backticks come back** rather than ordinary code reading as prose. - **Fenced and indented code blocks** — syntax-highlighted at 256 colours and above, - ordinary text below. Drawn at the full width, because a figure is looked at, not read - along. **A line too long for the frame wraps rather than being cut**, at every width: + ordinary text below, **only for a fence labelled with one of the forty or so languages + codeaf carries**; an unlabelled block and any other language draw as plain text (see + "Which languages a code block is coloured in" below). Drawn at the full width, because + a figure is looked at, not read along. **A line too long for the frame wraps rather than being cut**, at every width: there is no horizontal scroll anywhere on this screen, so a cut line was a line that could not be read, copied or trusted. See "Long lines inside a fence" below. - **Lists** — bullets and ordered. Wrapped items hang under their own first word, never @@ -1497,6 +1505,26 @@ another. What is supported: Autolinks get the same treatment. - **Tables** — see the table sections. +## Which languages a code block is coloured in — and why my code block is plain text + +A code block in a reply is coloured only when its fence names a language codeaf +carries, and only at 256 colours and above. It carries about forty: Go, Python, Rust, +TypeScript, JavaScript, Java, Kotlin, Swift, C, C++, C#, Objective-C, Dart, Zig, Scala, +Haskell, Elixir, Ruby, PHP, Perl, Lua, R, bash and shell, SQL, GraphQL, protobuf, HTML, +CSS, XML, JSON, YAML, TOML, INI, HCL, Terraform, Nix, Dockerfile, Makefile, diff and +markdown, under their usual names and aliases (`py`, `ts`, `yml`, `sh`). + +**Everything else draws as plain text**, still as a code block at the full width: + +- a fence naming a language outside that set — ` ```fortran `, ` ```vue `; +- a fence with no label at all, ` ``` ` on its own; +- an indented block, which has no label to read. + +codeaf does not guess a language from the code, because a wrong guess colours +somebody's code as something it is not. Nothing is lost: the text, its wrapping and +copying it are the same as for a coloured block. To get colour, ask for the block with +its language on the fence. + ## What markdown codeaf does not render Most of GFM is rendered in a reply, but two things are deliberately not. @@ -3936,8 +3964,9 @@ of one of the sentences above is running an older codeaf. ## The ? on the terminal tab — which codeaf tab is waiting on me, and the title after quitting A `?` in front of a conversation's title — `? Token counter · codeaf` — means that -conversation is waiting on you: a permission question, a sign-in, an offer, or a task -proposal waiting for your answer. It is the same fact the `?` on its tab in the tab strip +conversation is waiting on you: a permission question, a sign-in, an offer, a task +proposal waiting for your answer, a question the model asked, or a finished task that is +`your call`. It is the same fact the `?` on its tab in the tab strip says, and it goes the moment you answer. On home the same news is the count, `3 want you · codeaf`, over every conversation at once. diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index 25f0c92e35..df64d4cec5 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -163,7 +163,18 @@ is when it reads what you wrote. A task that has ended, `done` or `incomplete`, 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. +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 before a run task's page opens — where do the keys go + +A run task's page can take a moment to arrive, most of all over `--host`. Everything you type +between pressing its row 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 +withdraws the press and drops what was typed. ## Task setup through the session host diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 1eb009cf4b..9c2c2f4f13 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -284,7 +284,9 @@ this conversation's plan**, so a task another chat spawned is never reachable: `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. +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 +`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: diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 353d173d06..c244c9d5f7 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1309,6 +1309,12 @@ type app struct { // THE KEY IS THE CANONICAL TRANSCRIPT PATH ([convKey]), because that is what // home names a row by and what the flock is taken on. behind map[string]*kept + // frontWaits is the engine's answer to whether the conversation in front is + // stopped on a person ([session.Agent.NeedsPerson]), asked once per message + // on the loop and read by every frame ([app.frontSignal]). It is the front + // tab's copy of the fact [behindWatch.waits] holds for every other tab, so + // the two sides of the strip read ONE predicate (tabsignal.go). + frontWaits bool // homeGen is home's own clock generation. It belongs to the SURFACE rather // than to any conversation, because there is one home — and it is bumped by // every close, so a tick armed by a home that has since been closed cannot @@ -3232,6 +3238,13 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.ruler.noteModeReport(mode) } model, cmd := a.update(msg) + // THE ENGINE'S ONE QUESTION ABOUT A PERSON IS ASKED HERE, once per message, + // and never by a frame. It is what every held tab's watcher asks after every + // event its conversation produces (keeper.go), asked of the conversation in + // front at the same beat, so a `?` cannot stand on a tab beside this one and + // go away when that conversation comes forward (tabsignal.go). It is read + // BEFORE the title below, which is drawn from it. + a.frontWaits = needsPerson(a.agent) // AND WHATEVER THE LAST FRAME ASKED THE DISK ABOUT IS READ HERE, on the loop, // before the next frame draws (learned.go). `open` and `tick` may read the // disk and `body` may not, so a frame that met a picture nobody had stat'd diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 27f904561d..814bfcb8a7 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -615,8 +615,14 @@ func buildStamp() string { // showUnreadProfileKeys uses the notice ledger for a profile-scoped, set-scoped // conversation note. The keys are the config loader's result; this layer only // identifies and renders that result. +// +// IT IS NOT A TIP, SO THE HINTS ROW DOES NOT SILENCE IT. The Display tab's +// `hints` switch is for the one-line lessons in the border; this is a fact about +// the person's own settings file — something they wrote is being ignored and a +// default is in force instead — and a person who turned tips off still needs to +// hear it once. The ledger is borrowed only for its once-per-set memory. func (a *app) showUnreadProfileKeys(keys []string) { - if len(keys) == 0 || !a.notices.enabled { + if len(keys) == 0 { return } encoded, err := json.Marshal(keys) diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index f449163232..1e81668961 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -722,3 +722,18 @@ func TestUnreadProfileKeysNoticeTracksTheSet(t *testing.T) { t.Fatal("previous unread set showed again") } } + +// THE UNREAD-KEYS LINE IS NOT A TIP, so turning tips off does not silence it: it +// says something the person wrote is being ignored, and they need to hear it once +// whatever the hints row says. +func TestUnreadProfileKeysNoticeShowsWithHintsOff(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.notices = newNoticeBoard(filepath.Join(t.TempDir(), noticeLedgerName), "", false) + a.showUnreadProfileKeys([]string{"models"}) + for _, entry := range a.entries { + if entry.kind == entryNote && strings.Contains(entry.text, "config.json keys are not read: models") { + return + } + } + t.Fatal("with hints off, the unread config key was never named") +} diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 6881df2030..0a85f41739 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -1,6 +1,7 @@ package tui3 import ( + "sort" "strings" "time" @@ -9,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/fuzzy" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -614,19 +616,33 @@ func (a *app) setupModelChoices() []Model { models = append([]Model{{ID: current}}, models...) } } - find := strings.ToLower(strings.TrimSpace(a.setup.modelFind)) + find := strings.TrimSpace(a.setup.modelFind) if find == "" { return models } - out := make([]Model, 0, len(models)) + // THE SAME MATCHER /model USES ([picker.rank], internal/fuzzy), so what a + // person types finds the same models in setup as it does there. A plain + // substring test found nothing for `ds v4`, which /model answers with + // deepseek/deepseek-v4-flash: the query is tokens, every one must match, and + // the best alignment ranks first, ties keeping the catalog's order (#1321). + terms := fuzzy.Terms(strings.ToLower(find)) + type hit struct { + model Model + score int + } + hits := make([]hit, 0, len(models)) for _, model := range models { // A person types what they can SEE, so the friendly name is searched // beside the id: "sonnet" and "Claude Sonnet" reach the same row. - if strings.Contains(strings.ToLower(model.ID), find) || - strings.Contains(strings.ToLower(modelWord(model.ID)), find) { - out = append(out, model) + if score, ok := fuzzy.ScoreFields([]string{model.ID, modelWord(model.ID)}, terms); ok { + hits = append(hits, hit{model: model, score: score}) } } + sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score }) + out := make([]Model, 0, len(hits)) + for _, h := range hits { + out = append(out, h.model) + } return out } diff --git a/internal/tui3/onboarding_test.go b/internal/tui3/onboarding_test.go index e8a620bbfe..318fac4ed1 100644 --- a/internal/tui3/onboarding_test.go +++ b/internal/tui3/onboarding_test.go @@ -245,6 +245,30 @@ func TestTypingNarrowsTheModelListByNameAndById(t *testing.T) { } } +// SETUP FINDS WHAT /model FINDS. The first-run chooser matched the typed text +// as one substring, so `ds v4` found nothing there while /model's picker found +// deepseek/deepseek-v4-flash: the two lists read one catalog and must read one +// query the same way, which is the shared matcher's tokens (#1321). +func TestSetupsModelFilterIsTheModelPickersMatcher(t *testing.T) { + a, _ := controlsApp(t, nil) + a.models = func() []Model { + return []Model{ + {ID: "anthropic/claude-sonnet-4.5"}, + {ID: "deepseek/deepseek-v4-flash"}, + {ID: "openai/gpt-4.1-mini"}, + } + } + walkToControl(t, a, controlChatModel) + pressSetup(a, key("enter")) + for _, letter := range []string{"d", "s", " ", "v", "4"} { + pressSetup(a, key(letter)) + } + got := a.setupModelChoices() + if len(got) != 1 || got[0].ID != "deepseek/deepseek-v4-flash" { + t.Fatalf("`ds v4` in setup left %v, want the one model /model finds for it", got) + } +} + // THE MODEL IN USE IS CONFIRMABLE WITH NO CATALOG AT ALL. A fresh machine has // fetched nothing; a list that was empty — or worse, that opened on some other // model — would turn "let me look" into an accidental switch. diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index e32889df99..3998f93799 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -107,6 +107,11 @@ type tasksPlace struct { // ([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 diff --git a/internal/tui3/tabsignal.go b/internal/tui3/tabsignal.go index e0821d9a60..3e04970612 100644 --- a/internal/tui3/tabsignal.go +++ b/internal/tui3/tabsignal.go @@ -27,7 +27,9 @@ import ( // So the far side's two facts are CACHED BY THE WATCHER THAT WAS ALREADY // WATCHING, at the transitions it was already computing (keeper.go's // [behindWatch.waits] and [behindWatch.turning]), and reading them here is two -// atomic loads per tab. The near side's are read off the surface itself. +// atomic loads per tab. The near side's question is cached the same way, by the +// loop after each message ([app.frontWaits]), and its work is read off the +// surface itself. // // ── WHAT COUNTS AS NEEDING A PERSON ───────────────────────────────────────── // @@ -38,9 +40,12 @@ import ( // proposal carrying a deadline is a countdown, which proceeds whether or not // anybody looks at it, and is not a request. // -// The front tab is answered from the surface instead of from the agent, and its -// two states are written to mean the same thing: a consent card on screen, and a -// task proposal that is genuinely waiting rather than counting down. INTERNAL +// The front tab reads THE SAME PREDICATE, asked on the loop once per message +// rather than by the frame ([app.frontWaits]), with the cards already on its own +// screen beside it. It used to read the surface alone, which counted a consent +// card and a waiting proposal and none of a landed `your call`, the model's own +// blocking question or a sub-harness's, so a `?` on the tab beside went away +// the moment that conversation came forward (#1316). INTERNAL // WAITS ARE NOT ON EITHER LIST. A tool checking something, a node waiting on a // dependency, a provider being retried — all of those are WORK, and a strip that // spelled them `?` would put an amber question mark on every tab at once and @@ -143,20 +148,13 @@ func (a *app) tabSignalFor(key string, here bool) tabSignal { // asksStanding reports whether the surface holds a standing answer, which is a // question a firing put to this person and which nothing else will answer. // -// IT NAMES ONE KIND RATHER THAN TREATING EVERY OPEN QUESTION AS A SIGNAL, and -// the two it leaves out are left out for different reasons. A task proposal -// carrying a deadline is a countdown, and a countdown answers itself. A LANDED -// `your call` IS DELIBERATELY ABSENT, and not because it is unimportant: a -// landing's question object stays open through the whole settle AFTER the -// person has answered it (session's publishLandingQuestion retires it only when -// the node leaves TaskUnverified, and an accept does not move the state until -// the merge finishes). Counting it here would hold a mark up over an answer -// already given, which is the one thing a mark must never do. The fact that -// would tell a live landing from a settling one is not on this surface in any -// usable shape, so until it is, this lane says nothing rather than saying -// something stale. The landing is not lost from the screen: it is drawn in the -// conversation itself ([app.questionDrawnHere] returns true for it) and on its -// own card, so the person on this tab is already looking at it. +// IT NAMES ONE KIND RATHER THAN TREATING EVERY OPEN QUESTION AS A SIGNAL. A +// task proposal carrying a deadline is a countdown, and a countdown answers +// itself. A LANDED `your call` is not read here either, because this surface +// cannot tell a live landing from one already answered and still settling; the +// engine can (session's personAskLanding skips a settling landing, #1322), and +// its answer reaches the front tab through [app.frontWaits]. So the landing +// does wear the mark, from the one reader that knows when to take it off. func (a *app) asksStanding() bool { for _, open := range a.questions { if open.question.Kind == session.QuestionStanding { @@ -174,7 +172,18 @@ func (a *app) asksStanding() bool { // reading tells a question from a countdown (session's [Agent.waitingOnPerson]). // A proposal that will proceed on its own is work, and it wears the working mark // or none. +// +// THE ENGINE'S ANSWER COMES FIRST, AND IT IS THE SAME ANSWER A HELD TAB READS. +// [app.frontWaits] is [session.Agent.NeedsPerson] asked once per message on the +// loop, which is exactly what [behindWatch.waits] caches for every other tab +// (#1316). The surface's own list below it is not a second opinion: every lane +// on it is one of the engine's, and it stays so the mark is up on the frame the +// card arrives and so a scripted agent that cannot want anything still reads +// the cards it was handed. func (a *app) frontSignal() tabSignal { + if a.frontWaits { + return tabNeedsPerson + } if run := a.orchOf(); run != nil && run.gate != nil { return tabNeedsPerson } diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 42d8733f8e..d6eef68afa 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -1129,9 +1129,21 @@ func (a *app) taskPlanToggle(id string) tea.Cmd { // 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 { - text := strings.TrimSpace(a.taskSheet.planNote.String()) - if text == "" { + raw := a.taskSheet.planNote.String() + text := strings.TrimSpace(raw) + if text == "" || a.taskSheet.planSending { return nil } agent, ok := a.planReader() @@ -1139,21 +1151,32 @@ func (a *app) taskPlanNoteSend() tea.Cmd { 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 { - a.pageMsg = err.Error() + if onPage { + a.pageMsg = err.Error() + } a.touch() return nil } - a.taskSheet.planNote.reset() 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 { @@ -1179,6 +1202,12 @@ func (a *app) taskSheetPlanKey(key string) (tea.Cmd, bool) { if !ok || item.plan == nil { return nil, false } + // AN ENDED ROW TAKES NEITHER KEY, because its foot names neither + // ([app.tasksPlanKeyWords]): a key the line does not offer is the letter it + // is, never a verb the store can only refuse. + if planEnded(*item.plan) { + return nil, false + } switch key { case stopRaiseKey: return a.taskPlanStop(*item.plan), true @@ -1250,7 +1279,13 @@ func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { // 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]). - if a.taskSheet.planNote.empty() { + // + // 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) @@ -1297,7 +1332,11 @@ func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { a.taskPlanScroll(taskSheetRows) return nil case "ctrl+o": - if len(planBriefRows(a.taskSheet.plan.Description, a.bodyWidth())) > briefFoldLines { + // 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 @@ -1355,7 +1394,30 @@ func (a *app) taskPlanWindow(width, height int) ([]string, int, int) { if room < 1 { room = 1 } - return a.taskPlanBody(width - 2), room, foot + 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 @@ -1578,11 +1640,8 @@ func (a *app) taskPlanBody(width int) []string { // 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. - who := "" - if note.Person { - who = "you" - } + // on a person's screen ([planNoteWho]). + who := planNoteWho(note) when := sinceAt(note.At, a.now()) switch { case who != "" && when != "": @@ -2020,11 +2079,12 @@ func planWithoutOwnFolder(command, folder string) string { // 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 handed -// to the page's own keyboard the moment the page is up ([app.finishRailPlan]). +// 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]). // // THREE WAYS OUT, and none of them reaches the conversation: the answer opens -// the page and replays the keys; the answer says there is no page, the row's +// 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. @@ -2042,21 +2102,38 @@ func (a *app) finishRailPlan(id string) tea.Cmd { keys := a.railPlanPending.keys a.railPlanPending = railPlanPending{} a.railTaskPlanOn = true - var cmds []tea.Cmd for _, key := range keys { - // A KEY THAT LEFT THE PAGE ENDS THE REPLAY. The keys were kept for the - // page, and one of them can close it (`esc`, or the stop on the run's own - // page, which steps aside for its card): what was typed after it was typed - // blind, and is dropped rather than aimed at whatever is up now. - if !a.taskSheet.planOn { - break - } - if cmd := a.taskPlanKey(key); cmd != nil { - cmds = append(cmds, cmd) - } + 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. +// +// 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 +// (`x-axis labels are wrong`) cancelled a running part with nothing asked, and +// the `enter` after it sent the rest to the store, all before the page had been +// drawn (#1244). A person typing at a page they cannot see yet is writing to its +// box, so the text keys and the box's own editing keys are replayed and every +// 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 + switch key.String() { + case "backspace": + note.deleteBackward() + return + case "ctrl+w": + note.deleteWord() + return + case "ctrl+u": + note.killToStart() + return } - if !a.taskSheet.planOn { - a.railTaskPlanOn = false + if text := key.Key().Text; text != "" { + note.insert(text) } - return tea.Batch(cmds...) } diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 89ac2b7a2d..00f05e4e41 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -113,11 +113,15 @@ func (a *app) workTabFrame(width, height int) []string { out = append(out, "") } for _, note := range a.taskSheet.plan.Notes { - who := strings.TrimSpace(note.Author) - if note.Person { - who = "you" + // 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, a.pal.dim(who+railSep)+a.pal.ink(note.Body)) + out = append(out, line) } text := a.taskSheet.planNote.String() if strings.TrimSpace(text) == "" { From 745a0b1ade7947067bf9c6ca62e0849bf2d5f05c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:57:21 -0400 Subject: [PATCH 06/10] changes: the task-page fixes' entry, with what each one invalidates --- .../1395-task-page-keys-and-tab-mark.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/changes/unreleased/1395-task-page-keys-and-tab-mark.md diff --git a/docs/changes/unreleased/1395-task-page-keys-and-tab-mark.md b/docs/changes/unreleased/1395-task-page-keys-and-tab-mark.md new file mode 100644 index 0000000000..cd9ada6b87 --- /dev/null +++ b/docs/changes/unreleased/1395-task-page-keys-and-tab-mark.md @@ -0,0 +1,26 @@ +--- +kind: fixed +title: the task page's keys, its note and its tab mark say one thing, and three catalog warms are owned +pr: 1395 +surface: [chat, engine] +invalidates: + - "The tab in front answered 'waiting on a person' from the surface alone, so a landed your call, the model's own blocking question or a sub-harness's question wore a ? on the tab beside it and lost it when the conversation came forward. Both sides now read the engine's NeedsPerson; the front asks it once per message on the loop, never per frame." + - "x and p acted on an ended task's page and row although the key line hides both: x raised Stop this task? over a finished run, and on a finished part drew the store's raw 'is already terminal'. On an ended page they are letters in the note, and on an ended row they are letters." + - "Keys typed while a run task's page was opening were replayed through the page's whole keyboard, so a note beginning with x cancelled a running part and a typed enter sent it. They now go into the note box only, and an enter typed in that gap is dropped." + - "A note's enter pressed twice sent it twice, and its reply replaced whichever page was open with the page it was sent from. The send is one send while in flight, and the reply lands only on its own page." + - "A restart judge sweep that stopped early on a leftover pending.jsonl.sweeping claim renamed the fresh pending file over it and lost the leftover's unjudged rows. The fresh file now waits for the next start." + - "The direct-service catalog a conversation opens, the catalog each standing pass opens and the host door's catalog warmed under a context nothing cancelled and were never joined. The process, the pass and the window's fleet now close each." + - "The manual said a run task's page names its folder once in the head. It does not, and has not since #1257: the folder is the run's own working copy, and step commands are drawn without the change into it." +--- +The front tab caches the engine's answer in app.frontWaits after every message, +beside the surface's own cards, and frontSignal reads the cache; the strip still +asks no lock per frame. The keeper's watcher already asked the same predicate +after every event of a held conversation. + +ctrl+o on a run task's page counts the brief's rows at the width the page draws +it rather than the conversation's body width. The work tab draws a note's author +the page's way, `you` or nothing. The first-run model chooser filters through +internal/fuzzy, so `ds v4` finds what /model finds. The unread config keys notice +is not a tip and shows with hints off; commands.md has its section, and +screen.md says which fenced languages are coloured and that the rest are plain. +The goroutine inventory's chatv3/sweep-home row is joined, as #1276 made it. From 6a5b4d370b26822637fc67720ed2cb1b387cfe98 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 09:58:48 -0400 Subject: [PATCH 07/10] tui3, manual: the moved-away test starts the other page's box empty; the gap's heading stops pulling the run-rows question --- internal/manual/chat/task-controls.md | 10 +++++----- internal/tui3/taskpage_keys_test.go | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/task-controls.md b/internal/manual/chat/task-controls.md index df64d4cec5..01fbfe6d2e 100644 --- a/internal/manual/chat/task-controls.md +++ b/internal/manual/chat/task-controls.md @@ -166,12 +166,12 @@ the `Stop this task?` card before anything ends, and `p pause` is not offered, b 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 before a run task's page opens — where do the keys go +## Typing while a task page is still loading — the letters land in its note box -A run task's page can take a moment to arrive, most of all over `--host`. Everything you type -between pressing its row 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 +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 withdraws the press and drops what was typed. diff --git a/internal/tui3/taskpage_keys_test.go b/internal/tui3/taskpage_keys_test.go index 4b48f7d902..4e1574f267 100644 --- a/internal/tui3/taskpage_keys_test.go +++ b/internal/tui3/taskpage_keys_test.go @@ -220,6 +220,7 @@ func TestANoteReplyNeverOverwritesAnotherPage(t *testing.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") drain(t, a, sent) if got := a.taskSheet.plan.Row.ID; got != rows[1].ID { From 89f5e0f402740d44ac8ff71c6e1d802fc3f4bb2b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 11:11:04 -0400 Subject: [PATCH 08/10] tui3: setup builds its model terms through the picker's own fuzzyTerms --- internal/tui3/onboarding.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 0a85f41739..24b657b2e0 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -625,7 +625,9 @@ func (a *app) setupModelChoices() []Model { // substring test found nothing for `ds v4`, which /model answers with // deepseek/deepseek-v4-flash: the query is tokens, every one must match, and // the best alignment ranks first, ties keeping the catalog's order (#1321). - terms := fuzzy.Terms(strings.ToLower(find)) + // The words are folded and made into terms exactly as the picker makes them + // ([fuzzyTerms]), so a capital typed here means what it means there. + terms := fuzzyTerms(strings.Fields(strings.ToLower(find))) type hit struct { model Model score int From 9ef69833832a2af9f904f021cdb91ff254a80de6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 11:29:32 -0400 Subject: [PATCH 09/10] tui3: the gap's replay takes ctrl+k beside ctrl+u, both halves of the line kill --- internal/tui3/taskplan.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index d6eef68afa..f6391fb7c8 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -2132,6 +2132,9 @@ func (a *app) railPlanReplay(key tea.KeyPressMsg) { case "ctrl+u": note.killToStart() return + case "ctrl+k": + note.killToEnd() + return } if text := key.Key().Text; text != "" { note.insert(text) From 635c029c6a311ad84ccb73a961ea5b48dbe3ebb5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 23 Sep 2026 11:55:17 -0400 Subject: [PATCH 10/10] chat: the catalog and closer lists unlock from a defer, the guarded tree's lock law --- cmd/codeaf/chatv3_beside.go | 18 ++++++++++++------ cmd/codeaf/chatv3_process.go | 15 +++++++++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/cmd/codeaf/chatv3_beside.go b/cmd/codeaf/chatv3_beside.go index deb7ea3cbd..b26a2ff3c3 100644 --- a/cmd/codeaf/chatv3_beside.go +++ b/cmd/codeaf/chatv3_beside.go @@ -157,8 +157,18 @@ func (f *engineFleet) own(shut func()) { return } f.mu.Lock() + defer f.mu.Unlock() f.closers = append(f.closers, shut) - f.mu.Unlock() +} + +// takeClosers hands over everything [engineFleet.own] was given and empties the +// list, so the joins run after the lock is let go. +func (f *engineFleet) takeClosers() []func() { + f.mu.Lock() + defer f.mu.Unlock() + closers := f.closers + f.closers = nil + return closers } // machineReadings is the handful of per-window facts a beside conversation needs @@ -320,11 +330,7 @@ func (f *engineFleet) closeAll() { _ = f.boot.close() // AND WHAT THE WINDOW OPENED BESIDE THEM, joined last: nothing it warmed may // write after the door has let go. - f.mu.Lock() - closers := f.closers - f.closers = nil - f.mu.Unlock() - for _, shut := range closers { + for _, shut := range f.takeClosers() { shut() } } diff --git a/cmd/codeaf/chatv3_process.go b/cmd/codeaf/chatv3_process.go index 45f9090de2..e1ee9b3e33 100644 --- a/cmd/codeaf/chatv3_process.go +++ b/cmd/codeaf/chatv3_process.go @@ -137,14 +137,21 @@ func (p *v3Process) ownCatalog(models *catalog.Catalog) { if p == nil || models == nil { return } + if !p.keepCatalog(models) { + models.Close() + } +} + +// keepCatalog files one catalog for closeAll, and answers false when the close +// has already begun and nothing will come back for it. +func (p *v3Process) keepCatalog(models *catalog.Catalog) bool { p.mu.Lock() + defer p.mu.Unlock() if p.closed { - p.mu.Unlock() - models.Close() - return + return false } p.catalogs = append(p.catalogs, models) - p.mu.Unlock() + return true } // takeCatalogs hands over every catalog [v3Process.ownCatalog] was given and