diff --git a/README.md b/README.md index 7024bc6..aea10b3 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,8 @@ own front-end settings are separate; see [Configuration](#configuration). | `^E` | Toggle details — reasoning previews and every step's full output/logs (hidden in the calm default) | | `^Y` | Copy the last reply to the clipboard (local helper — `pbcopy`/`wl-copy`/`clip` — with OSC 52 fallback) | | `Esc` | Close the topmost window or leave item inspection. Bare composer: dismiss details, then arm cancellation (`y` confirms). Approvals: fold details or leave confirmation editing; use `Alt+D` to deny. | -| `↑` / `↓` / `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript (arrows at the input's edge lines) | +| `^U` | Clear the whole input draft (`⇧⌦`/Shift+Delete works too on enhanced-key terminals — kitty CSI-u, modifyOtherKeys; elsewhere it degrades to plain single-char Delete) | +| `↑` / `↓` / `PgUp` / `PgDn` / `^D` | Scroll the transcript (arrows at the input's edge lines) | | `^P` / `^N` | Recall previous prompts (prompt history) | | `^G` / `End` (empty input) | Jump to the latest output | | `F1` | Show the help card | @@ -583,7 +584,7 @@ can encode them: | `Alt+PgUp` / `Alt+PgDn` | Page expanded approval details | | `Esc` | Return from confirmation editing, or fold details; otherwise arm turn cancellation when busy | | `^X` | Arm turn cancellation from any state; `y` confirms | -| `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript | +| `PgUp` / `PgDn` / `^U` / `^D` | Scroll the transcript (approval/clarify cards still own `^U` for paging) | After three same-class approvals inside a minute the server engages **friction mode**. The card shows the recent count and withdraws trust. diff --git a/internal/tui/clear_draft_test.go b/internal/tui/clear_draft_test.go new file mode 100644 index 0000000..00202e1 --- /dev/null +++ b/internal/tui/clear_draft_test.go @@ -0,0 +1,83 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── composer clear-draft binding (Ctrl+U, alias Shift+Delete) ────────────── +// Ctrl+U is the readline whole-line kill and reaches every terminal +// identically; Shift+Delete is accepted only where an enhanced-key chord +// decodes (kitty CSI-u / modifyOtherKeys), so degraded terminals degrade to +// plain Delete, never to a wrong whole-draft wipe. + +// TestCtrlUClearsDraft: ctrl+u wipes the whole multi-line composer draft and +// leaves history recall intact. +func TestCtrlUClearsDraft(t *testing.T) { + m := newTestModel() + m.ta.SetValue("line one\nline two tail") + _, cmd := m.Update(key("ctrl+u")) + execAll(cmd) + if got := m.ta.Value(); got != "" { + t.Fatalf("ctrl+u left draft %q, want empty", got) + } + // History recall still works after a clear (histDraft is untouched). + m.recordHistory("old prompt") + m.Update(key("ctrl+p")) + if got := m.ta.Value(); got != "old prompt" { + t.Errorf("history recall after ctrl+u clear = %q, want %q", got, "old prompt") + } +} + +// TestCtrlUIgnoredWhileApprovalHead: an approval card captures the keyboard — +// ctrl+u must not wipe the (empty) composer or leak into the card. +func TestCtrlUIgnoredWhileApprovalHead(t *testing.T) { + m, _, _ := approvalRecorder(t) + m.approvals = []client.Event{{Type: "approval", Tool: "shell"}} + m.ta.SetValue("draft under approval") + m.Update(key("ctrl+u")) + if got := m.ta.Value(); got != "draft under approval" { + t.Fatalf("ctrl+u cleared the draft under an approval card: %q", got) + } + if m.curApproval() == nil { + t.Fatal("approval card lost") + } +} + +// TestShiftDeleteAliasClearsDraft: the shift+delete sentinel chord clears the +// draft exactly like ctrl+u. +func TestShiftDeleteAliasClearsDraft(t *testing.T) { + m := newTestModel() + m.ta.SetValue("wipe me") + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+delete")}) + if got := m.ta.Value(); got != "" { + t.Fatalf("shift+delete left draft %q, want empty", got) + } +} + +// TestShiftDeleteDecodedFromEnhancedChords: kitty CSI-u (51;2u) and xterm +// modifyOtherKeys (27;2;51~) both decode to the shift+delete sentinel. +func TestShiftDeleteDecodedFromEnhancedChords(t *testing.T) { + for _, csi := range []string{"\x1b[3;2u", "\x1b[27;2;3~"} { + msg, ok := parseEnhancedKey(csi) + if !ok { + t.Fatalf("%q did not decode", csi) + } + if msg.String() != "shift+delete" { + t.Errorf("%q decoded to %q, want shift+delete", csi, msg.String()) + } + } + // Legacy plain-delete CSI 3~ is not an enhanced chord — Bubble Tea maps + // it natively to plain delete; it must never decode to the sentinel. + if msg, ok := parseEnhancedKey("\x1b[3~"); ok { + t.Errorf("plain delete CSI decoded to %q sentinel", msg.String()) + } + // Kitty encodes the digit '3' as code 51 — shift+3 stays the unmapped + // sentinel (keyboard-layout glyph), never a whole-draft wipe. + if msg, ok := parseEnhancedKey("\x1b[51;2u"); ok { + t.Errorf("shift+3 CSI decoded to %q, want unmapped", msg.String()) + } +} diff --git a/internal/tui/input.go b/internal/tui/input.go index f167455..696a88f 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -82,6 +82,17 @@ func (m *Model) insertNewline() tea.Cmd { return cmd } +// clearComposer wipes the whole multi-line draft — the ctrl+u / shift+delete +// whole-line kill. History recall (histDraft) is untouched, so ↑ after a +// clear still recalls the previous prompt. +func (m *Model) clearComposer() tea.Cmd { + var cmd tea.Cmd + m.ta.Reset() + m.syncComposer() + m.closeAC() + return tea.Batch(cmd, m.schedulePersist()) +} + // FilterShiftEnter rewrites terminal CSI that Bubble Tea v1 does not map: // Shift+Enter, disambiguated Esc, and modifyOtherKeys / kitty CSI-u // chords (so ^C, ^K, and esc still reach the model after we ask xterm.js @@ -250,6 +261,20 @@ func keyMsgFromCode(key, mod int) (tea.KeyMsg, bool) { return tea.KeyMsg{Type: tea.KeyEnter}, true case 27: return tea.KeyMsg{Type: tea.KeyEscape}, true + case 3: + // Delete uses its legacy CSI-~ code in kitty CSI-u and + // modifyOtherKeys. Shift+Delete (enhanced-key terminals only — a + // degraded terminal sends plain Delete, which stays single-char) + // is an alias of ctrl+u, the whole-draft clear. A rune sentinel + // like shift+enter so no capture surface mistakes it for typed + // text. Key code 51 is the digit '3', never Delete. + // ...Key code 3's C0 meaning (ETX → ctrl+c) stays: hosts encode + // ctrl+c here, and plain CSI 3u already reads as ctrl+c — only + // the explicit shift form becomes the sentinel. + if shift && !ctrl && !alt { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("shift+delete")}, true + } + return tea.KeyMsg{Type: tea.KeyType(key)}, true case 9: if shift { return tea.KeyMsg{Type: tea.KeyShiftTab}, true diff --git a/internal/tui/model.go b/internal/tui/model.go index 4914424..42ce0f0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1037,6 +1037,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "f1": m.showHelp() return m, nil + case "ctrl+u", "shift+delete": + // Whole-draft clear — readline's ^U, plus the enhanced-key alias + // (see keyMsgFromCode). Reached only when no capture surface + // (approval, clarify, panels) holds the keyboard, so the gates + // come for free from handleKey's ordering. + return m, m.clearComposer() case "ctrl+r": return m, m.openSessions() case "ctrl+o": @@ -1157,7 +1163,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.vp.GotoBottom() return m, nil } - case "pgup", "pgdown", "ctrl+u", "ctrl+d": + case "pgup", "pgdown", "ctrl+d": var cmd tea.Cmd m.vp, cmd = m.vp.Update(msg) m.relayout()