From cdbc27bc83f10c938a9999c274183d8b3005d7c4 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 22:11:41 -0400 Subject: [PATCH 1/3] tui3: enter takes the answer the pointer stands on, on a standing card (#1506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standing card's box is its correction lane, and the InputText give-up read that as the whole question: enter over an empty box did nothing even with the pointer standing on an answer, and the key table's own reading offered enter only where the asker recommended something. Both give-ups now yield to a pointer that stands on an option on a question that has a pick to take — a choice or a judgement — while a connect key offer keeps its own law: one answer, the way out, taken by a digit, and enter means the words. The walk was already there; enter just never followed it. --- internal/tui3/question.go | 21 +++- internal/tui3/questionkeys.go | 17 ++- internal/tui3/standingenter_test.go | 154 ++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 internal/tui3/standingenter_test.go diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 87c14088ab..17334c7041 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -3186,8 +3186,27 @@ func (a *app) questionEnter(head questionShown, typing bool) (tea.Cmd, bool) { // as a decline; it does nothing, the question stands, and the way out is the // answer that says so ([questionOwnsBox] is the same fact from the other // side, and takes this key while there ARE words). + // + // AND THE WAY OUT IS NOT THE WHOLE QUESTION. A correction, a connect key or + // a standing card is still a question WITH ANSWERS, and every question with + // answers has a pointer the arrows walk ([questionPointerStart]) — a + // standing card whose Enter did nothing while the pointer stood on + // `1 keep this rule` was the owner, 2026-09-25 (#1506). So the give-up below + // yields to a pointer: enter over an empty box takes the answer the pointer + // is on, and only a question with nothing under the pointer — and no + // asker's pick beside it — hands the key back untouched. if head.question.Input.Kind == session.InputText { - return nil, false + // THE POINTER STANDS FOR A PICK ONLY WHERE A PICK EXISTS. A connect + // question keeps exactly one answer, the way out, and that answer is + // taken by a digit, never by enter — enter there means the words ([#1506 + // broke it wide]). A standing card is a real choice between answers, so + // a pointer standing on one of them IS the pick enter takes. + choice := head.question.Ask == session.AskChoice || head.question.Ask == session.AskJudgement + under := choice && head.pick >= 0 && head.pick < len(head.question.Options) + picked := head.question.Pick != nil && strings.TrimSpace(head.question.Pick.Key) != "" + if !under && !picked { + return nil, false + } } // ENTER TAKES THE ANSWER THE POINTER IS ON, through the same door a digit // goes through, so a widening answer still gets its second beat and a diff --git a/internal/tui3/questionkeys.go b/internal/tui3/questionkeys.go index 2e709fe500..c22b8ca7e9 100644 --- a/internal/tui3/questionkeys.go +++ b/internal/tui3/questionkeys.go @@ -619,9 +619,20 @@ func (a *app) questionOffers(q questionShown, need questionNeed) bool { return len(room.picked) > 0 || (q.question.Pick != nil && strings.TrimSpace(q.question.Pick.Key) != "") } if q.question.Input.Kind == session.InputText { - // A question answered in words has nothing for enter to take - // while the box is empty ([app.questionEnter]). - return q.question.Pick != nil && strings.TrimSpace(q.question.Pick.Key) != "" + // A question answered in words has nothing for enter to take while + // the box is empty AND nothing stands under the pointer — the same + // give-up [app.questionEnter] makes ([#1506]): a correction or a + // standing card still carries answers the arrows walk, so a + // pointer on one is enter taking it. + if q.question.Pick != nil && strings.TrimSpace(q.question.Pick.Key) != "" { + return true + } + // A PICK LIVES ON A CHOICE. A connect question keeps one answer, the + // way out, and its enter means the words — the offer row says so. + if q.question.Ask != session.AskChoice && q.question.Ask != session.AskJudgement { + return false + } + return q.pick >= 0 && q.pick < len(q.question.Options) } if len(q.question.Options) > 0 { return true diff --git a/internal/tui3/standingenter_test.go b/internal/tui3/standingenter_test.go new file mode 100644 index 0000000000..d4e3226f53 --- /dev/null +++ b/internal/tui3/standingenter_test.go @@ -0,0 +1,154 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/standing" +) + +// standingQuestionOf is the question a standing card raises: the same shape +// [app.standingQuestion] builds for the lane's notice, spelled here so the +// test owns the shape it is holding [app.questionEnter] to. +func standingQuestionOf() session.Question { + it := standing.Item{When: standing.When{Kind: standing.WhenHold}} + return session.Question{ + ID: 5, + Kind: session.QuestionStanding, + Ask: session.AskChoice, + Form: session.FormCard, + Asker: session.Asker{Kind: session.AskerModel}, + Head: session.StandingHeadRule, + Reason: session.StandingAskReason, + Subject: session.SubjectRef{Kind: session.SubjectOrder, ID: 5, Name: "daily build check"}, + Options: session.StandingOptions(it), + Stakes: session.StakesReversible, + Scope: []session.AnswerScope{session.ScopeOnce, session.ScopeAlways}, + Input: session.InputShape{Kind: session.InputText, Prompt: session.StandingChangeHint(it)}, + Asked: time.Date(2026, time.September, 25, 14, 0, 0, 0, time.UTC), + } +} + +// ENTER ON A STANDING CARD TAKES THE ANSWER THE POINTER IS ON. The card's box +// is its correction lane ([session.InputText]), but the card is still a +// question WITH ANSWERS, and every question with answers has a pointer the +// arrows walk — so `enter` over an empty box takes the pointed answer rather +// than doing nothing. The owner, 2026-09-25, #1506: Enter selected nothing; +// only a mouse click answered. +func TestEnterOnAStandingCardTakesTheAnswerThePointerIsOn(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(standingQuestionOf()) + lab.tick(2 * time.Second) // past the settle guard + + // The box is empty and the pointer stands on the first answer. + if !lab.press(questionEnterKey) { + t.Fatalf("enter was not taken on the standing card at all") + } + if len(lab.answer) != 1 { + t.Fatalf("enter answered nothing: %v", lab.answer) + } + got := lab.answer[0] + if len(got.Picked) == 0 || got.Picked[0] != "1" { + t.Fatalf("enter took %v, want the first answer (keep the rule)", got.Picked) + } + if got.Change != "" { + t.Fatalf("enter sent words %q from an empty box", got.Change) + } +} + +// THE ARROWS MOVE THE POINTER AND ENTER MOVES WITH IT, so a person who walked +// down to the second answer and pressed enter answered that one — not the +// first row, and not nothing. +func TestEnterOnAStandingCardTakesTheAnswerThePointerWalkedTo(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(standingQuestionOf()) + lab.tick(2 * time.Second) // past the settle guard + + if !lab.press("down") { + t.Fatalf("the walk was not taken on the standing card") + } + if !lab.press(questionEnterKey) { + t.Fatalf("enter was not taken after the walk") + } + if len(lab.answer) != 1 { + t.Fatalf("enter answered nothing: %v", lab.answer) + } + if len(lab.answer[0].Picked) == 0 || lab.answer[0].Picked[0] == "1" { + t.Fatalf("enter took %v, want the second answer the pointer stood on", lab.answer[0].Picked) + } +} + +// AND THE CARD ANSWERS TO THE WORDS THE PROMPT ASKS FOR, which is the +// correction lane this card always had: typing before enter sends the words, +// not the pick ([app.questionEnter]). +func TestAStandingCardTypedAnswerStillTravelsAsWords(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(standingQuestionOf()) + lab.tick(2 * time.Second) + + lab.a.input.setText("only on weekdays") + if !lab.press(questionEnterKey) { + t.Fatalf("enter was not taken with words in the box") + } + if len(lab.answer) != 1 || lab.answer[0].Change != "only on weekdays" { + t.Fatalf("enter took %+v, want the correction as words", lab.answer) + } +} + +// AND THE CONNECT KEY OFFER KEEPS ITS OWN LAW, which is what the pointer +// give-up must not reach: one answer, the way out, taken by a digit — enter +// over an empty box does nothing there (connectkey_test.go's own test holds +// this too; this one pins the standing shape BESIDE it). +func TestEnterOnATextQuestionWithNoPickStillDoesNothing(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(session.Question{ + ID: 7, + Kind: session.QuestionConnect, + Ask: session.AskClarification, + Form: session.FormCard, + Head: "connect your Notion account?", + Options: []session.AnswerOption{{Key: "2", Label: "not now", Safe: true}}, + Stakes: session.StakesReversible, + Scope: []session.AnswerScope{session.ScopeOnce}, + Input: session.InputShape{Kind: session.InputText, Prompt: "paste your Notion key"}, + Asked: time.Date(2026, time.September, 25, 14, 0, 0, 0, time.UTC), + }) + lab.tick(2 * time.Second) + + if lab.press(questionEnterKey) && len(lab.answer) != 0 { + t.Fatalf("enter answered a words question with nothing to take: %v", lab.answer) + } + if len(lab.a.questions) != 1 { + t.Fatalf("the offer did not stand") + } + // the way out is still the digit + if !lab.press("2") { + t.Fatalf("the way out was not taken") + } + if len(lab.answer) != 1 { + t.Fatalf("the digit answered nothing: %v", lab.answer) + } +} + +// AND THE OFFER ROW SAYS WHAT ENTER DOES ON A STANDING CARD, once the table +// offers it ([app.questionAnswerKeys] is one reading for drawn and taken +// alike): the walk and the pick are named where the question offers them. +func TestAStandingCardTableOffersEnterWhileThePointerStandsOnAnAnswer(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(standingQuestionOf()) + + keys := lab.a.questionAnswerKeys(lab.a.questions[0], formsCard) + var words []string + for _, verb := range keys { + words = append(words, questionKeySpelling(verb.key)+" "+verb.word) + } + row := strings.Join(words, questionKeyGap) + if !strings.Contains(row, "enter take it") { + t.Fatalf("the standing card's table never offered enter: %s", row) + } + if !strings.Contains(row, "↓ choose") { + t.Fatalf("the standing card's table never offered the walk: %s", row) + } +} From 8a4755e56a94c86214b7e6ec60f29cf75932d7d7 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 22:19:58 -0400 Subject: [PATCH 2/3] style: gofmt internal/tui3/standingenter_test.go Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/tui3/standingenter_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/tui3/standingenter_test.go b/internal/tui3/standingenter_test.go index d4e3226f53..0f154ae54e 100644 --- a/internal/tui3/standingenter_test.go +++ b/internal/tui3/standingenter_test.go @@ -104,16 +104,16 @@ func TestAStandingCardTypedAnswerStillTravelsAsWords(t *testing.T) { func TestEnterOnATextQuestionWithNoPickStillDoesNothing(t *testing.T) { lab := newQuestionLab(t) lab.raise(session.Question{ - ID: 7, - Kind: session.QuestionConnect, - Ask: session.AskClarification, - Form: session.FormCard, - Head: "connect your Notion account?", + ID: 7, + Kind: session.QuestionConnect, + Ask: session.AskClarification, + Form: session.FormCard, + Head: "connect your Notion account?", Options: []session.AnswerOption{{Key: "2", Label: "not now", Safe: true}}, - Stakes: session.StakesReversible, - Scope: []session.AnswerScope{session.ScopeOnce}, - Input: session.InputShape{Kind: session.InputText, Prompt: "paste your Notion key"}, - Asked: time.Date(2026, time.September, 25, 14, 0, 0, 0, time.UTC), + Stakes: session.StakesReversible, + Scope: []session.AnswerScope{session.ScopeOnce}, + Input: session.InputShape{Kind: session.InputText, Prompt: "paste your Notion key"}, + Asked: time.Date(2026, time.September, 25, 14, 0, 0, 0, time.UTC), }) lab.tick(2 * time.Second) From d8c1b0fc754b2473e8a730e3b8dbbaea54e40035 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 22:20:22 -0400 Subject: [PATCH 3/3] docs: write the change down for 1506 --- .../1506-enter-takes-the-pointers-answer.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1506-enter-takes-the-pointers-answer.md diff --git a/docs/changes/unreleased/1506-enter-takes-the-pointers-answer.md b/docs/changes/unreleased/1506-enter-takes-the-pointers-answer.md new file mode 100644 index 0000000000..c92e1fbf7c --- /dev/null +++ b/docs/changes/unreleased/1506-enter-takes-the-pointers-answer.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: enter takes the answer the pointer stands on, on a standing card +pr: 1506 +surface: [chat] +invalidates: + - "On a standing card with a text question, Enter did nothing: the typed answer sat in the field until the button was clicked, and a question asked for words looked like it refused the keyboard. Enter now takes the answer the pointer stands on and sends it as the person's words; with nothing picked, Enter still does nothing." +--- + +The standing card is the prompt a person answers every session, so its +keyboard has to work like every other card's. The Enter key takes the answer +the pointer stands on — the option highlighted, or the words typed into the +field — and travels as the person's words. A text question with nothing +picked keeps its old refusal, so a stray Enter cannot send an empty answer.