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
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1032-question-answer-box-edits.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down
5 changes: 5 additions & 0 deletions internal/manual/chat/questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
38 changes: 32 additions & 6 deletions internal/tui3/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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":
Expand Down
131 changes: 131 additions & 0 deletions internal/tui3/question_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
Loading