Compare commits

..
Author SHA1 Message Date
Gnanam da1224f45c fix: address CodeRabbit review comments on PR #352
- model.go: the first Esc (arming the cancel confirmation) was clearing the
  composer draft immediately, even though nothing had actually been
  cancelled yet — a leftover from the old single-press behavior, where
  clearing the draft and cancelling the run happened atomically as one
  action. Now that cancellation is two presses, only the confirming second
  Esc (or a plain Esc with no run pending) clears the composer; arming
  preserves whatever the user was typing, mirroring how Ctrl+C already
  preserves a draft (TestCtrlCClearsComposerBeforeExitConfirmation).

- clipboard.go: routePaste (bracketed paste and right-click paste) never
  disarmed a stale cancelConfirmActive — it isn't a tea.KeyPressMsg, so it
  bypassed the generic "any non-Esc key disarms it" hook added in the
  previous commit. A paste is a deliberate action like typing or clicking,
  so it should disarm the confirmation too; otherwise a later, unrelated
  Esc could silently cancel the run.

New tests: TestEscArmingCancelConfirmationPreservesComposerDraft,
TestPasteDisarmsCancelConfirmation.
2026-07-01 14:59:15 +05:30
Gnanam 592b2cb445 fix: pre-push review findings on the Esc cancel-confirmation gate
Ran an adversarial review before opening the PR. Three independent
reviewers converged on the same root defect: unlike exitConfirmActive
(disarmed by any key other than Ctrl+C), cancelConfirmActive had no
"anything else disarms it" safety net, so it behaved as a plain 3s timer
rather than a true immediate-second-press gate. Concretely:

- Typing, Ctrl+O, or any other non-Esc key left it armed, so a later,
  unrelated Esc press could silently cancel the run.
- A mid-turn ask_user or permission prompt lands asynchronously and is
  checked earlier in the Esc handler than the cancel-confirm block, so it
  silently "stole" the confirming press (denying the prompt instead) while
  leaving cancelConfirmActive armed for a subsequent, unrelated Esc to act
  on.
- Mouse clicks resolving a permission prompt or entering a subchat had the
  same gap.

Fixed by mirroring the Ctrl+C pattern: any non-Esc key disarms it (model.go,
next to the existing exitConfirmAction reset), and any left/right mouse
click disarms it too (mouse.go — scoped to clicks, not hover/motion, since
motion fires continuously and would otherwise defeat the window whenever
the mouse sits over the terminal). Within the Esc handler itself, restructured
to capture whether this press was really a confirm *before* any of the
earlier branches (subchat, MCP wizard, ask-user, permission, picker,
suggestions, queued-message, detailed-transcript) can run, then disarm
unconditionally — so an Esc consumed by any of those no longer leaves a
stale confirmation for a later press to trip.

Also updated two now-stale help strings the review flagged: the `?`
overlay's Esc row and pickerBusyText()'s "press Esc to cancel" hint, both
of which still described the old single-press behavior.

Added TestEscCancelConfirmationDisarmsOnInterveningKey (mirrors the
existing Ctrl+C equivalent) and
TestEscCancelConfirmationDisarmsWhenStolenByAskUser covering the two
confirmed gaps.
2026-07-01 14:12:09 +05:30
Gnanam 0aaf57064c fix(tui): require a second Esc press to cancel a running turn
A single stray Esc during an active run cancelled it instantly with no
chance to reconsider - e.g. a press meant to dismiss an overlay that had
already closed. Ctrl+C already guards app-exit with a double-press
confirmation (exitConfirmActive/exitConfirmSeq); Esc now gets its own
independent confirmation (cancelConfirmActive/cancelConfirmSeq) guarding
run-cancellation specifically, since the two are different actions with
different consequences armed by different keys.

Mirrors the existing seq-gated tea.Tick pattern: first Esc arms a 3s
window and shows "Press Esc again to cancel" in the footer; a second Esc
within the window cancels the run; letting it expire, or the run finishing
on its own, disarms without cancelling.

Updated the three existing tests that assumed single-press cancellation
(TestEscCancelsPendingRun -> TestEscRequiresSecondPressToCancelPendingRun,
TestEscCancellationLeavesVisibleMarker, TestEscCancelRecordsSessionError,
TestCancelledRunFlushesCheckpointSessionEvents) and added
TestEscCancelConfirmationExpires mirroring the Ctrl+C expiry test.
2026-07-01 13:46:45 +05:30
14 changed files with 378 additions and 249 deletions

No files matched your search

+54 -134
View File
@@ -28,17 +28,8 @@ const (
completedSessionRetention = 30 * time.Second
maxExecSessions = 64
recentExecOutputBytes = 4096
// maxExecOutputBufferBytes caps the undrained output an unpolled session can
// accumulate. Without a cap, a long-lived background session nobody polls
// again (e.g. a dev server left running after its initiating run was
// cancelled) grows this buffer forever as long as the process keeps writing
// output, with no ceiling — this previously ran a session's memory into the
// tens of gigabytes over several hours and got the whole zero process
// OOM-killed by the OS.
maxExecOutputBufferBytes = 2 * 1024 * 1024
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
execOutputBufferTruncatedMessage = "[zero] output buffer truncated: undrained output exceeded 2MiB, oldest output dropped"
execSessionStopTimeout = 3 * time.Second
execSessionEvictedMessage = "[zero] session evicted: too many background terminals\n"
)
type execSessionManager struct {
@@ -242,10 +233,6 @@ type ExecSessionSnapshot struct {
Status string
ExitCode *int
RecentOutput string
// OutputTruncated reflects the buffer's last-known truncation state (see
// execOutputBuffer.peekTruncated), so a session listing (/ps) still shows
// this even if the session is reaped before ever being polled again.
OutputTruncated bool
}
type ExecSessionController interface {
@@ -330,26 +317,24 @@ func (session *execSession) snapshot() ExecSessionSnapshot {
status = "exited"
}
return ExecSessionSnapshot{
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
OutputTruncated: session.output.peekTruncated(),
ID: session.id,
Command: session.commandText,
Cwd: session.cwd,
RelativeCwd: session.relativeCwd,
StartedAt: startedAt,
LastUsedAt: lastUsedAt,
TTY: session.tty,
Status: status,
ExitCode: copiedExit,
RecentOutput: session.output.recentString(),
}
}
type execOutputBuffer struct {
mu sync.Mutex
data []byte
recent []byte
truncated bool
notify chan struct{}
mu sync.Mutex
data []byte
recent []byte
notify chan struct{}
}
func newExecOutputBuffer() *execOutputBuffer {
@@ -359,10 +344,6 @@ func newExecOutputBuffer() *execOutputBuffer {
func (buffer *execOutputBuffer) Write(p []byte) (int, error) {
buffer.mu.Lock()
buffer.data = append(buffer.data, p...)
if len(buffer.data) > maxExecOutputBufferBytes {
buffer.data = buffer.data[len(buffer.data)-maxExecOutputBufferBytes:]
buffer.truncated = true
}
buffer.recent = append(buffer.recent, p...)
if len(buffer.recent) > recentExecOutputBytes {
buffer.recent = buffer.recent[len(buffer.recent)-recentExecOutputBytes:]
@@ -392,34 +373,6 @@ func (buffer *execOutputBuffer) drainString() string {
return out
}
// consumeTruncated reports whether the buffer has dropped output to stay
// within maxExecOutputBufferBytes since the last call, resetting the flag.
// Kept as an out-of-band signal rather than text embedded in drainString's
// result: an earlier version prefixed a notice directly into the drained
// string, but that notice always sat ~maxExecOutputBufferBytes (2MiB) before
// the end of the combined output, far outside the byte-budget head/tail
// window truncateExecOutput keeps afterward (at most 400KB even at the
// tool's maximum max_output_tokens) — so the notice was reliably swallowed
// or chopped by that second, unrelated truncation pass. Surfacing it as a
// separate bool lets the caller report it regardless of where in the byte
// stream the overflow happened.
func (buffer *execOutputBuffer) consumeTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
truncated := buffer.truncated
buffer.truncated = false
return truncated
}
// peekTruncated reports the buffer's last-known truncation state without
// clearing it, for status views (e.g. /ps) that shouldn't consume the signal
// a subsequent poll is still meant to report via consumeTruncated.
func (buffer *execOutputBuffer) peekTruncated() bool {
buffer.mu.Lock()
defer buffer.mu.Unlock()
return buffer.truncated
}
type execCommandTool struct {
baseTool
workspaceRoot string
@@ -532,28 +485,25 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if err != nil {
return errorResult("Error starting exec_command: " + err.Error())
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
if ctx != nil && ctx.Err() != nil && !session.doneClosed() {
session.terminate()
more, moreTruncated := session.collect(context.Background(), time.Second)
output += more
outputTruncated = outputTruncated || moreTruncated
output += session.collect(context.Background(), time.Second)
}
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: relativeCwd,
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -608,39 +558,26 @@ func (tool execCommandTool) startSession(commandText string, absoluteCwd string,
return session, nil
}
// collect drains the session's output buffer, returning the accumulated text
// and whether the buffer ever had to drop output to stay within
// maxExecOutputBufferBytes since the last collect call.
func (session *execSession) collect(ctx context.Context, wait time.Duration) (string, bool) {
func (session *execSession) collect(ctx context.Context, wait time.Duration) string {
if ctx == nil {
ctx = context.Background()
}
deadline := time.Now().Add(wait)
var builder strings.Builder
truncated := false
finish := func() (string, bool) {
if session.output.consumeTruncated() {
truncated = true
}
return builder.String(), truncated
}
for {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
if session.output.consumeTruncated() {
truncated = true
}
continue
}
if session.doneClosed() {
if chunk := session.output.drainString(); chunk != "" {
builder.WriteString(chunk)
}
return finish()
return builder.String()
}
remaining := time.Until(deadline)
if remaining <= 0 {
return finish()
return builder.String()
}
timer := time.NewTimer(remaining)
select {
@@ -665,9 +602,9 @@ func (session *execSession) collect(ctx context.Context, wait time.Duration) (st
default:
}
}
return finish()
return builder.String()
case <-timer.C:
return finish()
return builder.String()
}
}
}
@@ -764,23 +701,22 @@ func (tool writeStdinTool) RunWithOptions(ctx context.Context, args map[string]a
}
}
}
output, outputTruncated := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
output := session.collect(ctx, time.Duration(yieldTimeMS)*time.Millisecond)
exitCode, exited := session.exitStatus()
if exited {
tool.manager.remove(session.id)
}
return execToolResult(execToolResultInput{
commandText: session.commandText,
output: output,
outputBufferTruncated: outputTruncated,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
commandText: session.commandText,
output: output,
sessionID: session.id,
exitCode: exitCode,
exited: exited,
relativeCwd: session.relativeCwd,
tty: session.tty,
interrupted: interrupted,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
})
}
@@ -805,22 +741,16 @@ func shouldInterruptExecSession(chars string, tty bool) bool {
}
type execToolResultInput struct {
commandText string
output string
// outputBufferTruncated is true when the session's undrained output buffer
// had to drop bytes to stay within maxExecOutputBufferBytes since it was
// last collected — data that is gone for good, unlike the head/tail
// truncation below (which the caller can recover by polling again or
// raising max_output_tokens).
outputBufferTruncated bool
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
commandText string
output string
sessionID int
exitCode int
exited bool
relativeCwd string
tty bool
interrupted bool
plan zeroSandbox.CommandPlan
maxOutputTokens int
}
func execToolResult(input execToolResultInput) Result {
@@ -841,26 +771,16 @@ func execToolResult(input execToolResultInput) Result {
} else {
meta["session_id"] = strconv.Itoa(input.sessionID)
}
if input.outputBufferTruncated {
meta["output_buffer_truncated"] = "true"
}
status := StatusOK
if input.exited && ((input.exitCode != 0 && !input.interrupted) || meta[SandboxLikelyDeniedMeta] == "true") {
status = StatusError
}
body := formatExecCommandOutput(output, input.sessionID, input.exited, input.exitCode, input.interrupted)
if input.outputBufferTruncated {
// Appended after truncateExecOutput's own head/tail slicing, not
// embedded in the text that goes through it — a marker inside that
// text can land in the discarded middle or get chopped at a small
// max_output_tokens budget. Appending here guarantees it survives.
body += "\n" + execOutputBufferTruncatedMessage
}
return Result{
Status: status,
Output: body,
Truncated: truncated || input.outputBufferTruncated,
Truncated: truncated,
Meta: meta,
Display: Display{
Summary: execDisplaySummary(input.commandText, input.sessionID, input.exited, input.exitCode),
-87
View File
@@ -363,93 +363,6 @@ func TestStartExecProcessFallsBackAfterPTYStartMutation(t *testing.T) {
}
}
// TestExecOutputBufferCapsUndrainedData: a session nobody polls must not grow
// its undrained buffer without bound — a long-lived background process that
// keeps writing while unpolled previously ran a session's memory into the
// tens of gigabytes and got the whole zero process OOM-killed by the OS.
func TestExecOutputBufferCapsUndrainedData(t *testing.T) {
buffer := newExecOutputBuffer()
chunk := strings.Repeat("x", 1024)
writes := (maxExecOutputBufferBytes / len(chunk)) + 10 // comfortably over the cap
for i := 0; i < writes; i++ {
if _, err := buffer.Write([]byte(chunk)); err != nil {
t.Fatalf("Write: %v", err)
}
}
buffer.mu.Lock()
dataLen := len(buffer.data)
buffer.mu.Unlock()
if dataLen > maxExecOutputBufferBytes {
t.Fatalf("undrained buffer grew to %d bytes, want <= %d", dataLen, maxExecOutputBufferBytes)
}
out := buffer.drainString()
// drainString itself carries no truncation marker: a marker embedded in
// the drained text would always sit ~maxExecOutputBufferBytes before the
// end of the string, past any realistic head/tail truncation window a
// caller applies afterward (see execToolResult), so it's reliably lost.
// The signal lives out of band on consumeTruncated/peekTruncated instead.
if !strings.HasSuffix(out, chunk) {
t.Fatal("drained output should keep the most recent bytes, not the oldest")
}
if len(out) > maxExecOutputBufferBytes {
t.Fatalf("drained output = %d bytes, want <= %d (no marker text mixed in)", len(out), maxExecOutputBufferBytes)
}
if !buffer.peekTruncated() {
t.Fatal("peekTruncated should report the overflow without clearing it")
}
if !buffer.peekTruncated() {
t.Fatal("a second peekTruncated call should still report it — peek must not consume")
}
if !buffer.consumeTruncated() {
t.Fatal("consumeTruncated should report the overflow")
}
if buffer.consumeTruncated() {
t.Fatal("consumeTruncated should reset after being read once")
}
if buffer.peekTruncated() {
t.Fatal("peekTruncated should reflect the reset state after consumeTruncated")
}
// A second drain with no intervening writes must be empty.
if got := buffer.drainString(); got != "" {
t.Fatalf("drainString after a full drain = %q, want empty", got)
}
}
// TestExecToolResultSurfacesBufferTruncationOutsideByteBudget: an earlier
// version embedded the truncation notice directly in the drained text, which
// a review found sits ~maxExecOutputBufferBytes before the end of the
// string — far past the head/tail window truncateExecOutput keeps even at
// the tool's smallest allowed max_output_tokens, so the notice was reliably
// swallowed or chopped. The notice must survive regardless of how small the
// byte budget is, and it must not count against that budget.
func TestExecToolResultSurfacesBufferTruncationOutsideByteBudget(t *testing.T) {
hugeOutput := strings.Repeat("y", maxExecOutputBufferBytes+1024)
result := execToolResult(execToolResultInput{
commandText: "cmd",
output: hugeOutput,
outputBufferTruncated: true,
sessionID: 1,
exited: false,
maxOutputTokens: 1, // the schema's own declared minimum
})
if !strings.Contains(result.Output, execOutputBufferTruncatedMessage) {
t.Fatalf("result output should contain the buffer-truncation notice even at the smallest max_output_tokens, got %q", result.Output[:min(len(result.Output), 200)])
}
if result.Meta["output_buffer_truncated"] != "true" {
t.Fatalf("result meta should flag output_buffer_truncated, got %#v", result.Meta)
}
if !result.Truncated {
t.Fatal("result should report Truncated when the buffer dropped output")
}
}
// resilientTempDir is like t.TempDir() but tolerates the Windows handle-release
// lag: a SIGKILL'd child process that had the dir as its cwd may not have
// released it the instant it is reaped, so the immediate RemoveAll t.TempDir()
-3
View File
@@ -146,9 +146,6 @@ func formatBackgroundTerminalRow(session tools.ExecSessionSnapshot, now time.Tim
}
preview := compactCommandOutputText(session.RecentOutput)
prefix := fmt.Sprintf("%d · %s · %s · %s", session.ID, session.Status, age, cwd)
if session.OutputTruncated {
prefix += " · output truncated"
}
if preview != "" {
return prefix + " · " + command + " · " + preview
}
+7
View File
@@ -56,6 +56,13 @@ func pasteFromClipboardCmd() tea.Cmd {
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste; empty content is a no-op.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
// cancel-confirmation. tea.PasteMsg/clipboardReadMsg aren't
// tea.KeyPressMsg, so they don't go through that generic keypress hook;
// do it once here, before any early return, so both the bracketed-paste
// and right-click-paste paths are covered uniformly.
m = m.disarmCancelConfirmation()
if content == "" {
// Empty text clipboard — the user may have pasted a screenshot.
// Probe the OS clipboard for image content asynchronously.
+2
View File
@@ -122,6 +122,8 @@ func TestEscCancellationLeavesVisibleMarker(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !transcriptContains(next.transcript, "Run cancelled.") {
t.Fatalf("expected visible cancellation marker, got %#v", next.transcript)
}
+1 -1
View File
@@ -31,7 +31,7 @@ var keybindingGroups = []keybindingGroup{
bindings: []keybinding{
{"Enter", "send the message"},
{"Alt+Enter", "insert a newline (multi-line compose)"},
{"Esc", "cancel the run / dismiss a popup / clear the input"},
{"Esc (×2)", "cancel the run / dismiss a popup / clear the input"},
{"Ctrl+C", "cancel the run, then quit"},
{"?", "show this help (on an empty input)"},
},
+70 -3
View File
@@ -40,6 +40,15 @@ const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
@@ -296,6 +305,14 @@ type model struct {
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
@@ -358,6 +375,12 @@ type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
@@ -849,6 +872,14 @@ func (m model) disarmExitConfirmation() model {
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
@@ -927,6 +958,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
@@ -974,6 +1010,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
@@ -996,6 +1039,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
@@ -1056,11 +1108,24 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
m.cancelRun()
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
@@ -1610,6 +1675,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
@@ -3807,7 +3873,8 @@ func (m *model) cancelRun() {
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
+203 -7
View File
@@ -1108,7 +1108,87 @@ func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
}
}
func TestEscCancelsPendingRun(t *testing.T) {
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
@@ -1117,15 +1197,131 @@ func TestEscCancelsPendingRun(t *testing.T) {
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if !cancelled {
t.Fatal("expected Esc to cancel pending run")
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
if next.pending {
t.Fatal("expected Esc to clear pending state")
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
+9
View File
@@ -40,6 +40,15 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.providerWizard != nil && m.providerWizard.oauthPending {
return m, nil
}
// A click is a deliberate action, same as a keypress — it means the user
// moved on to something else, so it disarms a stale Esc cancel-confirmation
// rather than leaving it armed for some later, unrelated Esc to act on.
// Scoped to actual clicks (not hover/motion/wheel), since motion events fire
// continuously while the mouse merely sits over the terminal and would
// otherwise defeat the confirmation window on every render tick.
if mouseLeftPress(msg) || mouseRightPress(msg) {
m = m.disarmCancelConfirmation()
}
// A right-click pastes the clipboard straight into the focused field — no
// menu. pasteFromClipboardCmd reads the clipboard off the Update goroutine; the
// clipboardReadMsg result is routed by routePaste to wherever input is focused
+1 -1
View File
@@ -36,7 +36,7 @@ func pickerBusyText(name string) string {
Title: "Busy",
Lines: []string{"Can't change " + label + " while a run is in progress."},
}},
Hints: []string{"press Esc to cancel the run, then try again"},
Hints: []string{"press Esc twice to cancel the run, then try again"},
})
}
+6 -1
View File
@@ -775,6 +775,8 @@ func TestEscCancelRecordsSessionError(t *testing.T) {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
@@ -836,7 +838,10 @@ func TestCancelledRunFlushesCheckpointSessionEvents(t *testing.T) {
next = updated.(model)
if _, ok := runtimeMsg.(permissionRequestMsg); ok {
// Cancel mid-run via Esc while the permission prompt is pending: this
// unblocks the goroutine through ctx cancellation.
// unblocks the goroutine through ctx cancellation. Esc now requires a
// second press within the confirmation window to actually cancel.
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
cancelled = true
+3 -3
View File
@@ -32,9 +32,9 @@ func TestStreamingFadePaletteMonotonic(t *testing.T) {
// so per-bucket comparisons are byte-stable. The endpoint byte
// values are the contract — the hex strings are not.
fresh := rgbaOf(t, palette[0])
// Sanity: palette[0] must equal the accent endpoint (#caff3f).
if fresh.R != 0xca || fresh.G != 0xff || fresh.B != 0x3f {
t.Errorf("palette[0] RGBA = %v, want {R:0xca, G:0xff, B:0x3f} (accent)", fresh)
// Sanity: palette[0] must equal the accent endpoint (#759523).
if fresh.R != 0x75 || fresh.G != 0x95 || fresh.B != 0x23 {
t.Errorf("palette[0] RGBA = %v, want {R:0x75, G:0x95, B:0x23} (accent)", fresh)
}
last := rgbaOf(t, palette[streamingFadeSteps-1])
if last == fresh {
+17 -9
View File
@@ -129,15 +129,23 @@ type palette struct {
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
// painted — so no token references it.
var darkPalette = palette{
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
accent: "#caff3f",
panel: "#0e0e10",
promptBg: "#262626",
line: "#242429",
line2: "#414147",
ink: "#ececee",
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
// Brand lime, softened from the original #caff3f (L 62%, S 100% — fully
// saturated, which read as glaring/neon everywhere it filled a block or
// bold-text surface: the composer prompt glyph, the permission popup's
// focused-option badge, the text-selection highlight). Two prior passes
// (#ade619 at L50/S80, #8db620 at L42/S70) were each confirmed too subtle
// in turn; settled at L 36%/S 62% — a darker, more olive lime. Contrast
// vs both black (onAccent) and the near-black panel stays comfortably
// above WCAG AA (~6.1:1 and ~5.6:1) with real margin to spare.
accent: "#759523",
green: "#5dd1a4",
red: "#ff7a7a",
amber: "#ffc25c",
+5
View File
@@ -201,6 +201,9 @@ func (m model) statusLine(width int) string {
if m.exitConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(ctrlCExitConfirmText), width)
}
if m.cancelConfirmActive {
return fitStyledLine(prefix+zeroTheme.amber.Render("●")+" "+zeroTheme.amber.Render(escCancelConfirmText), width)
}
return fitStyledLine(left, width)
}
@@ -210,6 +213,8 @@ func (m model) statusLine(width int) string {
}
if m.exitConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
left = prefix + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(escCancelConfirmText)
} else if summary := m.backgroundTerminalSummary(); summary != "" {
left += separator + zeroTheme.muted.Render(summary)
}