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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ uam version
agent's PTY:

- `Ctrl+B d` detaches and returns to the dashboard (`Ctrl+B Ctrl+B` sends a
literal `Ctrl+B` to the agent)
literal `Ctrl+B` to the agent; `Ctrl+B c` sends a literal `Ctrl+C`)
- Plain `Ctrl+C` is swallowed while attached so terminal copy shortcuts do not
cancel the agent
- `←` (left arrow) also detaches when you haven't typed anything since the
last submit/clear — tap it to hop back to the dashboard. Inside a draft it
moves the cursor as usual, and after history/menu navigation it stays
Expand Down
25 changes: 18 additions & 7 deletions internal/session/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import (
)

// detachPrefix is the attach client's escape key (Ctrl+B, tmux's default
// prefix, kept for muscle memory). Prefix then `d` detaches; prefix twice
// sends a literal Ctrl+B to the agent.
// prefix, kept for muscle memory). Prefix then `d` detaches, prefix then `c`
// sends a literal Ctrl+C to the agent, and prefix twice sends a literal Ctrl+B.
const detachPrefix = 0x02

const ctrlC = 0x03

const AttachQuietEnv = "UAM_ATTACH_QUIET"

type attachOptions struct {
Expand Down Expand Up @@ -221,9 +223,11 @@ func backDetachEnabled() bool {
// 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.
// byte) until a key that submits or clears the box (Enter, Esc, Ctrl+U). Plain
// Ctrl+C is swallowed so terminal copy shortcuts cannot cancel the agent;
// Ctrl+B c sends a literal Ctrl+C when an explicit interrupt is needed. 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.
//
// Not everything on stdin is a keystroke: agents query the terminal (Ink
// re-requests the cursor position every render) and the replies — CPR, DA1,
Expand Down Expand Up @@ -306,6 +310,9 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) {
switch b {
case 'd':
return out, true
case 'c':
out = append(out, ctrlC)
f.clearBox()
case detachPrefix:
out = append(out, detachPrefix)
f.unknown = true
Expand Down Expand Up @@ -339,8 +346,12 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) {
f.esc = nil
f.clearBox()
}
case '\r', '\n', 0x03, 0x15:
// Enter submits; Ctrl+C and Ctrl+U clear the input box.
case ctrlC:
// Swallowed so terminal copy cannot cancel the agent. Use Ctrl+B c
// when a literal interrupt needs to be sent through.
f.clearBox()
case '\r', '\n', 0x15:
// Enter submits; Ctrl+U clears the input box.
out = append(out, b)
f.clearBox()
case 0x08, 0x7f:
Expand Down
16 changes: 16 additions & 0 deletions internal/session/attach_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ func TestCtrlCAndCtrlUReArm(t *testing.T) {
}
}

func TestCtrlCSwallowedForTerminalCopy(t *testing.T) {
f := &stdinFilter{backDetach: true}
out, detach := runFilter(t, f, "\x03")
if detach || out != "" {
t.Fatalf("plain Ctrl+C must not reach the agent, out=%q detach=%v", out, detach)
}
}

func TestChordCSendsLiteralCtrlC(t *testing.T) {
f := &stdinFilter{backDetach: true}
out, detach := runFilter(t, f, "\x02c")
if detach || out != "\x03" {
t.Fatalf("Ctrl+B c should forward a literal Ctrl+C, out=%q detach=%v", out, detach)
}
}

func TestModifiedLeftArrowPassesThrough(t *testing.T) {
f := &stdinFilter{backDetach: true}
out, detach := runFilter(t, f, "\x1b[1;2D") // shift-left
Expand Down
1 change: 1 addition & 0 deletions internal/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ func TestAttachQuietSuppressesPrimaryScreenNotice(t *testing.T) {
case <-time.After(10 * time.Second):
t.Fatal("detach chord did not detach")
}
waitFor(t, "alternate screen exit", func() bool { return strings.Contains(snapshot(), "\x1b[?1049l") })

full := snapshot()
exit := strings.LastIndex(full, "\x1b[?1049l")
Expand Down
Loading