Skip to content
Open
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
50 changes: 46 additions & 4 deletions internal/tmux/asyncsubmit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package tmux

import (
"errors"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 20 additions & 4 deletions internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions internal/ui/asyncsend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/limitrecovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down