Skip to content

Commit 86e6c2d

Browse files
authored
fix(tui): strip fragmented wheel reports and make friction approvals macOS-usable (#115)
* fix(tui): strip fragmented wheel reports carrying Alt or missing digits 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. * fix(tui): make friction-mode approvals usable without Alt chords 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.
1 parent ac9a1cc commit 86e6c2d

8 files changed

Lines changed: 195 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@ own front-end settings are separate; see [Configuration](#configuration).
307307
newlines) and `⏎` sends the answer. The form wraps by cell width and
308308
keeps a capped tail so a long paste cannot blow the layout; `Esc`
309309
while a turn is running still arms cancel.
310-
- **Friction & expiry** — repeated same-class approvals require `Alt+A`
311-
to focus confirmation, then typing `approve` and pressing Enter; every request is time-boxed, autocloses on expiry (focus
310+
- **Friction & expiry** — repeated same-class approvals require `a`
311+
(or `Alt+A`) to focus confirmation, then typing `approve` and pressing Enter; every request is time-boxed, autocloses on expiry (focus
312312
returns to the latest transcript message), and can never collect an
313313
approval for a prompt the engine already abandoned.
314314
- **Death-gates everywhere** — deletes are two-step, `/stop` and `^L` are
@@ -589,8 +589,8 @@ can encode them:
589589

590590
After three same-class approvals inside a minute the server engages
591591
**friction mode**. The card shows the recent count and withdraws trust.
592-
Press `Alt+A`, type the literal word `approve`, then press Enter. Escape returns
593-
to the draft without deciding; `Alt+D` denies immediately. The confirmation
592+
Press `a` (or `Alt+A`) on an empty composer, type the literal word `approve`, then press Enter. Escape returns
593+
to the draft without deciding; `d` (or `Alt+D`) denies immediately. The confirmation
594594
editor and expanded command pages are bounded to fit short terminals.
595595

596596
Approvals are time-boxed by the engine (60s by default), and an expired

‎internal/tui/approval.go‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,14 @@ func (m *Model) handleApprovalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
114114
// frictionWord is the literal confirmation the friction gate demands.
115115
const frictionWord = "approve"
116116

117-
// handleFrictionKey either keeps the normal composer active or, after Alt+A,
118-
// edits the literal confirmation. Enter approves only on an exact match. Esc
119-
// leaves confirmation editing without deciding; Alt+D remains an immediate
120-
// denial in either state.
117+
// handleFrictionKey either keeps the normal composer active or, after Alt+A
118+
// or a bare 'a' on an empty draft, edits the literal confirmation. Enter
119+
// approves only on an exact match. Esc leaves confirmation editing without
120+
// deciding; Alt+D and a bare 'd' (empty draft) remain immediate denials —
121+
// the friction gate slows approving, never blocking. Plain keys reuse the
122+
// empty-draft guard of the normal approval path so terminals that cannot
123+
// deliver Alt chords (macOS Option-as-UTF-8) stay usable; opening the editor
124+
// with 'a' never approves by itself.
121125
func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
122126
if approvalAltRune(msg, 'd') {
123127
return m, m.answer("deny")
@@ -130,6 +134,20 @@ func (m *Model) handleFrictionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
130134
m.refresh()
131135
return m, nil
132136
}
137+
if action, ok := m.plainApprovalAction(msg); ok {
138+
switch action {
139+
case "approve":
140+
// The friction gate's whole point: a plain 'a' only opens
141+
// the confirmation editor; the typed word still decides.
142+
m.apprEditing = true
143+
m.apprTyped = ""
144+
m.relayout()
145+
m.refresh()
146+
return m, nil
147+
case "deny":
148+
return m, m.answer("deny")
149+
}
150+
}
133151
if approvalAltRune(msg, 't') {
134152
return m, nil
135153
}
@@ -278,7 +296,7 @@ func (m *Model) frictionHint() string {
278296
}
279297
if !m.apprEditing {
280298
return m.th.noticeStyle.Render(fmt.Sprintf(
281-
"⏳ friction: %d approvals in the last 60s — Alt+A to confirm · Alt+D denies",
299+
"⏳ friction: %d approvals in the last 60s — 'a' (or Alt+A) to confirm · 'd'/Alt+D denies · plain keys need an empty composer",
282300
n))
283301
}
284302
return m.th.noticeStyle.Render(fmt.Sprintf(

‎internal/tui/approval_plainkeys_test.go‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ func TestApprovalPlainKeysNeedEmptyComposer(t *testing.T) {
8080
}
8181

8282
// TestApprovalPlainKeysInertDuringFriction verifies the friction gate still
83-
// demands the literal word — plain letters type into the editor/composer.
83+
// demands the literal word — a plain 'a' opens the editor but never approves,
84+
// and pasted letters land in the composer instead.
8485
func TestApprovalPlainKeysInertDuringFriction(t *testing.T) {
8586
m := newTestModel()
8687
busyTurn(m)
@@ -89,8 +90,14 @@ func TestApprovalPlainKeysInertDuringFriction(t *testing.T) {
8990
if m.curApproval() == nil {
9091
t.Fatal("plain a must not approve under friction")
9192
}
92-
if m.apprEditing {
93-
t.Fatal("plain a must not open the friction editor")
93+
if !m.apprEditing {
94+
t.Fatal("plain a should open the friction editor (macOS has no Alt chords)")
95+
}
96+
// Paste must never activate or decide anything.
97+
m.apprEditing = false
98+
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a"), Paste: true})
99+
if m.apprEditing || m.curApproval() == nil {
100+
t.Fatal("pasted 'a' must not open the friction editor")
94101
}
95102
}
96103

‎internal/tui/approval_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ func TestFrictionAltActivationAndLiteralConfirmation(t *testing.T) {
172172
busyTurn(m)
173173
m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Friction: true, FrictionApprovals: 3})
174174
m.Update(key("a"))
175-
if m.apprEditing || m.apprTyped != "" || m.ta.Value() != "a" {
176-
t.Fatalf("bare text should stay in composer before activation: editing=%v typed=%q draft=%q", m.apprEditing, m.apprTyped, m.ta.Value())
175+
if !m.apprEditing || m.apprTyped != "" || m.ta.Value() != "" {
176+
t.Fatalf("bare 'a' should open a fresh friction editor: editing=%v typed=%q draft=%q", m.apprEditing, m.apprTyped, m.ta.Value())
177177
}
178178
m.Update(key("alt+a"))
179179
if !m.apprEditing || m.apprTyped != "" {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package tui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/BackendStack21/bodek/internal/client"
7+
)
8+
9+
// frictionModel builds a recorder model with a friction-gated approval as
10+
// the queue head — the approval-fatigue state where Alt chords were the only
11+
// path, which macOS Option-as-UTF-8 terminals cannot deliver.
12+
func frictionModel(t *testing.T) (*Model, chan string) {
13+
t.Helper()
14+
m, actions, _ := approvalRecorder(t)
15+
busyTurn(m)
16+
m.handleEvent(client.Event{Type: "approval_request", ID: "apr", Friction: true, FrictionApprovals: 3})
17+
return m, actions
18+
}
19+
20+
// TestFrictionPlainKeysWorkWithoutAlt verifies the friction gate is usable
21+
// without Alt chords: a bare 'a' opens the confirmation editor (it must NOT
22+
// approve — the friction gate exists to slow approving), and a bare 'd'
23+
// denies. Both only fire on an empty composer draft.
24+
func TestFrictionPlainKeysWorkWithoutAlt(t *testing.T) {
25+
t.Run("bare a opens editor without approving", func(t *testing.T) {
26+
m, actions := frictionModel(t)
27+
m.Update(key("a"))
28+
if !m.apprEditing {
29+
t.Fatal("bare 'a' did not open the friction confirmation editor")
30+
}
31+
if m.curApproval() == nil {
32+
t.Fatal("bare 'a' must not answer the approval")
33+
}
34+
select {
35+
case got := <-actions:
36+
t.Fatalf("bare 'a' sent action %q — friction must require the typed word", got)
37+
default:
38+
}
39+
})
40+
41+
t.Run("bare d denies", func(t *testing.T) {
42+
m, actions := frictionModel(t)
43+
_, cmd := m.Update(key("d"))
44+
exec(cmd)
45+
if got := awaitAction(t, actions); got != "deny" {
46+
t.Fatalf("bare 'd' action = %q, want deny", got)
47+
}
48+
})
49+
50+
t.Run("draft guards plain keys", func(t *testing.T) {
51+
m, _ := frictionModel(t)
52+
m.ta.SetValue("draft")
53+
m.Update(key("d"))
54+
if m.curApproval() == nil {
55+
t.Fatal("bare 'd' denied while the composer held a draft")
56+
}
57+
if m.ta.Value() != "draftd" {
58+
t.Fatalf("composer draft = %q, want the letter typed", m.ta.Value())
59+
}
60+
})
61+
62+
t.Run("editor still requires the word", func(t *testing.T) {
63+
m, actions := frictionModel(t)
64+
m.Update(key("a"))
65+
m.Update(key("approve"))
66+
_, cmd := m.Update(key("enter"))
67+
exec(cmd)
68+
if got := awaitAction(t, actions); got != "approve" {
69+
t.Fatalf("typed word action = %q, want approve", got)
70+
}
71+
})
72+
}

‎internal/tui/gaps_test.go‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,17 +71,18 @@ func TestApprovalQueueFIFO(t *testing.T) {
7171
if m.apprTyped != "" || m.apprSel != 0 {
7272
t.Error("input state not reset for the new head")
7373
}
74-
// A friction request keeps ordinary letters in the composer until Alt+A
75-
// explicitly activates its confirmation editor.
74+
// A friction request answers plain keys: 'd' denies immediately, and
75+
// 'a' opens the confirmation editor without approving.
7676
_, cmd = m.Update(key("alt+d")) // deny apr-2 → queue drains
7777
exec(cmd)
7878
m.handleEvent(client.Event{Type: "approval_request", ID: "apr-3", Friction: true, FrictionApprovals: 3})
79-
m.Update(key("d"))
80-
if m.apprTyped != "" || m.ta.Value() != "d" {
81-
t.Fatalf("friction head should keep ordinary typing in composer: typed=%q draft=%q", m.apprTyped, m.ta.Value())
79+
_, cmd = m.Update(key("d"))
80+
exec(cmd)
81+
if got := awaitAction(t, actions); got != "deny" {
82+
t.Fatalf("plain 'd' on friction head = %q, want deny", got)
8283
}
83-
if len(m.approvals) != 1 {
84-
t.Fatal("letter decided a friction approval")
84+
if len(m.approvals) != 0 {
85+
t.Fatalf("plain 'd' did not drain the queue: %+v", m.approvals)
8586
}
8687
}
8788

‎internal/tui/input.go‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,38 @@ func FilterShiftEnter(_ tea.Model, msg tea.Msg) tea.Msg {
118118
return msg
119119
}
120120

121-
// Some terminal stacks split an SGR mouse report after ESC. Bubble Tea then
122-
// delivers the printable tail as text, which would otherwise land in the
123-
// composer. Remove only complete report tails from non-paste rune messages;
124-
// adjacent typed text and deliberate pasted text remain intact.
125-
var leakedMouseReportRe = regexp.MustCompile(`(?:\[<\d{1,3};\d{1,5};\d{1,5}[Mm])|(?:^(?:\d{1,3};\d{1,5};\d{1,5}[Mm])+)`)
121+
// Some terminal stacks split an SGR mouse report after ESC — a fast wheel
122+
// burst splits mid-report and after the head's digits, so what reaches the
123+
// model are tails like ";1;1M" or "64;5;13M", plain or carrying the Alt bit
124+
// from the consumed ESC head. Bubble Tea delivers those as text, which would
125+
// otherwise land in the composer. Remove only complete mouse-shaped tails
126+
// from non-paste rune messages; adjacent typed text and deliberate pasted
127+
// text remain intact (the unbracketed forms must cover the whole message,
128+
// so typed text with a similar shape is never rewritten).
129+
var (
130+
bracketedMouseReportRe = regexp.MustCompile(`\[<\d{1,3};\d{1,5};\d{1,5}[Mm]`)
131+
bareMouseReportRe = regexp.MustCompile(`\A(?:\d{0,3};\d{1,5};\d{1,5}[Mm])+\z`)
132+
)
126133

127134
func stripLeakedMouseReports(msg tea.KeyMsg) (tea.KeyMsg, bool) {
128-
if msg.Type != tea.KeyRunes || msg.Alt || msg.Paste || len(msg.Runes) == 0 {
135+
if msg.Type != tea.KeyRunes || msg.Paste || len(msg.Runes) == 0 {
129136
return msg, false
130137
}
138+
// Bracketed forms are never typed mid-text; strip them wherever they
139+
// appear. What remains must be entirely bare report tails — a fragment
140+
// sharing space with anything else (typed text) is left alone.
131141
before := string(msg.Runes)
132-
after := leakedMouseReportRe.ReplaceAllString(before, "")
133-
if after == before {
134-
return msg, false
142+
rest := bracketedMouseReportRe.ReplaceAllString(before, "")
143+
if rest == before {
144+
// Nothing bracketed: the whole message must be bare tails to strip.
145+
if rest == "" || !bareMouseReportRe.MatchString(rest) {
146+
return msg, false
147+
}
148+
rest = ""
149+
} else if rest != "" && bareMouseReportRe.MatchString(rest) {
150+
rest = "" // bracketed tails plus bare remainder — all report
135151
}
136-
msg.Runes = []rune(after)
152+
msg.Runes = []rune(rest)
137153
return msg, true
138154
}
139155

‎internal/tui/wheel_leak_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
)
9+
10+
// TestWheelBurstNeverTypesIntoComposer reproduces the Logitech wheel report:
11+
// a fast wheel burst split by the terminal arrives as partial report tails —
12+
// including Alt-prefixed heads (ESC consumed as alt+[) and tails missing
13+
// their button digits — and splices codes like ";1;1M" repeatedly into the
14+
// composer. None of these shapes may reach the draft.
15+
func TestWheelBurstNeverTypesIntoComposer(t *testing.T) {
16+
for _, tc := range []struct {
17+
name string
18+
msg tea.KeyMsg
19+
wantN int // runes that must survive (0 = fully stripped)
20+
}{
21+
{"alt head tail", tea.KeyMsg{Type: tea.KeyRunes, Alt: true, Runes: []rune(";1;1M")}, 0},
22+
{"plain partial tail", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";5;13M")}, 0},
23+
{"repeated tails", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("64;5;13M65;5;13M66;5;13M")}, 0},
24+
{"bracketed mid-string", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello[<64;5;13M")}, 5},
25+
{"typed text survives", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("M;1;1 hello 1M")}, -1},
26+
} {
27+
t.Run(tc.name, func(t *testing.T) {
28+
out := FilterShiftEnter(nil, tc.msg)
29+
if out == nil {
30+
if tc.wantN == 0 {
31+
return
32+
}
33+
t.Fatalf("fully stripped, want %d runes to survive", tc.wantN)
34+
}
35+
km, ok := out.(tea.KeyMsg)
36+
if !ok {
37+
t.Fatalf("unexpected message type %T", out)
38+
}
39+
if tc.wantN == 0 {
40+
t.Fatalf("mouse-shaped runes leaked into a key message: %q", string(km.Runes))
41+
}
42+
if tc.wantN >= 0 && len(km.Runes) != tc.wantN {
43+
t.Fatalf("kept %d runes %q, want %d", len(km.Runes), string(km.Runes), tc.wantN)
44+
}
45+
if tc.wantN < 0 && !strings.Contains(string(km.Runes), "M;1;1 hello 1M") {
46+
t.Fatalf("typed text mangled: %q", string(km.Runes))
47+
}
48+
})
49+
}
50+
}

0 commit comments

Comments
 (0)