From 7834cc7d01655733434d274781aa7798646d4126 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 14 Sep 2026 05:04:29 -0400 Subject: [PATCH] tui3, manual: a question's own answer box answers to the whole caret vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `questionOtherKey` routed keys by hand and bound a short list, so in a question's own answer box the usual text-editing keys did nothing: cmd-left/right never reached the ends of the line, cmd-delete never killed to the line start, and opt-delete/ctrl-delete never deleted a word — while the message box directly beneath the question answered to every one of them. The box now reads the surface's one word-and-line vocabulary (`editorMotion`/`editorWordKill`, editkeys.go) the way every other box does, and gains the line kill's two names (`ctrl+u`, `super+backspace`). `ctrl+w` stays out on purpose: it shuts the tab in front. The manual gains the box on the list of boxes that answer the caret vocabulary, `questions.md` says so at the `something else…` row, and a new test walks every chord family through the row's own box. Co-Authored-By: aforge --- .../1032-question-answer-box-edits.md | 14 ++ internal/manual/chat/keys.md | 7 +- internal/manual/chat/questions.md | 5 + internal/tui3/question.go | 38 ++++- internal/tui3/question_test.go | 131 ++++++++++++++++++ 5 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 docs/changes/unreleased/1032-question-answer-box-edits.md diff --git a/docs/changes/unreleased/1032-question-answer-box-edits.md b/docs/changes/unreleased/1032-question-answer-box-edits.md new file mode 100644 index 0000000000..632bf05d27 --- /dev/null +++ b/docs/changes/unreleased/1032-question-answer-box-edits.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: a question's own answer box answers to the whole caret vocabulary, not a hand-rolled copy of it +pr: 1032 +surface: [chat, docs] +invalidates: + - "The `something else…` row on a question — the one box on the block a person writes an answer in — bound its own short key map, so ⌘←/⌘→ reached no part of the caret, ⌘⌫ killed no line and ⌥⌫/ctrl+⌫ killed no word, all while the message box directly beneath it answered to every one of them. It now reads the same shared vocabulary every other box on the surface reads (`editorMotion`/`editorWordKill`)." + - "`internal/manual/chat/keys.md` listed the boxes that answer the one caret vocabulary and left the question's own answer box off it. It is on the list, and `questions.md` says so at the `something else…` row." +--- + +The row hand-rolled `left`/`right`/`home`/`end` and `alt+←`/`alt+b` and nothing +else — a copy of the surface's word-and-line vocabulary that had drifted from +the one every other box reads. It is one more caller of `internal/tui3/editkeys.go` +now, so the next chord added there reaches it too. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 8204285665..6b6b3f15b1 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -743,9 +743,10 @@ above, those are the keys. The caret keys are one vocabulary and every box on the surface answers it: the message box, **home's box at the foot of the screen**, the errand pane on home, -the settings filter and its value editor, the task page's filter, the rewind -search, and every filterable overlay — the model picker, `/resume`, `/files`, the -memory panel, the connect key box, the connections panel. +the `something else…` box on a question's own answer row, the settings filter and +its value editor, the task page's filter, the rewind search, and every filterable +overlay — the model picker, `/resume`, `/files`, the memory panel, the connect key +box, the connections panel. | Chord | Everywhere | |---|---| diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 33452c94df..b38c9eab69 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -550,6 +550,11 @@ straight to that row, and it carries the answer you were standing on with it — the row then says `it goes with 2 Adaptive`, which is "I will take that one, but not as it stands". +**It is a real box**, not a bare line: the caret keys every other box answers +work in it too — `⌘←`/`⌘→` to the ends of the line, `⌥⌫`/`ctrl+⌫` to kill a word, +`ctrl+u` to kill to the start of the line (keys.md, *Word jump and line jump work +in every box*). + **A question with nothing to weigh is one row**, because a frame around one sentence is a box drawn around nothing: diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 8f845404cd..e02c4afab4 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -3177,13 +3177,37 @@ func (a *app) questionWalkCount(q questionShown) int { // still *later* (it is read before this, with every other question's esc). A key // this row does not know is handed back rather than swallowed — the block takes // only the keys it draws, and a box is not a reason to stop being that. +// +// THE WORD JUMPS AND THE WORD KILL ARE THE SURFACE'S AND NOT THIS BOX'S +// (editkeys.go). This row is a box like every other on the program, so it reads +// the one vocabulary they all read rather than a hand-rolled copy of it — the +// copy this file carried bound `alt+←`/`alt+b` and the two ends of the line and +// nothing else, so ⌘←/⌘→ reached no part of the caret, ⌘⌫ killed no line and +// ⌥⌫/ctrl+⌫ killed no word in the one box a person writes an answer in. A box +// that answers to fewer names than the message box directly beneath it is the +// defect editkeys.go was written for, said about the last box it had not reached. func (a *app) questionOtherKey(head questionShown, msg tea.KeyPressMsg) (tea.Cmd, bool) { open := a.questionHeld(head.token()) if open == nil { return nil, false } box := &open.other.words - switch key := msg.String(); key { + key := msg.String() + // THE CARET JUMPS ANSWER TO EVERY NAME A TERMINAL SENDS THEM BY: `alt+←`, + // `alt+b`, `ctrl+←`, `super+←`/`meta+←` (⌘←), `ctrl+a` for the start of the + // line, and the same four forward for its end. + if editorMotion(box, key) { + a.touch() + return nil, true + } + // AND SO DOES THE WORD KILL: ⌥⌫ (`alt+backspace`), ctrl+⌫ (`ctrl+backspace`). + // `ctrl+w` is deliberately absent here as it is in the message box — it shuts + // the tab in front, read far above this box (tabclosekey.go). + if editorWordKill(box, key) { + a.touch() + return nil, true + } + switch key { case questionEnterKey: words := strings.TrimSpace(box.String()) if words == "" { @@ -3213,14 +3237,16 @@ func (a *app) questionOtherKey(head questionShown, msg tea.KeyPressMsg) (tea.Cmd box.left() case "right", "ctrl+f": box.right() - case "home", "ctrl+a": + case "home": box.home() case "end", "ctrl+e": box.end() - case "alt+left", "alt+b": - box.wordLeft() - case "alt+right", "alt+f": - box.wordRight() + // THE LINE KILL ANSWERS TO BOTH OF ITS NAMES, exactly as it does in the + // message box and in every filterable overlay (input.go, palette.go's + // [listNavigate], settings.go): `ctrl+u` is readline's, and `super+backspace` + // is what ⌘⌫ sends on a terminal that reports the modifier at all. + case "ctrl+u", "super+backspace": + box.killToStart() case "backspace": box.deleteBackward() case "delete": diff --git a/internal/tui3/question_test.go b/internal/tui3/question_test.go index 941141557a..c620e4d964 100644 --- a/internal/tui3/question_test.go +++ b/internal/tui3/question_test.go @@ -136,6 +136,16 @@ func (l *questionLab) press(key string) bool { return taken } +// pressMsg is [questionLab.press] for a chord whose spelling is a Code and a +// Modifier together and not a one-rune name — every modified ARROW and +// backspace, which is the half of the surface's vocabulary a test written in +// names cannot reach ([questionPressOf] spells only the plain keys). +func (l *questionLab) pressMsg(msg tea.KeyPressMsg) bool { + cmd, taken := l.a.questionKey(msg) + l.spend(cmd) + return taken +} + // spend runs one command the way the loop would, including whatever it hands // back — the fold of a door's answer among it (offloop.go). func (l *questionLab) spend(cmd tea.Cmd) { @@ -146,6 +156,127 @@ func (l *questionLab) spend(cmd tea.Cmd) { drive(l.t, l.a, runCmd(cmd)...) } +// ── THE ANSWER BOX IS A BOX ───────────────────────────────────────────────── + +// TestAQuestionAnswerBoxEditsLikeEveryOtherBoxOnTheSurface holds the `something +// else…` row — the one box on this block a person WRITES an answer in — to the +// surface's one word-and-line vocabulary (editkeys.go). +// +// WHAT WAS MEASURED. The row hand-rolled its key map: the two ends of the line +// and `alt+←`/`alt+b`, and nothing else. So the chords a hand actually presses +// to fix a sentence did nothing in the one box the block gives them for exactly +// that — ⌘←/⌘→ reached no part of the caret, ⌘⌫ killed no line, and ⌥⌫/ctrl+⌫ +// killed no word — while the message box directly beneath the question answered +// to all of them. A box that answers to fewer names than the box under it is the +// split editkeys.go was written to end, said about the last box it had not +// reached. +func TestAQuestionAnswerBoxEditsLikeEveryOtherBoxOnTheSurface(t *testing.T) { + const draft = "read the config file" // 20 runes; "file" starts at 16 + + // openBox raises a question, aims at the block, presses `c` — which walks the + // pointer onto the `something else…` row and carries the answer it came from + // — and hands back that row's own box. + openBox := func(t *testing.T, words string) (*questionLab, *questionShown) { + t.Helper() + lab := newQuestionLab(t) + lab.a.width = 120 + lab.raise(session.Question{ + ID: 61, Kind: session.QuestionAsk, Ask: session.AskChoice, + Asker: session.Asker{Kind: session.AskerModel}, Head: "Which store?", + Reason: "two fit", Stakes: session.StakesReversible, + Options: []session.AnswerOption{{Key: "1", Label: "sqlite"}, {Key: "2", Label: "postgres"}}, + }) + lab.tick(questionSettle) + lab.rows() + // The person is looking at the block rather than at the box, which is what + // lets the letter `c` reach it at all (questionkeys.go's THE BOX KEEPS THE + // FIRST LETTER). + aimed(lab.a) + if !lab.press(questionCommentKey) { + t.Fatal("c did not reach the block") + } + open := lab.a.questionHeld(lab.a.questions[0].token()) + if open == nil || !lab.a.questionOthering(*open) { + t.Fatal("c did not walk the pointer onto the something else… row") + } + open.other.words.setText(words) + return lab, open + } + + // THE CARET JUMPS, UNDER EVERY NAME A TERMINAL SENDS THEM BY. `meta+←/→` is + // what ⌘←/⌘→ arrives as on the arrow's own road; `super+←/→` is the same key + // from a terminal that spells the ninth modifier the other way — the split + // that left cmd+←/→ dead on every terminal until both names were bound + // (inputguard_test.go's TestTheCaretJumpsDecodeToTheNamesWeBindThemUnder). + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + from int + want int + }{ + {"cmd+←", tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModMeta}, len(draft), 0}, + {"cmd+→", tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModMeta}, 0, len(draft)}, + {"super+←", tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModSuper}, len(draft), 0}, + {"alt+←", tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModAlt}, len(draft), 16}, + {"alt+→", tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModAlt}, 0, 4}, + {"ctrl+←", tea.KeyPressMsg{Code: tea.KeyLeft, Mod: tea.ModCtrl}, len(draft), 16}, + // `ctrl+a` is the start of the line and `ctrl+e` its end, the same two the + // message box binds (input.go). + {"ctrl+a", tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl}, len(draft), 0}, + {"ctrl+e", tea.KeyPressMsg{Code: 'e', Mod: tea.ModCtrl}, 0, len(draft)}, + } { + lab, open := openBox(t, draft) + open.other.words.cursor = tc.from + lab.pressMsg(tc.msg) + if got := open.other.words.cursor; got != tc.want { + t.Fatalf("%s left the caret at %d in the answer box, want %d", tc.name, got, tc.want) + } + if open.other.words.String() != draft { + t.Fatalf("%s changed the words: %q", tc.name, open.other.words.String()) + } + if lab.a.input.String() != "" { + t.Fatalf("%s reached the message box instead of the answer box", tc.name) + } + } + + // THE KILLS. ⌘⌫ and `ctrl+u` kill to the start of the line; ⌥⌫ and ctrl+⌫ kill + // the word behind the caret. `ctrl+w` is deliberately not among them: it shuts + // the tab in front everywhere on this surface now (tabclosekey.go). + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + want string + }{ + {"ctrl+u", tea.KeyPressMsg{Code: 'u', Mod: tea.ModCtrl}, ""}, + {"cmd+⌫", tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModSuper}, ""}, + {"⌥⌫", tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModAlt}, "read the config "}, + {"ctrl+⌫", tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModCtrl}, "read the config "}, + } { + lab, open := openBox(t, draft) + open.other.words.end() + lab.pressMsg(tc.msg) + if got := open.other.words.String(); got != tc.want { + t.Fatalf("%s left %q in the answer box, want %q", tc.name, got, tc.want) + } + if lab.a.input.String() != "" { + t.Fatalf("%s deleted from the message box instead of the answer box", tc.name) + } + } + + // AND THE EDITED WORDS STILL TRAVEL AS THE ANSWER. Editing the row's own box + // has to leave the answer it carries intact — the whole reason the row is a + // box. + lab, open := openBox(t, draft) + open.other.words.end() + lab.pressMsg(tea.KeyPressMsg{Code: tea.KeyBackspace, Mod: tea.ModAlt}) + lab.press(questionEnterKey) + // The words travel TRIMMED, as the row always sent them; what matters here is + // that the edit reached the answer and the pointer's carry survived it. + if len(lab.answer) != 1 || lab.answer[0].Change != "read the config" || lab.answer[0].Key != "1" { + t.Fatalf("enter after editing the answer box sent %+v", lab.answer) + } +} + // questionPressOf spells one key the way bubbletea hands it over, so a test // presses what a terminal sends rather than what the routing happens to // compare against.