From 39eb13f3b8c9d56ddf373fbd611d80ab05ff12d1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 12 Sep 2026 15:52:17 +0200 Subject: [PATCH 1/2] fix(tui): strip fragmented wheel reports carrying Alt or missing digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fast wheel burst (Logitech) splits SGR mouse reports mid-sequence; the tails that reach the model are bare report fragments like ';1;1M' or '64;5;13M', plain or carrying the Alt bit from the consumed ESC head. The previous filter refused Alt messages and required the leading button digits, so those shapes spliced into the composer as repeating codes. The strip now drops bracketed reports wherever they appear, accepts an empty button field, honors the Alt bit, and clears any remainder that is entirely bare report tails — typed text coexisting with a stripped fragment is preserved. --- internal/tui/input.go | 36 +++++++++++++++++------- internal/tui/wheel_leak_test.go | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 internal/tui/wheel_leak_test.go diff --git a/internal/tui/input.go b/internal/tui/input.go index fefd017..f167455 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -118,22 +118,38 @@ func FilterShiftEnter(_ tea.Model, msg tea.Msg) tea.Msg { return msg } -// Some terminal stacks split an SGR mouse report after ESC. Bubble Tea then -// delivers the printable tail as text, which would otherwise land in the -// composer. Remove only complete report tails from non-paste rune messages; -// adjacent typed text and deliberate pasted text remain intact. -var leakedMouseReportRe = regexp.MustCompile(`(?:\[<\d{1,3};\d{1,5};\d{1,5}[Mm])|(?:^(?:\d{1,3};\d{1,5};\d{1,5}[Mm])+)`) +// Some terminal stacks split an SGR mouse report after ESC — a fast wheel +// burst splits mid-report and after the head's digits, so what reaches the +// model are tails like ";1;1M" or "64;5;13M", plain or carrying the Alt bit +// from the consumed ESC head. Bubble Tea delivers those as text, which would +// otherwise land in the composer. Remove only complete mouse-shaped tails +// from non-paste rune messages; adjacent typed text and deliberate pasted +// text remain intact (the unbracketed forms must cover the whole message, +// so typed text with a similar shape is never rewritten). +var ( + bracketedMouseReportRe = regexp.MustCompile(`\[<\d{1,3};\d{1,5};\d{1,5}[Mm]`) + bareMouseReportRe = regexp.MustCompile(`\A(?:\d{0,3};\d{1,5};\d{1,5}[Mm])+\z`) +) func stripLeakedMouseReports(msg tea.KeyMsg) (tea.KeyMsg, bool) { - if msg.Type != tea.KeyRunes || msg.Alt || msg.Paste || len(msg.Runes) == 0 { + if msg.Type != tea.KeyRunes || msg.Paste || len(msg.Runes) == 0 { return msg, false } + // Bracketed forms are never typed mid-text; strip them wherever they + // appear. What remains must be entirely bare report tails — a fragment + // sharing space with anything else (typed text) is left alone. before := string(msg.Runes) - after := leakedMouseReportRe.ReplaceAllString(before, "") - if after == before { - return msg, false + rest := bracketedMouseReportRe.ReplaceAllString(before, "") + if rest == before { + // Nothing bracketed: the whole message must be bare tails to strip. + if rest == "" || !bareMouseReportRe.MatchString(rest) { + return msg, false + } + rest = "" + } else if rest != "" && bareMouseReportRe.MatchString(rest) { + rest = "" // bracketed tails plus bare remainder — all report } - msg.Runes = []rune(after) + msg.Runes = []rune(rest) return msg, true } diff --git a/internal/tui/wheel_leak_test.go b/internal/tui/wheel_leak_test.go new file mode 100644 index 0000000..fb0b60b --- /dev/null +++ b/internal/tui/wheel_leak_test.go @@ -0,0 +1,50 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// TestWheelBurstNeverTypesIntoComposer reproduces the Logitech wheel report: +// a fast wheel burst split by the terminal arrives as partial report tails — +// including Alt-prefixed heads (ESC consumed as alt+[) and tails missing +// their button digits — and splices codes like ";1;1M" repeatedly into the +// composer. None of these shapes may reach the draft. +func TestWheelBurstNeverTypesIntoComposer(t *testing.T) { + for _, tc := range []struct { + name string + msg tea.KeyMsg + wantN int // runes that must survive (0 = fully stripped) + }{ + {"alt head tail", tea.KeyMsg{Type: tea.KeyRunes, Alt: true, Runes: []rune(";1;1M")}, 0}, + {"plain partial tail", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";5;13M")}, 0}, + {"repeated tails", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("64;5;13M65;5;13M66;5;13M")}, 0}, + {"bracketed mid-string", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello[<64;5;13M")}, 5}, + {"typed text survives", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("M;1;1 hello 1M")}, -1}, + } { + t.Run(tc.name, func(t *testing.T) { + out := FilterShiftEnter(nil, tc.msg) + if out == nil { + if tc.wantN == 0 { + return + } + t.Fatalf("fully stripped, want %d runes to survive", tc.wantN) + } + km, ok := out.(tea.KeyMsg) + if !ok { + t.Fatalf("unexpected message type %T", out) + } + if tc.wantN == 0 { + t.Fatalf("mouse-shaped runes leaked into a key message: %q", string(km.Runes)) + } + if tc.wantN >= 0 && len(km.Runes) != tc.wantN { + t.Fatalf("kept %d runes %q, want %d", len(km.Runes), string(km.Runes), tc.wantN) + } + if tc.wantN < 0 && !strings.Contains(string(km.Runes), "M;1;1 hello 1M") { + t.Fatalf("typed text mangled: %q", string(km.Runes)) + } + }) + } +} From e316e7062b14f795bc60f8a856442794f5839c9f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 12 Sep 2026 16:00:42 +0200 Subject: [PATCH 2/2] fix(tui): make friction-mode approvals usable without Alt chords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval fatigue gate required Alt+A / Alt+D, which macOS Option-as-UTF-8 terminals cannot deliver. The friction handler now routes plain keys through the empty-draft guard of the normal approval path: a bare 'a' opens the confirmation editor (never approves — the typed word still decides), a bare 'd' denies. Alt chords unchanged, paste still inert, trust stays withdrawn under friction. Hint strings and README document the plain keys. --- README.md | 8 +-- internal/tui/approval.go | 28 ++++++++-- internal/tui/approval_plainkeys_test.go | 13 +++-- internal/tui/approval_test.go | 4 +- internal/tui/friction_plainkeys_test.go | 72 +++++++++++++++++++++++++ internal/tui/gaps_test.go | 15 +++--- 6 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 internal/tui/friction_plainkeys_test.go diff --git a/README.md b/README.md index cf4e3dc..5dae7fb 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,8 @@ own front-end settings are separate; see [Configuration](#configuration). newlines) and `⏎` sends the answer. The form wraps by cell width and keeps a capped tail so a long paste cannot blow the layout; `Esc` while a turn is running still arms cancel. -- **Friction & expiry** — repeated same-class approvals require `Alt+A` - to focus confirmation, then typing `approve` and pressing Enter; every request is time-boxed, autocloses on expiry (focus +- **Friction & expiry** — repeated same-class approvals require `a` + (or `Alt+A`) to focus confirmation, then typing `approve` and pressing Enter; every request is time-boxed, autocloses on expiry (focus returns to the latest transcript message), and can never collect an approval for a prompt the engine already abandoned. - **Death-gates everywhere** — deletes are two-step, `/stop` and `^L` are @@ -589,8 +589,8 @@ can encode them: After three same-class approvals inside a minute the server engages **friction mode**. The card shows the recent count and withdraws trust. -Press `Alt+A`, type the literal word `approve`, then press Enter. Escape returns -to the draft without deciding; `Alt+D` denies immediately. The confirmation +Press `a` (or `Alt+A`) on an empty composer, type the literal word `approve`, then press Enter. Escape returns +to the draft without deciding; `d` (or `Alt+D`) denies immediately. The confirmation editor and expanded command pages are bounded to fit short terminals. Approvals are time-boxed by the engine (60s by default), and an expired diff --git a/internal/tui/approval.go b/internal/tui/approval.go index 63a734c..06ea899 100644 --- a/internal/tui/approval.go +++ b/internal/tui/approval.go @@ -114,10 +114,14 @@ func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // frictionWord is the literal confirmation the friction gate demands. const frictionWord = "approve" -// handleFrictionKey either keeps the normal composer active or, after Alt+A, -// edits the literal confirmation. Enter approves only on an exact match. Esc -// leaves confirmation editing without deciding; Alt+D remains an immediate -// denial in either state. +// handleFrictionKey either keeps the normal composer active or, after Alt+A +// or a bare 'a' on an empty draft, edits the literal confirmation. Enter +// approves only on an exact match. Esc leaves confirmation editing without +// deciding; Alt+D and a bare 'd' (empty draft) remain immediate denials — +// the friction gate slows approving, never blocking. Plain keys reuse the +// empty-draft guard of the normal approval path so terminals that cannot +// deliver Alt chords (macOS Option-as-UTF-8) stay usable; opening the editor +// with 'a' never approves by itself. func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if approvalAltRune(msg, 'd') { return m, m.answer("deny") @@ -130,6 +134,20 @@ func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() return m, nil } + if action, ok := m.plainApprovalAction(msg); ok { + switch action { + case "approve": + // The friction gate's whole point: a plain 'a' only opens + // the confirmation editor; the typed word still decides. + m.apprEditing = true + m.apprTyped = "" + m.relayout() + m.refresh() + return m, nil + case "deny": + return m, m.answer("deny") + } + } if approvalAltRune(msg, 't') { return m, nil } @@ -278,7 +296,7 @@ func (m *Model) frictionHint() string { } if !m.apprEditing { return m.th.noticeStyle.Render(fmt.Sprintf( - "⏳ friction: %d approvals in the last 60s — Alt+A to confirm · Alt+D denies", + "⏳ friction: %d approvals in the last 60s — 'a' (or Alt+A) to confirm · 'd'/Alt+D denies · plain keys need an empty composer", n)) } return m.th.noticeStyle.Render(fmt.Sprintf( diff --git a/internal/tui/approval_plainkeys_test.go b/internal/tui/approval_plainkeys_test.go index 83fbb4c..02d864b 100644 --- a/internal/tui/approval_plainkeys_test.go +++ b/internal/tui/approval_plainkeys_test.go @@ -80,7 +80,8 @@ func TestApprovalPlainKeysNeedEmptyComposer(t *testing.T) { } // TestApprovalPlainKeysInertDuringFriction verifies the friction gate still -// demands the literal word — plain letters type into the editor/composer. +// demands the literal word — a plain 'a' opens the editor but never approves, +// and pasted letters land in the composer instead. func TestApprovalPlainKeysInertDuringFriction(t *testing.T) { m := newTestModel() busyTurn(m) @@ -89,8 +90,14 @@ func TestApprovalPlainKeysInertDuringFriction(t *testing.T) { if m.curApproval() == nil { t.Fatal("plain a must not approve under friction") } - if m.apprEditing { - t.Fatal("plain a must not open the friction editor") + if !m.apprEditing { + t.Fatal("plain a should open the friction editor (macOS has no Alt chords)") + } + // Paste must never activate or decide anything. + m.apprEditing = false + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Paste: true}) + if m.apprEditing || m.curApproval() == nil { + t.Fatal("pasted 'a' must not open the friction editor") } } diff --git a/internal/tui/approval_test.go b/internal/tui/approval_test.go index 7f13653..e1c9a4d 100644 --- a/internal/tui/approval_test.go +++ b/internal/tui/approval_test.go @@ -172,8 +172,8 @@ func TestFrictionAltActivationAndLiteralConfirmation(t *testing.T) { busyTurn(m) m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Friction: true, FrictionApprovals: 3}) m.Update(key("a")) - if m.apprEditing || m.apprTyped != "" || m.ta.Value() != "a" { - t.Fatalf("bare text should stay in composer before activation: editing=%v typed=%q draft=%q", m.apprEditing, m.apprTyped, m.ta.Value()) + if !m.apprEditing || m.apprTyped != "" || m.ta.Value() != "" { + t.Fatalf("bare 'a' should open a fresh friction editor: editing=%v typed=%q draft=%q", m.apprEditing, m.apprTyped, m.ta.Value()) } m.Update(key("alt+a")) if !m.apprEditing || m.apprTyped != "" { diff --git a/internal/tui/friction_plainkeys_test.go b/internal/tui/friction_plainkeys_test.go new file mode 100644 index 0000000..27255a5 --- /dev/null +++ b/internal/tui/friction_plainkeys_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// frictionModel builds a recorder model with a friction-gated approval as +// the queue head — the approval-fatigue state where Alt chords were the only +// path, which macOS Option-as-UTF-8 terminals cannot deliver. +func frictionModel(t *testing.T) (*Model, chan string) { + t.Helper() + m, actions, _ := approvalRecorder(t) + busyTurn(m) + m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Friction: true, FrictionApprovals: 3}) + return m, actions +} + +// TestFrictionPlainKeysWorkWithoutAlt verifies the friction gate is usable +// without Alt chords: a bare 'a' opens the confirmation editor (it must NOT +// approve — the friction gate exists to slow approving), and a bare 'd' +// denies. Both only fire on an empty composer draft. +func TestFrictionPlainKeysWorkWithoutAlt(t *testing.T) { + t.Run("bare a opens editor without approving", func(t *testing.T) { + m, actions := frictionModel(t) + m.Update(key("a")) + if !m.apprEditing { + t.Fatal("bare 'a' did not open the friction confirmation editor") + } + if m.curApproval() == nil { + t.Fatal("bare 'a' must not answer the approval") + } + select { + case got := <-actions: + t.Fatalf("bare 'a' sent action %q — friction must require the typed word", got) + default: + } + }) + + t.Run("bare d denies", func(t *testing.T) { + m, actions := frictionModel(t) + _, cmd := m.Update(key("d")) + exec(cmd) + if got := awaitAction(t, actions); got != "deny" { + t.Fatalf("bare 'd' action = %q, want deny", got) + } + }) + + t.Run("draft guards plain keys", func(t *testing.T) { + m, _ := frictionModel(t) + m.ta.SetValue("draft") + m.Update(key("d")) + if m.curApproval() == nil { + t.Fatal("bare 'd' denied while the composer held a draft") + } + if m.ta.Value() != "draftd" { + t.Fatalf("composer draft = %q, want the letter typed", m.ta.Value()) + } + }) + + t.Run("editor still requires the word", func(t *testing.T) { + m, actions := frictionModel(t) + m.Update(key("a")) + m.Update(key("approve")) + _, cmd := m.Update(key("enter")) + exec(cmd) + if got := awaitAction(t, actions); got != "approve" { + t.Fatalf("typed word action = %q, want approve", got) + } + }) +} diff --git a/internal/tui/gaps_test.go b/internal/tui/gaps_test.go index 0fc6788..d7639be 100644 --- a/internal/tui/gaps_test.go +++ b/internal/tui/gaps_test.go @@ -71,17 +71,18 @@ func TestApprovalQueueFIFO(t *testing.T) { if m.apprTyped != "" || m.apprSel != 0 { t.Error("input state not reset for the new head") } - // A friction request keeps ordinary letters in the composer until Alt+A - // explicitly activates its confirmation editor. + // A friction request answers plain keys: 'd' denies immediately, and + // 'a' opens the confirmation editor without approving. _, cmd = m.Update(key("alt+d")) // deny apr-2 → queue drains exec(cmd) m.handleEvent(client.Event{Type: "approval_request", ID: "apr-3", Friction: true, FrictionApprovals: 3}) - m.Update(key("d")) - if m.apprTyped != "" || m.ta.Value() != "d" { - t.Fatalf("friction head should keep ordinary typing in composer: typed=%q draft=%q", m.apprTyped, m.ta.Value()) + _, cmd = m.Update(key("d")) + exec(cmd) + if got := awaitAction(t, actions); got != "deny" { + t.Fatalf("plain 'd' on friction head = %q, want deny", got) } - if len(m.approvals) != 1 { - t.Fatal("letter decided a friction approval") + if len(m.approvals) != 0 { + t.Fatalf("plain 'd' did not drain the queue: %+v", m.approvals) } }