From a26ba3f596205ddddb4811ba0694442cffdfdacf Mon Sep 17 00:00:00 2001 From: tfyl Date: Sat, 10 Oct 2026 12:14:48 +0000 Subject: [PATCH] fix(tmux): look for a dialog before pressing Enter on a delivered paste The poll pass decides a pane may take a message from a capture taken before the paste, then presses Enter up to a second later without looking again. Claude takes messages mid-turn, so an agent can put a question up in between. The dialog swallows the paste and takes the Enter as its answer: a multi-select ticks the box under its cursor, a single-select picks its first option, and a review page submits. The submit now captures the pane once more and, when the session's rules read a dialog there, leaves the Enter unpressed and reports ErrSubmitHeld, which drops the message the way any failed send does. Co-Authored-By: Claude Opus 5.5 --- internal/tmux/asyncsubmit_test.go | 50 ++++++++++++++++++++++++++++--- internal/tmux/tmux.go | 24 ++++++++++++--- internal/ui/asyncsend.go | 16 ++++++++-- internal/ui/limitrecovery.go | 2 +- internal/ui/poller.go | 4 +-- 5 files changed, 83 insertions(+), 13 deletions(-) diff --git a/internal/tmux/asyncsubmit_test.go b/internal/tmux/asyncsubmit_test.go index 8f07902b..2f2e5e8e 100644 --- a/internal/tmux/asyncsubmit_test.go +++ b/internal/tmux/asyncsubmit_test.go @@ -3,6 +3,7 @@ package tmux import ( + "errors" "os" "strings" "testing" @@ -49,7 +50,7 @@ func TestSendTextAsyncReturnsWithoutWaitingOutThePasteWindow(t *testing.T) { submitted := make(chan error, 1) start := time.Now() - if err := driver.SendTextAsync("x1", "hello world", func(err error) { submitted <- err }); err != nil { + if err := driver.SendTextAsync("x1", "hello world", nil, func(err error) { submitted <- err }); err != nil { t.Fatalf("SendTextAsync: %v", err) } handoff := time.Since(start) @@ -102,7 +103,7 @@ func TestSendTextAsyncStillSubmitsIntoAPaneThatReadsLate(t *testing.T) { time.Sleep(100 * time.Millisecond) submitted := make(chan error, 1) - if err := driver.SendTextAsync(id, text, func(err error) { submitted <- err }); err != nil { + if err := driver.SendTextAsync(id, text, nil, func(err error) { submitted <- err }); err != nil { t.Fatalf("SendTextAsync: %v", err) } select { @@ -126,6 +127,47 @@ func TestSendTextAsyncStillSubmitsIntoAPaneThatReadsLate(t *testing.T) { t.Fatalf("pane reads = %q, want the paste to end a read (%q) before the Enter", got, want) } +// A pane that put a dialog up after the caller last looked swallows the +// paste and would take the Enter as its answer, ticking or picking the row +// under its cursor. Shown that pane, hold keeps the Enter from going out. +func TestSendTextAsyncLeavesTheEnterUnpressedOnADialog(t *testing.T) { + driver := requireTmux(t) + id := "asyncheld" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "") + reads := "/tmp/gi-asyncheld-" + id + t.Cleanup(func() { os.Remove(reads) }) + + legend := "Enter to select · ↑/↓ to navigate · Esc to cancel" + command := "stty raw -echo; printf '\\033[?2004h'; printf '%s' " + ShellQuote(legend) + "; " + + "while :; do dd bs=4096 count=1 2>/dev/null >> " + ShellQuote(reads) + "; done" + if err := driver.Create(id, "/tmp", command, nil, 0, 0); err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { driver.Kill(id) }) + + time.Sleep(200 * time.Millisecond) + hold := func(pane string) bool { return strings.Contains(pane, legend) } + submitted := make(chan error, 1) + if err := driver.SendTextAsync(id, "rebase on main", hold, func(err error) { submitted <- err }); err != nil { + t.Fatalf("SendTextAsync: %v", err) + } + select { + case err := <-submitted: + if !errors.Is(err, ErrSubmitHeld) { + t.Fatalf("submit = %v, want ErrSubmitHeld", err) + } + case <-time.After(5 * time.Second): + t.Fatal("the deferred submit never settled") + } + time.Sleep(200 * time.Millisecond) + got, _ := os.ReadFile(reads) + if !strings.Contains(string(got), "rebase on main") { + t.Fatalf("pane reads = %q, want the paste to have arrived", got) + } + if strings.Contains(string(got), "\r") { + t.Fatalf("pane reads = %q: Enter reached a pane showing a dialog", got) + } +} + // What a deferred submit makes newly possible, and must not allow: a pane // left mid-send. Between the paste and the Enter it owes, that pane holds // text nothing has submitted yet -- so a second paste arriving in the gap @@ -139,7 +181,7 @@ func TestASecondPasteWaitsForTheEnterTheFirstIsOwed(t *testing.T) { driver, callLog := stubDriver(t, window) submitted := make(chan error, 1) - if err := driver.SendTextAsync("x1", "first message", func(err error) { submitted <- err }); err != nil { + if err := driver.SendTextAsync("x1", "first message", nil, func(err error) { submitted <- err }); err != nil { t.Fatalf("SendTextAsync: %v", err) } // Straight into the gap, the way a second send on a later pass would. @@ -175,7 +217,7 @@ func TestAPaneIsReleasedOnceItsSubmitHasGoneOut(t *testing.T) { driver, _ := stubDriver(t, window) submitted := make(chan error, 1) - if err := driver.SendTextAsync("x1", "first message", func(err error) { submitted <- err }); err != nil { + if err := driver.SendTextAsync("x1", "first message", nil, func(err error) { submitted <- err }); err != nil { t.Fatalf("SendTextAsync: %v", err) } if err := <-submitted; err != nil { diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index d24c9d6a..3203ef5d 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -896,7 +896,7 @@ func (d *Driver) RefreshChrome(id string) error { // gone out, so it spends whatever window the pane takes to draw the paste; // a caller that cannot stall for a pane sends with SendTextAsync instead. func (d *Driver) SendText(id, text string) error { - submit, err := d.pasteHoldingSubmit(id, text) + submit, err := d.pasteHoldingSubmit(id, text, nil) if err != nil { return err } @@ -912,8 +912,15 @@ func (d *Driver) SendText(id, text string) error { // A pane mid-turn is exactly the pane that will not draw a paste promptly, // so this is how the poll pass sends; what that was costing the board is in // internal/ui/asyncsend.go. -func (d *Driver) SendTextAsync(id, text string, submitted func(error)) error { - submit, err := d.pasteHoldingSubmit(id, text) +// +// The caller judged the pane from a capture taken before the paste, and an +// agent mid-turn can put a dialog up in between. A dialog swallows the paste +// and takes the Enter as its answer, ticking or picking whatever row its +// cursor is on. So hold, when non-nil, is shown the pane once more just +// before the Enter; reporting true leaves the Enter unpressed and submitted +// gets ErrSubmitHeld. +func (d *Driver) SendTextAsync(id, text string, hold func(pane string) bool, submitted func(error)) error { + submit, err := d.pasteHoldingSubmit(id, text, hold) if err != nil { return err } @@ -972,7 +979,11 @@ func (d *Driver) pasteWindow() time.Duration { // Operator keystrokes deliberately do not wait -- they already race a // synchronous send's Enter, and delivery is held off a pane someone is // typing into by rules further up rather than by a lock down here. -func (d *Driver) pasteHoldingSubmit(id, text string) (func() error, error) { +// ErrSubmitHeld reports a paste left unsubmitted because the pane showed +// something the Enter would have answered. +var ErrSubmitHeld = errors.New("the pane put up a dialog before the paste was submitted, so Enter was not pressed") + +func (d *Driver) pasteHoldingSubmit(id, text string, hold func(pane string) bool) (func() error, error) { done := d.claimPane(id) before, baseline := d.capturePlain(id) if err := d.paste(id, text); err != nil { @@ -989,6 +1000,11 @@ func (d *Driver) pasteHoldingSubmit(id, text string) (func() error, error) { } else { d.awaitPasteEcho(id, before, text) } + if hold != nil { + if pane, err := d.capturePlain(id); err == nil && hold(pane) { + return ErrSubmitHeld + } + } _, err := d.runAt(id, "send-keys", "-t", d.TargetName(id), "Enter") return err }, nil diff --git a/internal/ui/asyncsend.go b/internal/ui/asyncsend.go index 53ce6a6f..06ef1067 100644 --- a/internal/ui/asyncsend.go +++ b/internal/ui/asyncsend.go @@ -4,6 +4,9 @@ import ( "errors" "strconv" "time" + + "github.com/usestring/gate-inbox/internal/status" + "github.com/usestring/gate-inbox/internal/store" ) // A paste is not finished when it reaches the pane: the Enter behind it has @@ -89,7 +92,12 @@ func (p *poller) sendInFlight(key string) bool { // // The caller must hold a slot from reserveSend for key; runSend gives it // back. -func (p *poller) runSend(sessID, key, text string, settle func(error) error) { +// +// The pass judged the pane from a capture taken before the paste, so the pane +// is read again before the Enter: a dialog the agent put up in between would +// take it as an answer. +func (p *poller) runSend(sess store.Session, key, text string, settle func(error) error) { + sessID := sess.ID finish := func(sendErr error) { // Stamped before settle writes: a pass that reads the outcome must // also find the stamp, or it can type the next message against a @@ -110,7 +118,11 @@ func (p *poller) runSend(sessID, key, text string, settle func(error) error) { // consumed -- is worth a frame now rather than at the next tick. p.requestRefresh() } - if err := p.tmux.SendTextAsync(sessID, text, finish); err != nil { + hold := func(pane string) bool { + state, matched := p.engine.RuleMatch(sess.Tool, pane) + return matched && state == status.Waiting + } + if err := p.tmux.SendTextAsync(sessID, text, hold, finish); err != nil { // The paste never reached the pane, so no submit is coming and // finish has to be the one to record it. finish(err) diff --git a/internal/ui/limitrecovery.go b/internal/ui/limitrecovery.go index d55b28c6..81f06b55 100644 --- a/internal/ui/limitrecovery.go +++ b/internal/ui/limitrecovery.go @@ -86,7 +86,7 @@ func (p *poller) maybeRecoverLimit(sess store.Session, states map[string]store.L // AttemptedAt is written before the paste goes out, so no second pass // can reach this however long the pane takes to draw it; the send only // has to say whether it landed. - p.runSend(sess.ID, key, limitResumePrompt, func(err error) error { + p.runSend(sess, key, limitResumePrompt, func(err error) error { if err != nil { return fmt.Errorf("usage-limit continuation for %s was not confirmed; automatic retry suppressed to avoid duplicate input: %w", sess.Name, err) } diff --git a/internal/ui/poller.go b/internal/ui/poller.go index 8324a6fa..69e80500 100644 --- a/internal/ui/poller.go +++ b/internal/ui/poller.go @@ -1459,7 +1459,7 @@ func (p *poller) maybeSendPendingInput(sess store.Session, pane string, agentAli p.releaseSend(key) return false, nil } - p.runSend(sess.ID, key, input, func(err error) error { + p.runSend(sess, key, input, func(err error) error { // Opencode collapses a multi-line bracketed paste to "[Pasted ~N // lines]" and the first Enter can land inside the paste burst // rather than submitting it, leaving the prompt held in the @@ -1658,7 +1658,7 @@ func (p *poller) deliverInboxHead(sess store.Session, heads map[string]store.Inb } return nil } - p.runSend(sess.ID, key, p.envelope(sess, msg), func(err error) error { + p.runSend(sess, key, p.envelope(sess, msg), func(err error) error { return errors.Join(settle(err), p.restoreDraft(sess, draft)) }) return true, nil