Merge pull request #1260 from manaflow-ai/task-zprezto-double-enter-lines
Fix Pure-style zsh prompt redraws
This commit is contained in:
commit
07dd39730a
5 changed files with 439 additions and 4 deletions
|
|
@ -12,7 +12,7 @@ When we change the fork, update this document and the parent submodule SHA.
|
|||
|
||||
## Current fork changes
|
||||
|
||||
Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026.
|
||||
Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026.
|
||||
|
||||
### 1) OSC 99 (kitty) notification parser
|
||||
|
||||
|
|
@ -45,8 +45,9 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 202
|
|||
|
||||
### 4) macOS resize stale-frame mitigation
|
||||
|
||||
Sections 3 and 4 are grouped by feature, not by commit order. The fork branch HEAD is the
|
||||
section 3 copy-mode commit, even though the section 4 resize commits were applied earlier.
|
||||
Sections 3 and 4 are grouped by feature, not by commit order. The section 4 resize commits were
|
||||
applied earlier than the section 3 copy-mode commit, but they are kept together here because they
|
||||
touch the same stale-frame mitigation path and tend to conflict in the same files during rebases.
|
||||
|
||||
- Commits:
|
||||
- `769bbf7a9` (macos: reduce transient blank/scaled frames during resize)
|
||||
|
|
@ -62,6 +63,26 @@ section 3 copy-mode commit, even though the section 4 resize commits were applie
|
|||
- Replays the last rendered frame during resize and keeps its geometry anchored correctly.
|
||||
- Reduces transient blank or scaled frames while a macOS window is being resized.
|
||||
|
||||
### 5) zsh prompt redraw markers use OSC 133 P
|
||||
|
||||
- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws)
|
||||
- Files:
|
||||
- `src/shell-integration/zsh/ghostty-integration`
|
||||
- Summary:
|
||||
- Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions.
|
||||
- Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines.
|
||||
|
||||
### 6) zsh Pure-style multiline prompt redraws
|
||||
|
||||
- Commit: `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
|
||||
- Files:
|
||||
- `src/shell-integration/zsh/ghostty-integration`
|
||||
- Summary:
|
||||
- Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line.
|
||||
- Places the continuation marker after Pure's hidden carriage return so async redraws do not leave stale preprompt lines behind.
|
||||
|
||||
The fork branch HEAD is now the section 6 zsh redraw commit.
|
||||
|
||||
## Upstreamed fork changes
|
||||
|
||||
### cursor-click-to-move respects OSC 133 click-to-move
|
||||
|
|
@ -80,4 +101,9 @@ These files change frequently upstream; be careful when rebasing the fork:
|
|||
- `src/terminal/osc.zig`
|
||||
- OSC dispatch logic moves often. Re-check the integration points for the OSC 99 parser.
|
||||
|
||||
- `src/shell-integration/zsh/ghostty-integration`
|
||||
- Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the
|
||||
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes, and preserve the special
|
||||
handling for Pure-style `\n%{\r%}` prompt newlines.
|
||||
|
||||
If you resolve a conflict, update this doc with what changed.
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec
|
||||
Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe
|
||||
|
|
@ -3,3 +3,4 @@
|
|||
# Format: <ghostty_sha> <sha256>
|
||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||
|
|
|
|||
175
tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
Normal file
175
tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: zsh prompt redraws should not replay fresh-line OSC 133;A markers.
|
||||
|
||||
Prompt themes with async redraws (such as Prezto-like setups) can call
|
||||
`zle reset-prompt` after the prompt is already visible. Ghostty's zsh shell
|
||||
integration should emit a single fresh prompt mark for the actual prompt, then
|
||||
use OSC 133;P for redraws so redraws stay in place instead of looking like
|
||||
extra prompt lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FRESH_PROMPT = b"\x1b]133;A;cl=line\x07"
|
||||
PROMPT_START = b"\x1b]133;P;k=i\x07"
|
||||
END_COMMAND = b"\x1b]133;D\x07"
|
||||
START_OUTPUT = b"\x1b]133;C\x07"
|
||||
|
||||
|
||||
def _write_redrawing_zshrc(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""
|
||||
autoload -Uz add-zsh-hook
|
||||
|
||||
setopt prompt_cr prompt_percent prompt_sp prompt_subst
|
||||
PROMPT='%F{4}%1~%f %# '
|
||||
RPROMPT=''
|
||||
|
||||
typeset -gi _cmux_redraw_done=0
|
||||
typeset -g _cmux_redraw_fd=''
|
||||
|
||||
_cmux_redraw_precmd() {
|
||||
_cmux_redraw_done=0
|
||||
}
|
||||
|
||||
_cmux_redraw_ready() {
|
||||
emulate -L zsh
|
||||
local fd="${1:-$_cmux_redraw_fd}"
|
||||
if [[ -n "$fd" ]]; then
|
||||
zle -F "$fd"
|
||||
exec {fd}<&-
|
||||
fi
|
||||
_cmux_redraw_fd=''
|
||||
(( _cmux_redraw_done )) && return 0
|
||||
_cmux_redraw_done=1
|
||||
zle reset-prompt
|
||||
}
|
||||
|
||||
_cmux_redraw_line_init() {
|
||||
if (( !_cmux_redraw_done )) && [[ -z "$_cmux_redraw_fd" ]]; then
|
||||
exec {_cmux_redraw_fd}< <(
|
||||
sleep 0.05
|
||||
printf 'ready\\n'
|
||||
)
|
||||
zle -F "$_cmux_redraw_fd" _cmux_redraw_ready
|
||||
fi
|
||||
}
|
||||
|
||||
add-zsh-hook precmd _cmux_redraw_precmd
|
||||
zle -N zle-line-init _cmux_redraw_line_init
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _capture_session(env: dict[str, str], zsh_path: str) -> bytes:
|
||||
master, slave = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
[zsh_path, "-d", "-i"],
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=slave,
|
||||
env=env,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave)
|
||||
|
||||
output = bytearray()
|
||||
start = time.time()
|
||||
phase = 0
|
||||
try:
|
||||
while time.time() - start < 5:
|
||||
readable, _, _ = select.select([master], [], [], 0.2)
|
||||
if master in readable:
|
||||
try:
|
||||
chunk = os.read(master, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
|
||||
elapsed = time.time() - start
|
||||
if phase == 0 and elapsed > 1.0:
|
||||
os.write(master, b"\n")
|
||||
phase = 1
|
||||
elif phase == 1 and elapsed > 2.5:
|
||||
os.write(master, b"exit\n")
|
||||
phase = 2
|
||||
finally:
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
finally:
|
||||
os.close(master)
|
||||
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh"
|
||||
if not (wrapper_dir / ".zshenv").exists():
|
||||
print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
|
||||
return 0
|
||||
|
||||
zsh_path = shutil.which("zsh")
|
||||
if zsh_path is None:
|
||||
print("SKIP: zsh not installed")
|
||||
return 0
|
||||
|
||||
base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_prompt_redraw_"))
|
||||
try:
|
||||
home = base / "home"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
_write_redrawing_zshrc(home / ".zshrc")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["HOME"] = str(home)
|
||||
env["ZDOTDIR"] = str(wrapper_dir)
|
||||
env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
|
||||
env["GHOSTTY_RESOURCES_DIR"] = str(root / "ghostty" / "src")
|
||||
env.pop("GHOSTTY_SHELL_FEATURES", None)
|
||||
env.pop("GHOSTTY_BIN_DIR", None)
|
||||
|
||||
output = _capture_session(env, zsh_path)
|
||||
|
||||
marker = output.find(END_COMMAND)
|
||||
if marker == -1:
|
||||
print("FAIL: did not observe OSC 133;D for the empty command prompt cycle")
|
||||
return 1
|
||||
|
||||
end = output.find(START_OUTPUT, marker + len(END_COMMAND))
|
||||
if end == -1:
|
||||
end = len(output)
|
||||
|
||||
prompt_cycle = output[marker:end]
|
||||
fresh_count = prompt_cycle.count(FRESH_PROMPT)
|
||||
prompt_start_count = prompt_cycle.count(PROMPT_START)
|
||||
|
||||
if fresh_count != 1:
|
||||
print(f"FAIL: expected exactly 1 fresh prompt marker after redraw, saw {fresh_count}")
|
||||
return 1
|
||||
|
||||
if prompt_start_count < 1:
|
||||
print("FAIL: expected redraw path to emit OSC 133;P prompt-start markers")
|
||||
return 1
|
||||
|
||||
print("PASS: zsh prompt redraws keep a single fresh prompt marker and reuse OSC 133;P")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
233
tests/test_ghostty_zsh_pure_preprompt_redraw.py
Normal file
233
tests/test_ghostty_zsh_pure_preprompt_redraw.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: Ghostty's zsh integration must not leave stale Pure-style preprompt
|
||||
lines behind after an async redraw.
|
||||
|
||||
Pure does not render its top path/git line as a static multiline PS1. Instead,
|
||||
it rewrites PROMPT with a special newline sequence and later calls
|
||||
`zle .reset-prompt` when async git info arrives. Plain zsh redraws that cleanly.
|
||||
The Ghostty integration currently leaves stale copies of the old top line behind.
|
||||
|
||||
This test uses a minimal Pure-like prompt implementation as a control:
|
||||
- plain zsh must redraw without stale preprompt lines
|
||||
- Ghostty-integrated zsh must match that behavior
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_MINIMAL_PURE_ZSHRC = r"""
|
||||
setopt promptsubst nopromptcr nopromptsp
|
||||
prompt_newline=$'\n%{\r%}'
|
||||
|
||||
typeset -g CMUX_TOP='%F{4}%~%f'
|
||||
typeset -g CMUX_LAST_PROMPT=''
|
||||
typeset -gi CMUX_ASYNC_DONE=0
|
||||
typeset -g CMUX_ASYNC_FD=''
|
||||
|
||||
cmux_render_prompt() {
|
||||
local cleaned_ps1=$PROMPT
|
||||
if [[ $PROMPT = *$prompt_newline* ]]; then
|
||||
cleaned_ps1=${PROMPT##*${prompt_newline}}
|
||||
fi
|
||||
|
||||
PROMPT="${CMUX_TOP}${prompt_newline}${cleaned_ps1:-%F{5}❯%f }"
|
||||
|
||||
local expanded_prompt="${(S%%)PROMPT}"
|
||||
if [[ ${1:-} == precmd ]]; then
|
||||
print
|
||||
elif [[ $CMUX_LAST_PROMPT != $expanded_prompt ]]; then
|
||||
zle && zle .reset-prompt
|
||||
fi
|
||||
typeset -g CMUX_LAST_PROMPT=$expanded_prompt
|
||||
}
|
||||
|
||||
cmux_async_ready() {
|
||||
emulate -L zsh
|
||||
local fd="${1:-$CMUX_ASYNC_FD}"
|
||||
if [[ -n $fd ]]; then
|
||||
zle -F "$fd"
|
||||
exec {fd}<&-
|
||||
fi
|
||||
CMUX_ASYNC_FD=''
|
||||
|
||||
(( CMUX_ASYNC_DONE )) && return
|
||||
CMUX_ASYNC_DONE=1
|
||||
CMUX_TOP='%F{4}%~%f %F{242}main%f%F{218}*%f %F{6}⇣⇡%f'
|
||||
cmux_render_prompt async
|
||||
}
|
||||
|
||||
precmd() {
|
||||
CMUX_ASYNC_DONE=0
|
||||
cmux_render_prompt precmd
|
||||
}
|
||||
|
||||
cmux_line_init() {
|
||||
if (( !CMUX_ASYNC_DONE )) && [[ -z $CMUX_ASYNC_FD ]]; then
|
||||
exec {CMUX_ASYNC_FD}< <(
|
||||
sleep 0.05
|
||||
printf 'ready\n'
|
||||
)
|
||||
zle -F "$CMUX_ASYNC_FD" cmux_async_ready
|
||||
fi
|
||||
}
|
||||
|
||||
zle -N zle-line-init cmux_line_init
|
||||
PROMPT='%F{5}❯%f '
|
||||
""".lstrip()
|
||||
|
||||
_ANSI_RE = re.compile(rb"\x1b\][^\x07]*\x07|\x1b\[[0-9;?]*[ -/]*[@-~]|\r")
|
||||
|
||||
|
||||
def _capture_session(
|
||||
*,
|
||||
use_ghostty: bool,
|
||||
wrapper_dir: Path,
|
||||
resources_dir: Path,
|
||||
workdir: Path,
|
||||
zsh_path: str,
|
||||
) -> str:
|
||||
base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_pure_preprompt_"))
|
||||
try:
|
||||
home = base / "home"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / ".zshrc").write_text(_MINIMAL_PURE_ZSHRC, encoding="utf-8")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["HOME"] = str(home)
|
||||
env["TERM"] = "xterm-256color"
|
||||
env.pop("GHOSTTY_SHELL_FEATURES", None)
|
||||
env.pop("GHOSTTY_BIN_DIR", None)
|
||||
if use_ghostty:
|
||||
env["ZDOTDIR"] = str(wrapper_dir)
|
||||
env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
|
||||
env["GHOSTTY_RESOURCES_DIR"] = str(resources_dir)
|
||||
else:
|
||||
env["ZDOTDIR"] = str(home)
|
||||
env.pop("GHOSTTY_ZSH_ZDOTDIR", None)
|
||||
env.pop("GHOSTTY_RESOURCES_DIR", None)
|
||||
|
||||
master, slave = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
[zsh_path, "-d", "-i"],
|
||||
cwd=str(workdir),
|
||||
stdin=slave,
|
||||
stdout=slave,
|
||||
stderr=slave,
|
||||
env=env,
|
||||
close_fds=True,
|
||||
)
|
||||
os.close(slave)
|
||||
|
||||
output = bytearray()
|
||||
start = time.time()
|
||||
phase = 0
|
||||
try:
|
||||
while time.time() - start < 4.5:
|
||||
readable, _, _ = select.select([master], [], [], 0.2)
|
||||
if master in readable:
|
||||
try:
|
||||
chunk = os.read(master, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
|
||||
elapsed = time.time() - start
|
||||
if phase == 0 and elapsed > 1.2:
|
||||
os.write(master, b"\n")
|
||||
phase = 1
|
||||
elif phase == 1 and elapsed > 2.8:
|
||||
os.write(master, b"exit\n")
|
||||
phase = 2
|
||||
finally:
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
finally:
|
||||
os.close(master)
|
||||
|
||||
cleaned = _ANSI_RE.sub(b"", bytes(output)).decode("utf-8", errors="replace")
|
||||
return cleaned
|
||||
finally:
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
|
||||
|
||||
def _stale_preprompt_lines(cleaned: str, path_line: str, async_line: str) -> tuple[int, int]:
|
||||
marker = cleaned.find(async_line)
|
||||
if marker == -1:
|
||||
return (-1, -1)
|
||||
|
||||
tail = cleaned[marker + len(async_line) :]
|
||||
return (tail.count(path_line), tail.count(async_line))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh"
|
||||
resources_dir = root / "ghostty" / "src"
|
||||
workdir = root
|
||||
|
||||
if not (wrapper_dir / ".zshenv").exists():
|
||||
print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
|
||||
return 0
|
||||
zsh_path = shutil.which("zsh")
|
||||
if zsh_path is None:
|
||||
print("SKIP: zsh not installed")
|
||||
return 0
|
||||
|
||||
path_line = f"{workdir}\n"
|
||||
async_line = f"{workdir} main* ⇣⇡"
|
||||
|
||||
plain = _capture_session(
|
||||
use_ghostty=False,
|
||||
wrapper_dir=wrapper_dir,
|
||||
resources_dir=resources_dir,
|
||||
workdir=workdir,
|
||||
zsh_path=zsh_path,
|
||||
)
|
||||
ghostty = _capture_session(
|
||||
use_ghostty=True,
|
||||
wrapper_dir=wrapper_dir,
|
||||
resources_dir=resources_dir,
|
||||
workdir=workdir,
|
||||
zsh_path=zsh_path,
|
||||
)
|
||||
|
||||
plain_stale, plain_async = _stale_preprompt_lines(plain, path_line, async_line)
|
||||
ghostty_stale, ghostty_async = _stale_preprompt_lines(ghostty, path_line, async_line)
|
||||
|
||||
if plain_stale < 0:
|
||||
print("FAIL: plain zsh control never rendered the async preprompt line")
|
||||
return 1
|
||||
if ghostty_stale < 0:
|
||||
print("FAIL: Ghostty zsh integration never rendered the async preprompt line")
|
||||
return 1
|
||||
|
||||
if plain_stale != 0:
|
||||
print(f"FAIL: plain zsh control left stale preprompt lines behind ({plain_stale})")
|
||||
return 1
|
||||
|
||||
if ghostty_stale != plain_stale:
|
||||
print(
|
||||
"FAIL: Ghostty zsh integration left stale preprompt lines behind "
|
||||
f"(ghostty={ghostty_stale}, plain={plain_stale}, async_renders={ghostty_async})"
|
||||
)
|
||||
return 1
|
||||
|
||||
print("PASS: Ghostty zsh integration redraws Pure-style preprompts without stale lines")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue