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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions internal/tui/clear_draft_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
25 changes: 25 additions & 0 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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()
Expand Down