mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1224f45c | ||
|
|
592b2cb445 | ||
|
|
0aaf57064c |
No files matched your search
+54
-134
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user