Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/changes/unreleased/1013-question-answer-box-chords.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions internal/manual/chat/questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
41 changes: 36 additions & 5 deletions internal/tui3/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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":
Expand Down
227 changes: 227 additions & 0 deletions internal/tui3/questionchords_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading