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 000000000..c92e1fbf7 --- /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. diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 87c14088a..17334c704 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 2e709fe50..c22b8ca7e 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 000000000..0f154ae54 --- /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) + } +}