From 70c141fec050d15edbc52a9642a79d152c417041 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Thu, 11 Jun 2026 07:58:07 +0000 Subject: [PATCH] fix(attach): re-arm the left-arrow quick detach when the draft is deleted The quick-detach heuristic tracked the agent's input box with a one-way boolean: any printable marked it non-empty and only Enter/Esc/Ctrl+C/ Ctrl+U reset it - backspace never did (worse, DEL is 0x7f, which the b >= 0x20 check itself counted as typing). Typing something and deleting it left the bare left arrow moving the cursor instead of detaching. Track the box with a rune counter plus an unknown latch: printables increment (one per rune - UTF-8 continuation bytes don't count), backspace (DEL and Ctrl+H) decrements with a floor at zero, and a count of zero re-arms the quick detach. Input whose effect can't be counted - tab completion, history/menu navigation, a literal prefix byte - latches unknown, where only a submit/clear re-arms, preserving the old conservatism exactly where the box really may hold unseen text. --- internal/session/attach.go | 55 +++++++++++++++++-------- internal/session/attach_filter_test.go | 56 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/internal/session/attach.go b/internal/session/attach.go index f972fb8..d64510c 100644 --- a/internal/session/attach.go +++ b/internal/session/attach.go @@ -140,21 +140,31 @@ func backDetachEnabled() bool { // (believed) empty. // // uam is a byte bridge and cannot see the agent's real input box, so "empty" -// is approximated locally: typedSinceClear flips on anything that could put -// text in the box (printables, tab, history/menu navigation via forwarded -// escape sequences) and resets on the keys that submit or clear it (Enter, -// Esc, Ctrl+C, Ctrl+U). A bare left arrow while clear detaches; inside a -// draft it keeps moving the cursor. Ctrl+B d always detaches regardless. +// is approximated locally: typed counts the runes put into the box, backspace +// deletes one (so deleting the whole draft re-arms the quick detach), and +// unknown latches on anything whose effect cannot be counted (tab completion, +// history/menu navigation via forwarded escape sequences, a literal prefix +// byte) until a key that submits or clears the box (Enter, Esc, Ctrl+C, +// Ctrl+U). A bare left arrow while the box is believed empty detaches; inside +// a draft it keeps moving the cursor. Ctrl+B d always detaches regardless. type stdinFilter struct { backDetach bool // pendingPrefix is set after Ctrl+B, waiting for the chord's second key. pendingPrefix bool // esc accumulates a partial escape sequence (possibly across reads). esc []byte - // typedSinceClear approximates "the agent's input box is non-empty". - typedSinceClear bool + // typed approximates the number of runes in the agent's input box. + typed int + // unknown latches when the box may hold text typed cannot account for. + unknown bool } +// boxEmpty reports whether the agent's input box is believed empty. +func (f *stdinFilter) boxEmpty() bool { return !f.unknown && f.typed == 0 } + +// clearBox resets the estimate on keys that submit or clear the input box. +func (f *stdinFilter) clearBox() { f.typed, f.unknown = 0, false } + // maxEscLen bounds escape-sequence accumulation; anything longer is flushed // through verbatim rather than parsed. const maxEscLen = 8 @@ -197,10 +207,10 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { return out, true case detachPrefix: out = append(out, detachPrefix) - f.typedSinceClear = true + f.unknown = true default: out = append(out, detachPrefix, b) - f.typedSinceClear = true + f.unknown = true } continue } @@ -226,16 +236,29 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { if i == len(chunk)-1 { out = append(out, 0x1b) f.esc = nil - f.typedSinceClear = false + f.clearBox() } case '\r', '\n', 0x03, 0x15: // Enter submits; Ctrl+C and Ctrl+U clear the input box. out = append(out, b) - f.typedSinceClear = false + f.clearBox() + case 0x08, 0x7f: + // Backspace deletes one rune; deleting the whole draft re-arms + // the quick detach. On an empty box it is a no-op. + out = append(out, b) + if f.typed > 0 { + f.typed-- + } + case '\t': + // Tab completion can insert text uam cannot count; disarm until + // the next submit/clear. + out = append(out, b) + f.unknown = true default: out = append(out, b) - if b >= 0x20 || b == '\t' { - f.typedSinceClear = true + // Count one per rune: skip UTF-8 continuation bytes. + if b >= 0x20 && b&0xc0 != 0x80 { + f.typed++ } } } @@ -250,20 +273,20 @@ func (f *stdinFilter) escByte(out []byte, b byte) ([]byte, bool) { if len(f.esc) > maxEscLen { out = append(out, f.esc...) f.esc = nil - f.typedSinceClear = true + f.unknown = true } return out, false } seq := f.esc f.esc = nil - if f.backDetach && !f.typedSinceClear && isLeftArrow(seq) { + if f.backDetach && f.boxEmpty() && isLeftArrow(seq) { return out, true } // Any other navigation may recall history or move through a menu, either // of which can leave text in the input box — be conservative and require // a fresh submit/clear before the quick detach re-arms. out = append(out, seq...) - f.typedSinceClear = true + f.unknown = true return out, false } diff --git a/internal/session/attach_filter_test.go b/internal/session/attach_filter_test.go index 249ccfb..576598e 100644 --- a/internal/session/attach_filter_test.go +++ b/internal/session/attach_filter_test.go @@ -134,3 +134,59 @@ func TestCtrlZSwallowed(t *testing.T) { t.Fatalf("Ctrl+Z must be swallowed, out=%q", out) } } + +// Deleting everything you typed re-arms the quick detach: the filter tracks +// an approximate rune count, so a backspaced-empty input box behaves like an +// untouched one. +func TestBackspacedEmptyDraftReArmsQuickDetach(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "ab\x7f\x7f"); detach { + t.Fatal("typing and deleting must not detach by itself") + } + if _, detach := runFilter(t, f, "\x1b[D"); !detach { + t.Fatal("left arrow after deleting the whole draft should detach") + } +} + +func TestPartialDeleteStaysDisarmed(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "ab\x7f", "\x1b[D"); detach { + t.Fatal("left arrow with a char still in the box must not detach") + } +} + +func TestExtraBackspacesAtEmptyStayArmed(t *testing.T) { + f := &stdinFilter{backDetach: true} + // Backspace on an empty box is a no-op; more deletes than chars typed + // must not wedge the estimate below zero. + if _, detach := runFilter(t, f, "\x7f\x7fa\x7f\x7f\x7f", "\x1b[D"); !detach { + t.Fatal("left arrow after over-deleting should still detach") + } +} + +func TestMultibyteRuneDeletesWithOneBackspace(t *testing.T) { + f := &stdinFilter{backDetach: true} + // é is two bytes but one rune: a single backspace empties the box. + if _, detach := runFilter(t, f, "é\x7f", "\x1b[D"); !detach { + t.Fatal("left arrow after deleting a multibyte rune should detach") + } +} + +func TestCtrlHBackspaceAlsoDeletes(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "a\x08", "\x1b[D"); !detach { + t.Fatal("Ctrl+H backspace should re-arm like DEL") + } +} + +// Tab completion can insert text uam cannot count; backspaces afterwards must +// not re-arm — only a submit/clear does. +func TestTabDisarmsUntilClear(t *testing.T) { + f := &stdinFilter{backDetach: true} + if _, detach := runFilter(t, f, "a\t\x7f\x7f", "\x1b[D"); detach { + t.Fatal("backspaces after a tab must not re-arm the quick detach") + } + if _, detach := runFilter(t, f, "\x15", "\x1b[D"); !detach { + t.Fatal("Ctrl+U after a tab should re-arm") + } +}