diff --git a/docs/changes/unreleased/1013-question-answer-box-chords.md b/docs/changes/unreleased/1013-question-answer-box-chords.md new file mode 100644 index 0000000000..43e0c9403b --- /dev/null +++ b/docs/changes/unreleased/1013-question-answer-box-chords.md @@ -0,0 +1,19 @@ +--- +kind: fixed +title: A question's own answer box edits like the message box under it +pr: 1013 +surface: [chat, docs] +invalidates: + - "In the `something else…` row — the box a question becomes when you type your own answer — only `alt+left`/`alt+right`, `home`, `end`, `ctrl+a` and `ctrl+e` reached the caret. ⌘←/⌘→ (as `super+left`/`meta+left`), ⌃←/⌃→ (`ctrl+left`/`ctrl+right`), the word kills `alt+backspace`/`ctrl+backspace` and the line kill `ctrl+u`/`super+backspace` were all silently dead there. They now move and kill exactly as they do in the message box, through the shared vocabulary in `internal/tui3/editkeys.go`." + - "The answer box read its own hand-rolled key map. It reads the shared `editorMotion`/`editorWordKill` vocabulary now, so `ctrl+a` still goes to the line start here (the map's spelling of it agrees with the row's own `home` case) and `ctrl+e` still goes to the line end rather than being claimed by the map — the same split `editkeys.go` draws for every other box." +--- + +`#1013`: the `something else…` row is a box, and it bound a short list of editing +keys by hand while every other box on the surface read the one vocabulary in +`editkeys.go`. `cmd+←/→` never reached the ends of the line, `cmd+delete` (or +`ctrl+u`) never killed to the line's start, and `opt+delete`/`ctrl+delete` never +deleted a word. It was the same defect that file was written for, said about a +box that had grown its own map. + +`ctrl+w` is deliberately still not the word kill here: it shuts the tab in front +everywhere on this surface, and the message box makes the same bargain. diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 33452c94df..13af37cc70 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -550,6 +550,13 @@ 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". +The box edits like the message box under it. `alt+left`/`alt+b`/`ctrl+left` and +`alt+right`/`alt+f`/`ctrl+right` jump a word; `super+left`/`meta+left` and +`super+right`/`meta+right` (⌘← and ⌘→) go to the ends of the line; `ctrl+u` or +`super+backspace` (⌘⌫) kills to the line's start; `alt+backspace` or +`ctrl+backspace` (⌥⌫) deletes the word behind the caret. `ctrl+w` stays the tab +close it is everywhere else. + **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..7c6bd891df 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -3177,13 +3177,41 @@ 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 AND LINE MOTIONS COME FROM THE ONE VOCABULARY (editkeys.go), which is +// what every OTHER box on this surface reads them from. Hand-binding them here +// is how this box came to answer only `alt+left`/`alt+right` and the two ends: +// `ctrl+left`, the `super`/`meta` spellings, and both word kills were silently +// dead in a question's own answer box, which is the exact defect editkeys.go was +// written for said about home, the errand pane and the filters. 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 MOTIONS AND THE WORD KILL FIRST, from the shared map, so a keyboard + // that moves the caret in the message box moves it here. + // + // THE LINE ENDS STAY BELOW. `home`, `end`, `ctrl+a` and `ctrl+e` are this + // box's own spelling of the two ends and have always been read here; the + // shared map's `ctrl+a` happens to agree with the `home` case below it, and + // `ctrl+e` it deliberately does not claim (editkeys.go). `ctrl+b`/`ctrl+f` + // are the plain caret motions and the shared map claims neither. + if editorMotion(box, key) { + a.touch() + return nil, true + } + // `ctrl+w` IS NOT HERE, ON PURPOSE. It shuts the tab in front everywhere on + // this surface (tabclosekey.go), and the word kill on a question's box is + // spelled by the two names a hand actually presses — `alt+backspace` and + // `ctrl+backspace` — exactly as the message box spells it (input.go). + if editorWordKill(box, key) { + a.touch() + return nil, true + } + switch key { case questionEnterKey: words := strings.TrimSpace(box.String()) if words == "" { @@ -3217,10 +3245,13 @@ func (a *app) questionOtherKey(head questionShown, msg tea.KeyPressMsg) (tea.Cmd box.home() case "end", "ctrl+e": box.end() - case "alt+left", "alt+b": - box.wordLeft() - case "alt+right", "alt+f": - box.wordRight() + case "ctrl+u", "super+backspace": + // KILL TO THE START OF THE LINE, under both of its names — the same + // pair the message box binds (input.go), so ⌃U and ⌘⌫ do here what a + // hand already knows them to do there. It is the line rather than the + // draft ([editor.killToStart]), which is the same promise the message + // box makes. + box.killToStart() case "backspace": box.deleteBackward() case "delete": diff --git a/internal/tui3/questionchords_test.go b/internal/tui3/questionchords_test.go new file mode 100644 index 0000000000..03d32267b0 --- /dev/null +++ b/internal/tui3/questionchords_test.go @@ -0,0 +1,227 @@ +package tui3 + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// ── THE QUESTION'S OWN ANSWER BOX TAKES THE STANDARD CHORDS ───────────────── +// +// THE DEFECT (#1013). The `something else…` row is a box — the one place on a +// question a person types an answer of their own — and [app.questionOtherKey] +// routed its keys by hand, binding only a short list. So `cmd+←`/`cmd+→` never +// reached the ends of the line, `cmd+delete` never killed to the line's start, +// and `opt+delete`/`ctrl+delete` never deleted a word: the chords worked in the +// message box under the answer and were silently dead IN the answer. That is the +// exact defect editkeys.go was written for, said about home, the errand pane and +// the filters — a box that learned its own key map and forgot the shared one. +// +// THE FIX IS THE SHARED VOCABULARY (editkeys.go), not a second copy of it: the +// motions go through [editorMotion] and the word kill through [editorWordKill], +// and kill-to-start is read beside them under the two names the message box +// already binds (`ctrl+u`, `super+backspace`). `ctrl+w` is deliberately still +// NOT the word kill here — it shuts the tab in front everywhere on this surface +// (tabclosekey.go), the same bargain the message box makes — so the word kill on +// this box is spelled by the two names a hand actually presses. + +// answerBoxLab is one question with the pointer standing on the +// `something else…` row, so that row is a box — settled, aimed at nothing, and +// with an empty composer under it, which is the law every letter here is held to +// ([app.questionKeyTaken]). +func answerBoxLab(t *testing.T) *questionLab { + t.Helper() + lab := newQuestionLab(t) + lab.raise(session.Question{ + ID: 9201, Kind: session.QuestionAsk, Ask: session.AskChoice, + Asker: session.Asker{Kind: session.AskerModel}, Head: "which store should it use?", + Reason: "two fit", Stakes: session.StakesReversible, + Options: []session.AnswerOption{{Key: "1", Label: "sqlite"}, {Key: "2", Label: "postgres"}}, + }) + lab.tick(questionSettle * 2) + lab.rows() + // MOVED ONTO THE ROW DIRECTLY. Which key walks the pointer here is the + // pointer's business and has its own tests; this file is about what the box + // the row becomes does with a chord once somebody is standing on it. + open := lab.a.questionHeld(lab.a.questions[0].token()) + open.pick = questionOtherAt(open.question) + lab.a.touch() + return lab +} + +// box is the answer box the pointer is on. +func (l *questionLab) box() *editor { + open := l.a.questionHeld(l.a.questions[0].token()) + if open == nil { + l.t.Fatal("the question is not open any more") + } + return &open.other.words +} + +// chord drives one key through the block's own router and reports whether the +// block took it — the same door a real keystroke goes through, command and all. +func (l *questionLab) chord(msg tea.KeyPressMsg) bool { + cmd, taken := l.a.questionKey(msg) + l.spend(cmd) + return taken +} + +// leftOf is what stands behind the caret. +func leftOf(e *editor) string { return string(e.value[:e.cursor]) } + +// rightOf is what stands in front of the caret. +func rightOf(e *editor) string { return string(e.value[e.cursor:]) } + +func TestTheQuestionAnswerBoxMovesTheCaretToTheEndsOfTheLine(t *testing.T) { + lab := answerBoxLab(t) + const sentence = "keep the sensors optional" + + // THE WORD BACK, under every name a terminal spells it with — the same three + // editkeys.go hands every other box, and the same three the message box + // reads. Before the fix only `alt+left` reached this box at all. + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"alt+left", arrowChord(tea.KeyLeft, tea.ModAlt)}, + {"alt+b", macKey('b', tea.ModAlt)}, + {"ctrl+left", arrowChord(tea.KeyLeft, tea.ModCtrl)}, + } { + e := lab.box() + e.setText(sentence) + if !lab.chord(tc.msg) { + t.Fatalf("%s was not taken by the question's own answer box", tc.name) + } + if got := leftOf(e); got != "keep the sensors " { + t.Fatalf("%s left %q behind the caret, want %q", tc.name, got, "keep the sensors ") + } + } + + // THE LINE START, under `cmd+←`'s two real spellings. super+ is what the + // kitty protocol reports and meta+ the CSI form — the same pair the message + // box binds, and the pair that was dead here. + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"super+left", arrowChord(tea.KeyLeft, tea.ModSuper)}, + {"meta+left", arrowChord(tea.KeyLeft, tea.ModMeta)}, + } { + e := lab.box() + e.setText(sentence) + if !lab.chord(tc.msg) { + t.Fatalf("%s did not reach the line start in the answer box", tc.name) + } + if e.cursor != 0 { + t.Fatalf("%s left the caret at %d, want the line start", tc.name, e.cursor) + } + } + + // THE WORD FORWARD, and then the line end. + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"alt+right", arrowChord(tea.KeyRight, tea.ModAlt)}, + {"ctrl+right", arrowChord(tea.KeyRight, tea.ModCtrl)}, + } { + e := lab.box() + e.setText(sentence) + e.cursor = len("keep ") + if !lab.chord(tc.msg) { + t.Fatalf("%s was not taken by the answer box", tc.name) + } + if got := rightOf(e); got != " sensors optional" { + t.Fatalf("%s left %q in front of the caret, want %q", tc.name, got, " sensors optional") + } + } + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"super+right", arrowChord(tea.KeyRight, tea.ModSuper)}, + {"meta+right", arrowChord(tea.KeyRight, tea.ModMeta)}, + } { + e := lab.box() + e.setText(sentence) + e.cursor = 0 + if !lab.chord(tc.msg) { + t.Fatalf("%s did not reach the line end in the answer box", tc.name) + } + if e.cursor != len(e.value) { + t.Fatalf("%s left the caret at %d, want the line end", tc.name, e.cursor) + } + } +} + +func TestTheQuestionAnswerBoxKillsToTheStartOfTheLineAndByTheWord(t *testing.T) { + lab := answerBoxLab(t) + + // KILL TO THE LINE'S START, under `cmd+delete`'s two names (`ctrl+u` and + // `super+backspace`), the same pair the message box binds (input.go). + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"ctrl+u", key("ctrl+u")}, + {"super+backspace", key("super+backspace")}, + } { + e := lab.box() + e.setText("keep the sensors optional") + if !lab.chord(tc.msg) { + t.Fatalf("%s did not kill to the start of the line in the answer box", tc.name) + } + if e.String() != "" { + t.Fatalf("%s left %q, want an empty line", tc.name, e.String()) + } + if e.cursor != 0 { + t.Fatalf("%s left the caret at %d, want the line start", tc.name, e.cursor) + } + } + + // IT IS THE LINE AND NOT THE DRAFT, which is the whole distinction + // [editor.killToStart] draws and the same promise the message box makes. + e := lab.box() + e.setText("keep the sensors\noptional extras") + if !lab.chord(key("ctrl+u")) { + t.Fatal("ctrl+u was not taken on the second line") + } + if e.String() != "keep the sensors\n" { + t.Fatalf("ctrl+u killed across the newline, leaving %q", e.String()) + } + + // THE WORD BEHIND THE CARET, under the two names that reach this box — + // `alt+backspace` (⌥⌫, which both macOS and every GTK/Qt field agree on) and + // `ctrl+backspace`. + for _, tc := range []struct { + name string + msg tea.KeyPressMsg + }{ + {"alt+backspace", key("alt+backspace")}, + {"ctrl+backspace", key("ctrl+backspace")}, + } { + e := lab.box() + e.setText("keep the sensors optional") + if !lab.chord(tc.msg) { + t.Fatalf("%s did not delete a word in the answer box", tc.name) + } + if got := e.String(); got != "keep the sensors " { + t.Fatalf("%s left %q, want %q", tc.name, got, "keep the sensors ") + } + } + + // AND `ctrl+w` IS STILL NOT THE WORD KILL, because it shuts the tab in front + // everywhere on this surface (tabclosekey.go) — the same bargain the message + // box makes, kept here so a question's answer box cannot steal a global + // chord. The box hands it back rather than eating it. + e = lab.box() + e.setText("keep the sensors optional") + if lab.chord(key("ctrl+w")) { + t.Fatal("the answer box claimed ctrl+w, which belongs to the tab strip") + } + if got := e.String(); got != "keep the sensors optional" { + t.Fatalf("ctrl+w edited the answer box: %q", got) + } +}