Skip to content

Commit 853f9e9

Browse files
steveyeggeclaude
andcommitted
fix(tmux): detect and dismiss Claude Code Rewind menu in nudge delivery (gt-8el)
The double-Escape Rewind menu can lock agent sessions when stray Escape sequences combine with the deliberate Escape in the nudge protocol (step 5). Three-layer defense: 1. Pre-delivery: check if the session is already stuck in Rewind mode (from a previous nudge or user action) and dismiss it before sending. 2. Post-Escape: after the vim-mode Escape in step 5, check if Rewind was triggered (previous Escape still in buffer + ours = double-Escape) and recover by dismissing + re-sending the message text. 3. Existing: sanitizeNudgeMessage already strips raw ESC (0x1b) from message payloads, preventing escape leakage from content. Detection uses capture-pane analysis for Rewind-specific UI patterns (action prompt pairs like "Enter to continue" / "Esc to exit"). Requires multiple co-occurring indicators to avoid false positives. Applied to both NudgeSessionWithOpts and NudgePane. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3b728f commit 853f9e9

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

internal/tmux/session_creation_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ func TestSanitizeNudgeMessage(t *testing.T) {
188188
}
189189
}
190190

191+
// TestContainsRewindIndicators verifies detection of Claude Code's Rewind menu.
192+
func TestContainsRewindIndicators(t *testing.T) {
193+
t.Parallel()
194+
tests := []struct {
195+
name string
196+
content string
197+
want bool
198+
}{
199+
{"empty", "", false},
200+
{"normal prompt", "❯ hello world", false},
201+
{"busy indicator", "⏵⏵ Running tool... esc to interrupt", false},
202+
{"rewind with enter and esc", "Rewind\nPress Enter to select, Esc to go back", true},
203+
{"rewind case insensitive", "rewind history\nenter to continue\nesc to exit", true},
204+
{"enter to continue + esc to exit", "Some UI\nEnter to continue\nEsc to exit", true},
205+
{"enter to accept + esc to cancel", "Enter to accept changes\nEsc to cancel", true},
206+
{"enter to select + esc to cancel", "Choose a checkpoint:\nEnter to select\nEsc to cancel", true},
207+
{"only rewind no actions", "Rewind history shown here", false},
208+
{"only enter no esc", "Enter to continue", false},
209+
{"only esc no enter", "Esc to exit", false},
210+
{"conversation mentioning rewind", "User said: please rewind the video\n❯ ", false},
211+
{"partial match no pair", "Enter to continue\nSome other text", false},
212+
}
213+
for _, tt := range tests {
214+
t.Run(tt.name, func(t *testing.T) {
215+
got := containsRewindIndicators(tt.content)
216+
if got != tt.want {
217+
t.Errorf("containsRewindIndicators(%q) = %v, want %v", tt.content, got, tt.want)
218+
}
219+
})
220+
}
221+
}
222+
191223
// TestSendMessageToTarget_Chunking verifies that long messages are chunked.
192224
func TestSendMessageToTarget_Chunking(t *testing.T) {
193225
tm := newTestTmux(t)

internal/tmux/tmux.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,59 @@ func sanitizeNudgeMessage(msg string) string {
13411341
return b.String()
13421342
}
13431343

1344+
// isInRewindMode checks if a tmux target is displaying Claude Code's Rewind
1345+
// conversation history browser. When Rewind is active, the session ignores
1346+
// typed text and only responds to Enter (accept rewind) or Escape (cancel).
1347+
// This can happen when a stray or deliberate Escape keystroke combines with
1348+
// a previous Escape to form the double-Escape sequence that activates Rewind.
1349+
//
1350+
// Detection is based on pane content analysis. Returns false on any error
1351+
// (defensive — don't block nudge delivery on detection failure).
1352+
func (t *Tmux) isInRewindMode(target string) bool {
1353+
content, err := t.CapturePane(target, 15)
1354+
if err != nil {
1355+
return false
1356+
}
1357+
return containsRewindIndicators(content)
1358+
}
1359+
1360+
// containsRewindIndicators checks pane content for Claude Code Rewind menu
1361+
// patterns. The Rewind UI takes over the terminal and shows distinctive
1362+
// action prompts (Enter to act, Esc to cancel/exit). We require multiple
1363+
// co-occurring indicators to avoid false positives from conversation text.
1364+
func containsRewindIndicators(content string) bool {
1365+
lower := strings.ToLower(content)
1366+
1367+
// Primary: "rewind" appears alongside both Enter and Esc action prompts.
1368+
if strings.Contains(lower, "rewind") {
1369+
if strings.Contains(lower, "enter") && strings.Contains(lower, "esc") {
1370+
return true
1371+
}
1372+
}
1373+
1374+
// Secondary: specific action prompt pairs characteristic of the Rewind UI.
1375+
rewindActionPairs := [][2]string{
1376+
{"enter to continue", "esc to exit"},
1377+
{"enter to accept", "esc to cancel"},
1378+
{"enter to select", "esc to go back"},
1379+
{"enter to select", "esc to cancel"},
1380+
}
1381+
for _, pair := range rewindActionPairs {
1382+
if strings.Contains(lower, pair[0]) && strings.Contains(lower, pair[1]) {
1383+
return true
1384+
}
1385+
}
1386+
1387+
return false
1388+
}
1389+
1390+
// dismissRewindMode sends Escape to cancel Claude Code's Rewind menu,
1391+
// then waits briefly for the UI to return to normal.
1392+
func (t *Tmux) dismissRewindMode(target string) {
1393+
_, _ = t.run("send-keys", "-t", target, "Escape")
1394+
time.Sleep(300 * time.Millisecond)
1395+
}
1396+
13441397
// sendMessageToTarget sends a sanitized message to a tmux target. For small
13451398
// messages (< sendKeysChunkSize), uses send-keys -l. For larger messages,
13461399
// sends in chunks with delays to avoid overwhelming the TTY input buffer.
@@ -1493,6 +1546,14 @@ func (t *Tmux) NudgeSessionWithOpts(session, message string, opts NudgeOpts) err
14931546
target = agentPane
14941547
}
14951548

1549+
// 0. Pre-delivery: dismiss Rewind menu if the session is stuck in it.
1550+
// A previous nudge or user action may have triggered Claude Code's
1551+
// double-Escape Rewind UI, which captures all input. Dismiss it first
1552+
// so the nudge can be delivered normally. (GH#gt-8el)
1553+
if t.isInRewindMode(target) {
1554+
t.dismissRewindMode(target)
1555+
}
1556+
14961557
// 1. Exit copy/scroll mode if active — copy mode intercepts input,
14971558
// preventing delivery to the underlying process.
14981559
if inMode, _ := t.run("display-message", "-p", "-t", target, "#{pane_in_mode}"); strings.TrimSpace(inMode) == "1" {
@@ -1522,6 +1583,19 @@ func (t *Tmux) NudgeSessionWithOpts(session, message string, opts NudgeOpts) err
15221583
// Without this, ESC+Enter within 500ms becomes M-Enter (meta-return) which
15231584
// does NOT submit the line.
15241585
time.Sleep(600 * time.Millisecond)
1586+
1587+
// 6.5. Post-Escape: check if our Escape triggered Rewind mode.
1588+
// This happens when a previous Escape was still in the input buffer,
1589+
// combining with ours to form the double-Escape that activates Rewind.
1590+
// If triggered, dismiss Rewind and re-send the message (Rewind
1591+
// consumed the original input). Skip the second Escape to avoid
1592+
// re-triggering. (GH#gt-8el)
1593+
if t.isInRewindMode(target) {
1594+
t.dismissRewindMode(target)
1595+
// Re-send message text — Rewind consumed the original input.
1596+
_ = t.sendMessageToTarget(target, sanitized, constants.NudgeReadyTimeout)
1597+
time.Sleep(500 * time.Millisecond)
1598+
}
15251599
}
15261600

15271601
// 7. Send Enter with retry (critical for message submission)
@@ -1553,6 +1627,11 @@ func (t *Tmux) NudgePane(pane, message string) error {
15531627
}
15541628
defer releaseNudgeLock(pane)
15551629

1630+
// 0. Pre-delivery: dismiss Rewind menu if active. (GH#gt-8el)
1631+
if t.isInRewindMode(pane) {
1632+
t.dismissRewindMode(pane)
1633+
}
1634+
15561635
// 1. Exit copy/scroll mode if active — copy mode intercepts input,
15571636
// preventing delivery to the underlying process.
15581637
if inMode, _ := t.run("display-message", "-p", "-t", pane, "#{pane_in_mode}"); strings.TrimSpace(inMode) == "1" {
@@ -1579,6 +1658,13 @@ func (t *Tmux) NudgePane(pane, message string) error {
15791658
// 6. Wait 600ms — must exceed bash readline's keyseq-timeout (500ms default)
15801659
time.Sleep(600 * time.Millisecond)
15811660

1661+
// 6.5. Post-Escape: check if our Escape triggered Rewind mode. (GH#gt-8el)
1662+
if t.isInRewindMode(pane) {
1663+
t.dismissRewindMode(pane)
1664+
_ = t.sendMessageToTarget(pane, sanitized, constants.NudgeReadyTimeout)
1665+
time.Sleep(500 * time.Millisecond)
1666+
}
1667+
15821668
// 7. Send Enter with retry (critical for message submission)
15831669
var lastErr error
15841670
for attempt := 0; attempt < 3; attempt++ {

0 commit comments

Comments
 (0)