Skip to content

fix(exec): clear View before releasing terminal for subprocess - #1762

Open
zhaoxinyi02 wants to merge 1 commit into
charmbracelet:mainfrom
zhaoxinyi02:fix/exec-clear-view-before-release
Open

fix(exec): clear View before releasing terminal for subprocess#1762
zhaoxinyi02 wants to merge 1 commit into
charmbracelet:mainfrom
zhaoxinyi02:fix/exec-clear-view-before-release

Conversation

@zhaoxinyi02

Copy link
Copy Markdown

Summary

Fixes #431.

When ExecProcess (or Exec) runs a subprocess, the renderer's final flush wrote the current View() output to stdout before handing the terminal to the subprocess. In inline (non-alt-screen) mode this content persisted in the terminal after the subprocess exited, which is why the documented workaround is to return an empty string from View() right before issuing ExecProcess.

Root cause

exec() calls releaseTerminal(false), which calls stopRenderer(false) -> renderer.flush(true). That closing flush emits the current view to the output. The view rendered for the message that triggered the exec is therefore written to the terminal immediately before the subprocess takes over.

Concretely, with a program whose View() returns EXEC_MARKER, the output captured around the exec hand-off is:

\x1b[?25l...\r\x1b[JEXEC_MARKER\x1b[>4m...\r\x1b[J\x1b[?25h\x1b[?2004l

The EXEC_MARKER content is written to stdout and (for multi-line views, where close() only erases from the last line down) persists after the subprocess exits.

The fix

Blank the current view in exec() before releasing the terminal, so the closing flush has no content to write:

func (p *Program) exec(c ExecCommand, fn ExecCallback) {
	if p.renderer != nil {
		p.renderer.clearView()
	}
	if err := p.releaseTerminal(false); err != nil {
		...

clearView is a new renderer method that clears Content and Cursor while preserving terminal-mode flags such as AltScreen.

Why preserve AltScreen

close() and start() key alt-screen exit/re-enter off lastView.AltScreen:

  • close() only writes the exit-alt-screen sequence when lastView.AltScreen is true.
  • start() only re-enters the alt screen on resume when lastView.AltScreen is true.

A plain render(View{}) flips AltScreen to false, so on resume start() skips re-entering the alt screen and it is only re-entered on the next render flush - a visible flicker, and the screen-mode bookkeeping diverges from the non-exec path. Preserving the flag keeps the alt-screen exit/re-enter symmetric with normal suspend/resume and avoids the flicker.

Tests

  • TestExecProcessDoesNotLeakViewOutput - inline program whose View() returns a marker until the subprocess finishes and a different marker afterwards; asserts the pre-exec marker never reaches stdout while the post-exec view still renders. Uses WithFPS(1) so the only flushes during the short test are the exec hand-off and shutdown, keeping the assertion deterministic.
  • TestExecProcessAltscreenNoLeak - same in alt-screen mode; asserts no content leak and that alt-screen enter/leave sequences stay balanced (the terminal is left in a clean state).

Both tests fail on main and pass with this change. go test ./..., go vet, gofumpt, and goimports are clean.

Notes

There is an older open PR (#1687) that addresses the same symptom by rendering an empty View{}. This change takes a different approach - clearing content via clearView while preserving AltScreen - to avoid the alt-screen resume flicker described above, and adds the regression tests that #1687 lacks. Happy to coordinate/close in favor of whichever the maintainers prefer.

Checklist

  • Code is readable and follows existing conventions
  • go test ./... passes
  • go vet, gofumpt, goimports clean
  • Regression tests added (fail before, pass after)

When ExecProcess (or Exec) runs a subprocess, the renderer's final flush
in releaseTerminal wrote the last View() output to stdout. In inline mode
this content persisted in the terminal after the subprocess exited,
forcing users to work around it by returning an empty string from View()
before issuing ExecProcess (see the workaround linked in charmbracelet#431).

Root cause: exec() calls releaseTerminal(false), which calls
stopRenderer(false) -> renderer.flush(true). That closing flush emits the
current view to the output, so the View() rendered for the message that
triggered the exec is written to the terminal right before the subprocess
takes over.

Fix: blank the current view in exec() before releasing the terminal, so
the closing flush has no content to write. The new renderer.clearView
clears Content and Cursor while preserving terminal-mode flags such as
AltScreen. Preserving AltScreen matters: close() and start() key screen
restoration off lastView.AltScreen, so a plain empty View{} (AltScreen
false) would skip re-entering the alt screen on resume and cause a
flicker. Preserving the flag keeps alt screen exit/re-enter symmetric
with the non-exec path.

Regression tests:
- TestExecProcessDoesNotLeakViewOutput: inline program whose View()
  returns a marker until the subprocess finishes; asserts the marker
  never reaches stdout (and the post-exec view still does).
- TestExecProcessAltscreenNoLeak: same in alt screen mode; asserts no
  leak and that alt screen enter/leave sequences stay balanced.

Fixes charmbracelet#431

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 10, 2026 08:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tea.ExecProcess writes View output to stdout before executing the command

2 participants