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
20 changes: 17 additions & 3 deletions harness/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,9 +770,23 @@ func (t *tuiConsole) SetTitle(s string) {
// footer itself persists between inputs.
func (t *tuiConsole) beginInput(prompt string, mask bool) {
t.mu.Lock()
t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask
t.snippets = nil
t.images = nil
// A turn can end while the user is mid-typing a steering message. Carry
// that draft into the next prompt instead of discarding it: endInput and
// the steer/stop paths already clear the buffer on submit, so a non-empty
// one here uniquely identifies the carry-over. The buffer's snippet and
// image tokens index into the slices, so they carry too.
//
// A draft never crosses a secret boundary in either direction: not out of
// a masked prompt, where it would leak the secret into plain view, and not
// into one, where it would sit in the field rendered as dots and be
// submitted as the key on the next Enter.
if !t.mask && !mask && len(t.buf) > 0 {
t.prompt, t.mask = prompt, mask
} else {
t.prompt, t.buf, t.pos, t.mask = prompt, nil, 0, mask
t.snippets = nil
t.images = nil
}
t.histIdx = len(t.hist)
t.draft = nil
t.winTop = 0
Expand Down
82 changes: 82 additions & 0 deletions harness/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,88 @@ func TestAttendTurnStopCommandCancelsWithoutQueuing(t *testing.T) {
}
}

// TestBeginInputPreservesDraftAcrossTurnEnd: a draft typed during a turn (but
// never submitted) must survive the working-to-completed transition and remain
// in the editor when the next prompt opens, so in-progress text is not lost.
// attendTurn leaves the typed buffer in place when the turn ends; the next
// ReadLine re-opens the editor through beginInput, which must carry that buffer
// forward instead of zeroing it. Breaker: revert beginInput to an unconditional
// t.buf = nil and the submitted line comes back empty instead of "fix it".
func TestBeginInputPreservesDraftAcrossTurnEnd(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "tui-out")
if err != nil {
t.Fatal(err)
}
defer f.Close()
tc := &tuiConsole{out: f, in: bufio.NewReader(strings.NewReader("fix it")), cols: 80}
// Phase 1: the user types a steering draft while a turn runs; the stream's
// EOF ends the attend (errTurnOver) the way a turn finishing on its own does.
if err := tc.attendTurn(turnAttend{
done: make(chan struct{}), // never closes; EOF ends attend
cancel: func() {},
queue: func(string) {},
}); err != errTurnOver {
t.Fatalf("attendTurn err = %v, want errTurnOver", err)
}
if got := string(tc.buf); got != "fix it" {
t.Fatalf("draft must survive attendTurn: buf = %q, want %q", got, "fix it")
}
// Phase 2: the next prompt re-opens the editor. EOF closed the pump's
// channel, so re-arm a fresh input source the way a live console keeps
// reading, then submit with Enter.
tc.runes = nil
tc.in = bufio.NewReader(strings.NewReader("\r"))
line, err := tc.ReadLine("-> ")
if err != nil {
t.Fatalf("ReadLine: %v", err)
}
if line != "fix it" {
t.Fatalf("draft must carry into the next prompt: line = %q, want %q", line, "fix it")
}
}

// TestBeginInputDropsMaskedDraftForOrdinaryPrompt: a non-empty buffer left from
// a masked prompt must never carry into an ordinary one, so a secret cannot
// leak across prompt types. The carry-over branch requires the PRIOR prompt to
// be unmasked, so a non-empty masked buffer is cleared when the next prompt
// opens. Breaker: drop the !t.mask guard on the carry-over branch and the
// secret text survives into the ordinary prompt.
func TestBeginInputDropsMaskedDraftForOrdinaryPrompt(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "tui-out")
if err != nil {
t.Fatal(err)
}
defer f.Close()
// Seed the exact state a masked prompt leaves mid-edit (non-empty buffer,
// masked), then open an ordinary prompt: the secret must not carry.
tc := &tuiConsole{out: f, cols: 80, buf: []rune("hush"), pos: 4, mask: true}
tc.beginInput("-> ", false)
if len(tc.buf) != 0 {
t.Fatalf("a masked draft must not carry into an ordinary prompt: buf = %q", string(tc.buf))
}
}

// TestBeginInputDropsDraftForSecretPrompt: the other direction of the same
// boundary. An ordinary draft must not carry INTO a masked prompt, where it
// would render as dots and be submitted as the secret on the next Enter. No
// current path reaches a secret prompt with a draft pending (every route to one
// goes through a submitted line, which clears the buffer), so this guards a
// boundary rather than fixing a live bug: the carry rule is about crossing the
// secret line at all, not about which side it started on. Breaker: relax the
// guard to `!t.mask && len(t.buf) > 0` and the draft lands in the key field.
func TestBeginInputDropsDraftForSecretPrompt(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "tui-out")
if err != nil {
t.Fatal(err)
}
defer f.Close()
tc := &tuiConsole{out: f, cols: 80, buf: []rune("my draft text"), pos: 13}
tc.beginInput("api key: ", true)
if len(tc.buf) != 0 {
t.Fatalf("a draft must not carry into a masked prompt: buf = %q", string(tc.buf))
}
}

// TestEscIsBareKeysOffIntroducer: bare-Escape detection decides by the byte
// following Esc, not by timing, so it holds up under tmux/SSH latency. A lone
// Esc with the stream then closed is bare; an Esc followed by a CSI introducer
Expand Down