From a7661c0a26c4deea9b981be0e1cdd1f736d11fb7 Mon Sep 17 00:00:00 2001 From: mike-diff Date: Mon, 15 Jun 2026 21:06:02 -0700 Subject: [PATCH] feat(harness): keep input live during a turn; Escape cancels, typing steers Fixes #25 and #26. The bug behind both: during a turn the main goroutine was inside the run loop, so nothing read stdin and the input bar froze until the turn finished. Ctrl-C only ever worked because it rode SIGINT, which is exactly why it was flaky. Now a single pump goroutine owns stdin and feeds a selectable channel, so the editor stays live while the turn runs in a worker goroutine. While it works: type a message and Enter to queue a steer (injected as a user turn at the next iteration boundary, the format-safe point), or press Escape to cancel. Ctrl-C is now reserved for quitting (twice). Scope: live input covers the default free-running posture; with -ask the gate reads approvals mid-turn from the same keyboard, so that posture stays synchronous. Injection lands at iteration boundaries, not mid-tool-chain. --- harness/drive.go | 93 +++++++++++++-------- harness/drive_test.go | 50 ++++++++++++ harness/harness.go | 130 +++++++++++++++++++++-------- harness/help.go | 3 +- harness/repl.go | 13 +-- harness/tui.go | 184 ++++++++++++++++++++++++++++++++++++------ harness/tui_test.go | 28 +++++++ 7 files changed, 401 insertions(+), 100 deletions(-) diff --git a/harness/drive.go b/harness/drive.go index 0d58173..bdaaf4e 100644 --- a/harness/drive.go +++ b/harness/drive.go @@ -9,7 +9,7 @@ // Stop layers, all of them, because each alone fails (the unanimous field // lesson): the judge, never the worker, decides from transcript evidence; // -max-iters bounds every request; a no-progress detector stops iterations -// that mutate nothing; Ctrl-C always pauses to the prompt. Mutation approval +// that mutate nothing; Escape always pauses to the prompt. Mutation approval // is untouched: gates in interactive, -yes in print mode. package harness @@ -137,9 +137,13 @@ type driveConfig struct { // Interactive routes it into the transcript. say func(format string, a ...any) // turnCtx supplies each iteration's context; nil means Background. The - // interactive REPL passes its interrupt watcher's, so Ctrl-C pauses the + // interactive REPL passes its interrupt watcher's, so Escape pauses the // drive instead of being ignored. turnCtx func() (context.Context, func()) + // drainQueued returns and clears any messages the user typed while the turn + // ran (live input). When present at an iteration boundary they are injected + // as the user's steer for the next step, in place of the judge's verdict. + drainQueued func() []string } // drive continues an already-run first turn until the judge rules done or @@ -165,43 +169,61 @@ func drive(r *repl, cfg driveConfig, firstTurns []agent.Turn) int { iterTurns := firstTurns stuck := 0 for iter := 1; ; iter++ { - // The judge runs under the same cancellable context as the worker, so - // Ctrl-C pauses the drive during the judge phase too (not just during a - // streamed worker iteration). - jctx, jdone := turnCtx() - v, jUsed, jerr := judgeGoal(jctx, r.p, cfg.request, renderTranscript(iterTurns, 300)) - jdone() - r.accountAux(jUsed) // the judge is real spend; count it, leave the gauge - switch { - case jerr != nil: - if isCanceled(jerr) { - say("== paused; your next message steers") - return driveInterrupted + // A message the user typed while the turn ran (live input) takes priority + // over the judge: act on their steer at this boundary instead of + // auto-continuing or stopping. + var steered string + if cfg.drainQueued != nil { + if q := cfg.drainQueued(); len(q) > 0 { + steered = strings.Join(q, "\n") } - // No verdict means no mandate to keep spending: stop quietly. - say("== judge unavailable (%s); returning to you", compact(jerr.Error())) - return driveBlocked - case v.Done: - if iter > 1 { - say("== done after %d iterations: %s", iter, compact(v.Reason)) - } - return driveDone - case v.Blocked: - say("== needs you: %s", compact(v.Reason)) - return driveBlocked } - - if iter >= cfg.maxIters { - say("== max iterations (%d) reached; latest verdict: %s", cfg.maxIters, compact(v.Reason)) - return driveMaxIters + var v verdict + if steered == "" { + // The judge runs under the same cancellable context as the worker, so + // Escape pauses the drive during the judge phase too (not just during a + // streamed worker iteration). + jctx, jdone := turnCtx() + var jUsed agent.Usage + var jerr error + v, jUsed, jerr = judgeGoal(jctx, r.p, cfg.request, renderTranscript(iterTurns, 300)) + jdone() + r.accountAux(jUsed) // the judge is real spend; count it, leave the gauge + switch { + case jerr != nil: + if isCanceled(jerr) { + say("== paused; your next message steers") + return driveInterrupted + } + // No verdict means no mandate to keep spending: stop quietly. + say("== judge unavailable (%s); returning to you", compact(jerr.Error())) + return driveBlocked + case v.Done: + if iter > 1 { + say("== done after %d iterations: %s", iter, compact(v.Reason)) + } + return driveDone + case v.Blocked: + say("== needs you: %s", compact(v.Reason)) + return driveBlocked + } + if iter >= cfg.maxIters { + say("== max iterations (%d) reached; latest verdict: %s", cfg.maxIters, compact(v.Reason)) + return driveMaxIters + } + } else { + say("== steering: %s", compact(steered)) } start := time.Now() mutBefore := cfg.mutations() mark := len(r.history) - opening := render(steerPrompt("continue", continueTemplate), map[string]string{ - "iteration": fmt.Sprint(iter + 1), "verdict": v.Reason, "request": cfg.request, - }) + opening := steered + if opening == "" { + opening = render(steerPrompt("continue", continueTemplate), map[string]string{ + "iteration": fmt.Sprint(iter + 1), "verdict": v.Reason, "request": cfg.request, + }) + } if r.preflight(opening) { return driveBlocked } @@ -241,9 +263,12 @@ func drive(r *repl, cfg driveConfig, firstTurns []agent.Turn) int { r.managePressure() // the window boundary stays the chain's iterTurns = out[mark:] - if cfg.mutations() == mutBefore { + switch { + case steered != "": // the user steered: intent, not a stall + stuck = 0 + case cfg.mutations() == mutBefore: stuck++ - } else { + default: stuck = 0 } say("== iteration %d · %s · %d in / %d out tokens", diff --git a/harness/drive_test.go b/harness/drive_test.go index 6f2153b..d1445ec 100644 --- a/harness/drive_test.go +++ b/harness/drive_test.go @@ -295,6 +295,56 @@ func TestDriveInterrupted(t *testing.T) { } } +// TestDriveInjectsQueuedSteer: a message typed during the turn (live input) is +// injected as a user turn and acted on at the next boundary, in place of the +// judge. The worker reply "ok" is not valid JSON, so if the judge still ran +// first the drive would block; reaching done proves the steer was injected. +// Breaker: drop the drainQueued branch and the judge sees "ok" -> driveBlocked. +func TestDriveInjectsQueuedSteer(t *testing.T) { + drained := false + drain := func() []string { + if drained { + return nil + } + drained = true + return []string{"do X instead"} + } + p := &seqChat{fns: []func(context.Context) (agent.Reply, error){ + reply("ok"), // the steered iteration's worker reply (no tool calls) + reply(`{"done": true, "blocked": false, "reason": "done"}`), // judge on the next loop + }} + r := driveRepl(t, p, workTurns()) + _, count := counting() + cfg := driveConfig{request: "fix", maxIters: 5, mutations: count, drainQueued: drain} + if code := drive(r, cfg, workTurns()); code != driveDone { + t.Fatalf("code %d, want driveDone", code) + } + found := false + for _, tn := range r.history { + if tn.Role == "user" && strings.Contains(tn.Text, "do X instead") { + found = true + } + } + if !found { + t.Fatal("the queued steer was not injected into history") + } +} + +// TestInterruptsQueue: drain returns the enqueued messages and clears them, so a +// steer is consumed once. Breaker: drop the clear in drain and it re-injects +// forever. +func TestInterruptsQueue(t *testing.T) { + in := &interrupts{} + in.enqueue("a") + in.enqueue("b") + if got := in.drain(); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("drain = %v, want [a b]", got) + } + if got := in.drain(); len(got) != 0 { + t.Fatalf("second drain must be empty, got %v", got) + } +} + // TestDriveJudgeUnavailable: no verdict means no mandate to keep spending. func TestDriveJudgeUnavailable(t *testing.T) { p := &seqChat{} // judge call errors immediately diff --git a/harness/harness.go b/harness/harness.go index df9fd0b..30a00d4 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -404,19 +404,35 @@ func Main() { }() } - // Ctrl-C cancels the running turn, not sesh; pressed twice within - // two seconds it quits (after restoring the terminal). + // Escape cancels the running turn; Ctrl-C quits (twice within two seconds, + // after restoring the terminal). intr := newInterrupts(func() { con.Close() r.goodbye() }) + say := func(f string, a ...any) { + emit("%s "+f+"%s\n", append(append([]any{dim}, a...), reset)...) + } + // Live input (type/queue/Escape-cancel while the agent works) needs the + // footer TUI and a turn that never stops to ask: with -ask the gate reads + // approvals mid-turn from the same keyboard, so that posture stays + // synchronous. + tc, isTUI := con.(*tuiConsole) + live := isTUI && !(*ask && !*autoYes) + + var pending string // a queued message that becomes the next turn's input for { - line, err := con.ReadLine("-> ") - if err != nil { - emit("\n") - r.goodbye() - return + line := pending + pending = "" + if line == "" { + l, err := con.ReadLine("-> ") + if err != nil { + emit("\n") + r.goodbye() + return + } + line = l } if line == "" { continue @@ -435,40 +451,66 @@ func Main() { if r.preflight(line) { continue // the message can never fit; nothing was sent } - ctx, done := intr.turnContext() - stopSpin := r.spin() - turns, ok := r.runTurn(ctx, line, tools, hooks) - done() + cfg := driveConfig{ + request: line, maxIters: *maxIters, tools: tools, hooks: hooks, + mutations: mutCount, turnCtx: intr.turnContext, drainQueued: intr.drain, + say: say, + } // Goal-driven persistence: the request is the goal; a judged not-done // keeps the session working until done, blocked, stuck, or the cap. - // Conversation (no tool use) never drives. Ctrl-C pauses to the prompt. - if ok { - drive(r, driveConfig{ - request: line, maxIters: *maxIters, tools: tools, hooks: hooks, - mutations: mutCount, turnCtx: intr.turnContext, - say: func(f string, a ...any) { - emit("%s "+f+"%s\n", append(append([]any{dim}, a...), reset)...) - }, - }, turns) + // Conversation (no tool use) never drives. + if live { + // The turn runs in the background while the editor stays live: the + // user can type a steering message (queued, injected at the next + // boundary) or press Escape to cancel. + ctx, done := intr.turnContext() + stopSpin := r.spin() + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + turns, ok := r.runTurn(ctx, line, tools, hooks) + done() + if ok { + drive(r, cfg, turns) + } + }() + tc.attendTurn(turnAttend{done: doneCh, cancel: intr.cancelCurrent, queue: intr.enqueue}) + <-doneCh // the worker is finished (attendTurn can also return on EOF); never overlap turns + stopSpin() + if q := intr.drain(); len(q) > 0 { // typed after the last boundary: run next + pending = strings.Join(q, "\n") + } + } else { + ctx, done := intr.turnContext() + stopSpin := r.spin() + turns, ok := r.runTurn(ctx, line, tools, hooks) + done() + if ok { + drive(r, cfg, turns) + } + stopSpin() } - stopSpin() } } -// interrupts watches Ctrl-C for the whole session. During a turn the first -// press cancels that turn; a second press within the window (whether or not a -// turn is running) restores the terminal and quits. The watcher is persistent -// so the quit window spans the gap after a cancelled turn ends, which is -// exactly when an impatient second press arrives. +// interrupts owns turn control for the session: the cancel function for the +// turn in flight (the live editor's Escape calls it) and the queue of messages +// the user types while a turn runs (drained and injected as a steer at the next +// boundary). Ctrl-C is handled here too, but only to quit (a stray press warns, +// a second within the window quits), since cancelling is Escape's job now. type interrupts struct { mu sync.Mutex cancel context.CancelFunc // non-nil while a turn is running last time.Time cleanup func() + queued []string // messages typed during a turn, drained at the next boundary } const doublePressWindow = 2 * time.Second +// newInterrupts wires Ctrl-C to quit (a stray press warns first, a second within +// the window quits and restores the terminal). Cancelling a turn is Escape's +// job, handled by the live editor, so Ctrl-C is left purely for quitting. func newInterrupts(cleanup func()) *interrupts { in := &interrupts{cleanup: cleanup} sigc := make(chan os.Signal, 1) @@ -478,23 +520,43 @@ func newInterrupts(cleanup func()) *interrupts { in.mu.Lock() double := time.Since(in.last) < doublePressWindow in.last = time.Now() - cancel := in.cancel in.mu.Unlock() - switch { - case double: + if double { in.cleanup() os.Exit(130) - case cancel != nil: - emit("\n%s cancelling turn... (ctrl-c again to quit)%s\n", yellow, reset) - cancel() - default: - emit("\n%s (ctrl-c again to quit)%s\n", yellow, reset) } + emit("\n%s (ctrl-c again to quit; esc cancels the turn)%s\n", yellow, reset) } }() return in } +// cancelCurrent aborts the turn in flight, if any (the live editor's Escape). +func (in *interrupts) cancelCurrent() { + in.mu.Lock() + c := in.cancel + in.mu.Unlock() + if c != nil { + c() + } +} + +// enqueue stashes a message typed while a turn runs; drain returns and clears +// the queue at a safe boundary, where it can be injected as a user turn. +func (in *interrupts) enqueue(s string) { + in.mu.Lock() + in.queued = append(in.queued, s) + in.mu.Unlock() +} + +func (in *interrupts) drain() []string { + in.mu.Lock() + q := in.queued + in.queued = nil + in.mu.Unlock() + return q +} + // turnContext hands out a cancellable context for one turn and the cleanup // that detaches it from the watcher. func (in *interrupts) turnContext() (context.Context, func()) { diff --git a/harness/help.go b/harness/help.go index 3634dc6..4eec8aa 100644 --- a/harness/help.go +++ b/harness/help.go @@ -30,7 +30,8 @@ GOAL-DRIVEN PERSISTENCE (default in every mode) fresh-context judge rules from transcript evidence: done (stop), blocked (return to the user), or continue (the reason feeds the next iteration and work resumes). Plain conversation is never judged and never loops. - Stop layers: -max-iters, a no-progress detector, -max-tools, Ctrl-C. + Stop layers: -max-iters, a no-progress detector, -max-tools, Esc (cancels a + turn; type while it works to steer at the next step). Ctrl-C quits. CONTINUITY (infinite sessions) Context pressure is managed by handoff, never lossy in-place compaction: at diff --git a/harness/repl.go b/harness/repl.go index a3816fa..8fd2c8c 100644 --- a/harness/repl.go +++ b/harness/repl.go @@ -435,9 +435,10 @@ func (r *repl) helpCmd() { /copy copy the last response to the clipboard (clean source) /help this help exit, /exit quit (ctrl-d works too); prints how to resume -keys: ctrl-c cancels the running turn (twice quits) · shift+enter, ctrl-j, or - \+enter inserts a newline · up/down history · tab completes · pastes over - 3 lines collapse to a [snippet] sent in full +keys: while a turn runs, type to queue a steer (sent at the next step) or esc + to cancel · ctrl-c quits (twice) · shift+enter, ctrl-j, or \+enter inserts + a newline · up/down history · tab completes · pastes over 3 lines collapse + to a [snippet] sent in full config: ~/.sesh/ holds providers.json, credentials, SYSTEM.md, statusline %s `, dim, reset) @@ -457,11 +458,11 @@ func (r *repl) banner(cwd string, ask bool, resumed int, buildErr error) { emit("\n") switch { case ask: - emit("-ask: write/edit/bash prompt for approval.\n") + emit("-ask: write/edit/bash prompt for approval. ctrl-c quits.\n") case findGateMod() != "": - emit("tools run freely; gate mod %s rules on write/edit/bash. ctrl-c interrupts.\n", findGateMod()) + emit("tools run freely; gate mod %s rules on write/edit/bash. esc cancels, type to steer.\n", findGateMod()) default: - emit("tools run freely; ctrl-c interrupts (-ask to approve each mutation).\n") + emit("tools run freely; while it works, type to steer or esc to cancel (-ask to approve each mutation).\n") } if len(r.models) > 0 { emit("%s%d models on this endpoint; /model to list%s\n", dim, len(r.models), reset) diff --git a/harness/tui.go b/harness/tui.go index fff1c68..c18ea72 100644 --- a/harness/tui.go +++ b/harness/tui.go @@ -192,13 +192,23 @@ type tuiConsole struct { histPath string completer func(line string) []string mention *mentions // recognizes/completes/highlights #skill and @file tokens; nil disables + + // runes is the single decoded-keystroke stream: one pump goroutine owns the + // real stdin and feeds this channel, so reads become selectable. That is what + // lets the editor stay live during a turn (select on a key or the turn's + // end) instead of freezing while the model works. ungot is a tiny pushback + // for lookahead (bare-Escape detection). Both are touched only by whichever + // consumer is currently reading; the pump only ever sends. + runes chan rune + ungot []rune } func newTUI() (*tuiConsole, error) { - t := &tuiConsole{out: os.Stdout, in: bufio.NewReader(os.Stdin), maxInputRows: 6} + t := &tuiConsole{out: os.Stdout, in: bufio.NewReader(os.Stdin), maxInputRows: 6, runes: make(chan rune, 1024)} if err := t.measure(); err != nil { return nil, err } + go t.pump() // cbreak: keys arrive immediately and unechoed; output processing stays // on. -icrnl keeps Enter (\r) distinct from Ctrl-J (\n): with the default // mapping both arrive as \n and submit-vs-newline cannot be told apart. @@ -757,24 +767,136 @@ func (t *tuiConsole) endInput(echo string) { t.drawFooterLocked() } +// noteQueuedLocked drops a dim transcript line confirming a typed message was +// queued to steer the running turn, so the user sees it was captured rather than +// dropped. Caller holds the mutex. +func (t *tuiConsole) noteQueuedLocked(msg string) { + if r := []rune(msg); len(r) > 60 { + msg = string(r[:60]) + "..." + } + t.removeFooterLocked() + t.writeLocked(fmt.Sprintf("%s queued, will steer at the next step: %s%s\n", dim, msg, reset)) + t.drawFooterLocked() +} + +// pump is the one goroutine that reads the real terminal. Every keystroke +// becomes a rune on t.runes, so all other reads select on a channel and the +// editor can stay live during a turn. It runs for the life of the console; +// EOF closes the channel, which every consumer reads as end of input. +func (t *tuiConsole) pump() { + for { + r, _, err := t.in.ReadRune() + if err != nil { + close(t.runes) + return + } + t.runes <- r + } +} + +// nextRune returns the next keystroke, or ok=false at end of input. ungot is a +// one-deep lookahead used by bare-Escape detection. Only the active consumer +// (always the main goroutine) calls this, so no lock is needed. +func (t *tuiConsole) nextRune() (rune, bool) { + if n := len(t.ungot); n > 0 { + r := t.ungot[n-1] + t.ungot = t.ungot[:n-1] + return r, true + } + r, ok := <-t.runes + return r, ok +} + +func (t *tuiConsole) unget(r rune) { t.ungot = append(t.ungot, r) } + +// nextKey is the editor's read: like nextRune, but when done is non-nil it also +// returns the moment the turn ends (over=true), so the live editor stops +// attending without consuming a keystroke meant for the next prompt. +func (t *tuiConsole) nextKey(done <-chan struct{}) (r rune, over, eof bool) { + if n := len(t.ungot); n > 0 { + r = t.ungot[n-1] + t.ungot = t.ungot[:n-1] + return r, false, false + } + if done == nil { + r, ok := <-t.runes + return r, false, !ok + } + select { + case r, ok := <-t.runes: + return r, false, !ok + case <-done: + return 0, true, false + } +} + +// turnAttend wires the live editor to a running turn: done closes when the turn +// finishes, cancel aborts it (Escape), and queue stashes a typed message to +// steer the agent at the next iteration boundary. +type turnAttend struct { + done <-chan struct{} + cancel func() + queue func(string) +} + +// errTurnOver ends a live-editor attend when the turn finishes on its own. +var errTurnOver = fmt.Errorf("turn over") + func (t *tuiConsole) ReadLine(prompt string) (string, error) { - return t.readLine(prompt, false) + return t.readLineMode(prompt, false, nil) } func (t *tuiConsole) ReadSecret(prompt string) (string, error) { - return t.readLine(prompt, true) + return t.readLineMode(prompt, true, nil) +} + +// attendTurn runs the editor live while a turn works: typing edits as usual, +// Escape cancels the turn, and Enter queues the message to steer. It returns +// when the turn ends (errTurnOver) or on EOF. +func (t *tuiConsole) attendTurn(ta turnAttend) error { + _, err := t.readLineMode("-> ", false, &ta) + return err } +// readLineMode runs the line editor. With turn == nil it is the normal +// between-turns prompt. With turn set it attends a running turn: it reads keys +// the same way but Escape cancels the turn, Enter queues the typed text to steer +// instead of submitting, and it returns errTurnOver the instant the turn ends. func (t *tuiConsole) readLine(prompt string, mask bool) (string, error) { + return t.readLineMode(prompt, mask, nil) +} + +func (t *tuiConsole) readLineMode(prompt string, mask bool, turn *turnAttend) (string, error) { + if t.runes == nil { // a test-built console has no pump yet; the real one starts it in newTUI + t.runes = make(chan rune, 1024) + go t.pump() + } t.beginInput(prompt, mask) + var done <-chan struct{} + if turn != nil { + done = turn.done + } for { - r, _, err := t.in.ReadRune() + r, over, eof := t.nextKey(done) + if over { + return "", errTurnOver // the turn finished; stop attending + } t.mu.Lock() switch { - case err != nil: + case eof: + if turn != nil { // Ctrl-D / EOF must not end the session mid-turn + t.mu.Unlock() + return "", errTurnOver + } t.endInput("") t.mu.Unlock() - return "", err + return "", io.EOF + case r == '\r' && turn != nil: // Enter while working: queue the message, keep editing + if line := strings.TrimSpace(t.expandSnippets()); line != "" { + turn.queue(line) + t.noteQueuedLocked(line) + t.buf, t.pos, t.snippets = nil, 0, nil + } case r == '\r': // Enter submits; Shift+Enter, Ctrl-J, and \+Enter newline if !mask && t.pos > 0 && t.buf[t.pos-1] == '\\' { // \ + Enter: a universal newline for terminals (tmux, Apple @@ -798,8 +920,8 @@ func (t *tuiConsole) readLine(prompt string, mask bool) (string, error) { return line, nil case r == '\n': // Ctrl-J: newline everywhere, no protocol needed t.insertLocked('\n') - case r == 0x04: // Ctrl-D on an empty line ends the session - if len(t.buf) == 0 { + case r == 0x04: // Ctrl-D on an empty line ends the session (not mid-turn) + if turn == nil && len(t.buf) == 0 { t.endInput("") t.mu.Unlock() return "", io.EOF @@ -826,7 +948,11 @@ func (t *tuiConsole) readLine(prompt string, mask bool) (string, error) { t.insertLocked(' ') case r == 0x1b: t.mu.Unlock() - t.handleEscape() + if turn != nil && t.escIsBare() { // bare Escape cancels the running turn + turn.cancel() + } else { + t.handleEscape() + } t.mu.Lock() case r >= 0x20: // printable, unicode included via ReadRune t.insertLocked(r) @@ -943,14 +1069,14 @@ func (t *tuiConsole) insertPasteLocked(content []rune) { // returning its parameter bytes and final byte. ok=false for a bare escape // or read error; unknown sequences still return so callers can ignore them. func (t *tuiConsole) readCSI() (params string, final rune, ok bool) { - r, _, err := t.in.ReadRune() - if err != nil || (r != '[' && r != 'O') { + r, more := t.nextRune() + if !more || (r != '[' && r != 'O') { return "", 0, false } var p []rune for { - c, _, err := t.in.ReadRune() - if err != nil { + c, more := t.nextRune() + if !more { return "", 0, false } if c >= '@' && c <= '~' { @@ -965,8 +1091,8 @@ func (t *tuiConsole) readCSI() (params string, final rune, ok bool) { func (t *tuiConsole) readPaste() []rune { var content []rune for { - r, _, err := t.in.ReadRune() - if err != nil { + r, more := t.nextRune() + if !more { return content } if r == 0x1b { @@ -1155,8 +1281,8 @@ func (t *tuiConsole) Select(title string, items []string, current int) (int, err draw() for { - r, _, err := t.in.ReadRune() - if err != nil { + r, ok := t.nextRune() + if !ok { return finish(-1) } switch { @@ -1204,13 +1330,21 @@ func (t *tuiConsole) Select(title string, items []string, current int) (int, err } // escIsBare reports whether an Esc keypress arrived alone: escape sequences -// land as a burst, so an empty buffer shortly after means the bare key. +// land as a burst, so if no rune follows within a short window it was the bare +// key. A peeked rune is put back for the real decoder. func (t *tuiConsole) escIsBare() bool { - if t.in.Buffered() > 0 { + if len(t.ungot) > 0 { return false } - time.Sleep(25 * time.Millisecond) - return t.in.Buffered() == 0 + select { + case r, ok := <-t.runes: + if ok { + t.unget(r) + } + return !ok // a closed stream after Esc counts as bare + case <-time.After(25 * time.Millisecond): + return true + } } func commonPrefix(a, b string) string { @@ -1232,16 +1366,16 @@ func (t *tuiConsole) ReadKey(prompt string) (byte, error) { t.removeFooterLocked() t.writeLocked(prompt) t.mu.Unlock() - r, _, err := t.in.ReadRune() - if err == nil && r == 0x1b { + r, ok := t.nextRune() + if ok && r == 0x1b { t.readCSI() r = 'n' } t.mu.Lock() defer t.mu.Unlock() - if err != nil { + if !ok { t.writeLocked("\n") - return 0, err + return 0, io.EOF } t.writeLocked(fmt.Sprintf("%c\n", r)) if had { diff --git a/harness/tui_test.go b/harness/tui_test.go index b3ac315..5ed1f5e 100644 --- a/harness/tui_test.go +++ b/harness/tui_test.go @@ -165,6 +165,34 @@ func TestEditorSecretSubmitsOnBackslashEnter(t *testing.T) { } } +// TestAttendTurnQueuesAndCancels: while a turn runs, the live editor queues a +// typed message on Enter (instead of submitting) and cancels the turn on a bare +// Escape. Breaker: drop the turn-mode Enter branch and "fix it" is never queued; +// drop the bare-Escape branch and cancel is never called. +func TestAttendTurnQueuesAndCancels(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\r\x1b")), cols: 80} + var queued []string + canceled := false + if err := tc.attendTurn(turnAttend{ + done: make(chan struct{}), // never closes; the script's EOF ends attend + cancel: func() { canceled = true }, + queue: func(s string) { queued = append(queued, s) }, + }); err != errTurnOver { + t.Fatalf("attendTurn err = %v, want errTurnOver", err) + } + if len(queued) != 1 || queued[0] != "fix it" { + t.Fatalf("Enter must queue the message, got %v", queued) + } + if !canceled { + t.Fatal("bare Escape must cancel the turn") + } +} + // TestInputCapClampsToTerminal: the editor height honors the dial but never // grows past what the terminal can hold under the dividers and status. Breaker: // drop the t.rows-reserve clamp and a 7-row terminal returns 6.