Skip to content
Merged
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions internal/tui/approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 10 additions & 3 deletions internal/tui/approval_plainkeys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/tui/approval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
72 changes: 72 additions & 0 deletions internal/tui/friction_plainkeys_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
15 changes: 8 additions & 7 deletions internal/tui/gaps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
36 changes: 26 additions & 10 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
50 changes: 50 additions & 0 deletions internal/tui/wheel_leak_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
})
}
}