From 1e5e86285ca9d3c72d443f205aaddcabf7f78767 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 30 Jun 2026 00:16:47 +0000 Subject: [PATCH 1/2] fix(attach): protect Ctrl-C terminal copy --- README.md | 4 +++- internal/session/attach.go | 25 ++++++++++++++++++------- internal/session/attach_filter_test.go | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3c50e6e..5b8e8af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/session/attach.go b/internal/session/attach.go index 2367bca..91819bd 100644 --- a/internal/session/attach.go +++ b/internal/session/attach.go @@ -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 { @@ -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, @@ -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 @@ -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: diff --git a/internal/session/attach_filter_test.go b/internal/session/attach_filter_test.go index 3a7a6af..ecbae72 100644 --- a/internal/session/attach_filter_test.go +++ b/internal/session/attach_filter_test.go @@ -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 From 2000bbacbe0164c30c2e743ec9b0b8ece44f12d8 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Tue, 30 Jun 2026 00:21:38 +0000 Subject: [PATCH 2/2] test(session): wait for quiet attach screen exit --- internal/session/session_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 784321f..a459aa1 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -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")