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) } } 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)) + } + }) + } +}