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
55 changes: 39 additions & 16 deletions internal/session/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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++
}
}
}
Expand All @@ -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
}

Expand Down
56 changes: 56 additions & 0 deletions internal/session/attach_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading