fix(pty): defer initial PTY resize to recover from startup TIOCGWINSZ race (Linux/Wayland)#140
Draft
aeriondyseti wants to merge 14 commits into
Draft
fix(pty): defer initial PTY resize to recover from startup TIOCGWINSZ race (Linux/Wayland)#140aeriondyseti wants to merge 14 commits into
aeriondyseti wants to merge 14 commits into
Conversation
…warning The OnceLock import in specs.rs is only used by the mach_absolute_time clock helpers, which are themselves cfg(target_os = "macos"). On Linux builds the import becomes unused and trips the compiler warning. Mirror the cfg gate on the import.
…nfig Three new [popup] keys give users control over popup appearance: - gutter_padding (u8, 0-8, default 1): trailing spaces between the kind icon and suggestion text. Total gutter width is 1 + 2 + padding cols. - fixed_width (bool, default false): when true, popup always renders at max_width (clamped to screen) instead of fitting the longest item. Also honored in the feedback-only path so Loading/Empty/Error popups don't jump width when results arrive. - truncation_indicator (String, default ""): appended to clipped text or description on overflow; e.g. "…" (1 col) or "..." (3 cols). Empty preserves the historical silent-truncation behavior. All three are hot-reloadable, exposed in the TUI config editor with clamp validation, and documented in the default config template. The static GUTTER_COLS layout constant is replaced by a theme-driven gutter_cols() that derives from gutter_padding, with ICON_BASE_COLS and DEFAULT_GUTTER_COLS preserving the historical 4-col default for the TUI preview path. A shared truncation_budget() helper dedupes the overflow-decision logic between write_highlighted_text and write_description, and short-circuits the per-row width scan when the indicator is empty (default config) so the hot path is unaffected. build_popup_theme now takes &PopupConfig instead of seven positional scalars, dropping the clippy::too_many_arguments allow.
When the user is mid-argument (Context::SpecArg) the popup previously mixed spec-declared flags into the candidate set alongside subcommands and arg values. Adds `suggest.flags_require_dash` (default true) so flags only surface once the user types `-` and falls into the FlagPrefix arm. Wired through the engine, handler, proxy builder, TUI field editor, and the default config template.
Local dev loop: uninstall shell integration, drop the cargo binary, rebuild from the current checkout, reinstall the integration block, and open the TUI config editor. Each cleanup step is best-effort (fresh checkouts have nothing to remove); the cargo install step is fatal so a failed rebuild does not strand the user without a binary.
cargo install refuses to overwrite a binary it does not have in its local manifest (e.g. one placed by a manual cp into $CARGO_HOME/bin). That orphan blocks the rebuild even after `cargo uninstall` cleanly no-ops. --force tells cargo to overwrite regardless.
The reinstalled binary only takes effect for *new* shells: the proxy running in the current terminal was spawned at shell startup and stays on the old code. Print a heads-up after the config editor exits with the two options (new terminal, or pkill + exec zsh).
The frecency ranker starts empty and only learns from accepted suggestions, so commonly-typed commands don't outrank the long tail until you've used the proxy for a few days. import-history walks ~/.zsh_history (or $HISTFILE / --from PATH), splits each line into pipeline segments, and records: - cmd:<head> for the head token of each segment - <head>:sub:<next> for the next non-flag, non-path-looking token That covers `git status`, `cargo build`, `docker ps`, etc. without trying to mine remote/branch/file tokens that vary too much to be reliable. Flags include --dry-run and --max N for previewing scope. Internally exposes HistoryProvider::read_history_from so the importer can reuse the existing zsh-extended-format parser and the multi-line merger, instead of re-implementing them.
- FrecencyDb::record_many: batch path for bulk imports — single lock acquisition, single flush. Avoids the SAVE_EVERY=3 disk thrashing that record() does for the runtime per-keystroke path. ~100x fewer fsyncs on a typical 300-record import. - parse_baseline_flag now delegates to the generic parse_value_flag instead of duplicating its loop. - DEFAULT_MAX_HISTORY_ENTRIES promoted to pub in gc-suggest::history; the importer's local copy is gone. - PIPELINE_SEPARATORS reordered so '||' precedes '|' (was dead in the array because '|' split first turned '||' into two empty segments). - Drop the Display impl on Recording (its `_ =>` arm was an admission of incoherent invariants); inline the dry-run formatting instead. - Bind `cmd.to_string()` once per segment instead of cloning twice; drop the redundant `tokens.find(|t| !t.is_empty())` since split_whitespace never yields empty tokens.
… race `spawn_shell` queries the outer terminal's dimensions exactly once via `crossterm::terminal::size()` (TIOCGWINSZ ioctl) and uses the result to open the inner PTY. On some platforms — observed on Linux/Wayland with Ghostty — the outer terminal hasn't completed its window-size handshake with the compositor at the moment the ioctl runs, so the inner PTY is created with stale/default dimensions and the inner shell renders against the wrong $COLUMNS until the first real SIGWINCH arrives. Symptom: first prompt only, right-aligned RPROMPT segments land on a new line, zsh's `PROMPT_SP` fires with `%` because its internal cursor tracking disagrees with the actual terminal width. Resizing the window (zoom in/out) "fixes" it by firing SIGWINCH, which routes through the existing resize handler — but that's exactly the signal that's missing on first prompt. Fix: 50ms after startup, fire a one-shot mpsc message that triggers a fresh `get_terminal_size()` + `resize_pty()` + parser dimension update. Reuses the same primitives as the SIGWINCH path; if the initial size was already correct, the second `resize_pty` with the same dimensions is a no-op. The popup-overlay cleanup branch of the SIGWINCH path is intentionally omitted: at the 50ms mark no popup can yet be visible (the user hasn't typed), so the narrower body suffices.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a 50ms-deferred re-query + resize of the inner PTY at proxy startup, working around a TIOCGWINSZ race that strands the inner shell with stale terminal dimensions until the first real
SIGWINCHarrives.I'm running ghost-complete on Linux (Ghostty/Wayland), so this PR is firmly in "patch from a fork user who hit a platform-specific edge case" territory — flagging that up front since
CONTRIBUTING.mddeclares the proxy as macOS-focused. Posting as draft so you can decide whether it's worth merging, ignoring, or pointing me at a better fix.The bug
On a fresh terminal window, the first zsh prompt renders misaligned:
PROMPT_SPindicator (%) appears at column 0 because zsh's internal cursor-column tracking disagrees with the actual terminal width.Every subsequent prompt is fine.
Root cause
gc_pty::spawn::spawn_shellcallsget_terminal_size()(acrossterm::terminal::size()→ TIOCGWINSZ ioctl) once and uses the result to open the inner PTY. On Linux/Wayland with Ghostty, the outer terminal's window-size handshake with the compositor isn't always complete at the moment that ioctl runs, so the ioctl returns stale/default dimensions. The inner PTY is created with those dimensions; the inner shell's$COLUMNSis wrong until the nextSIGWINCH.The existing SIGWINCH handler in
proxy.rsalready does the right thing (re-query,resize_pty, update parser dimensions) — the only problem is that noSIGWINCHarrives during normal first-prompt rendering. Zooming "fixes" the bug only because zoom is what finally triggers SIGWINCH.Why macOS likely doesn't see this
On macOS, AppKit's window-creation/spawn flow is synchronous enough that TIOCGWINSZ at child-process start almost always returns correct dimensions. On Linux + Wayland the spawn-then-size sequence is multiple async round-trips across the compositor, and "process alive" precedes "process knows its window size." This is the same class of bug that's bitten tmux, screen, and other PTY proxies historically.
The fix
Single file change in
crates/gc-pty/src/proxy.rs(+51 lines).Trade-offs / open questions
terminal::size()until two consecutive reads agree (capped at, say, 200ms), or wire a one-shot timer that's cancelled by the first real SIGWINCH if one arrives early. Happy to rework if you'd prefer a less timing-coupled approach.resize_ptycall happens within 100ms of spawn, regardless of value) would at least pin the behavior. Let me know if you'd like one and I'll add it.resize_ptywith same dims doesn't emit anything to the inner shell). So this is a free defensive layer for macOS users too — but I haven't tested on macOS.Verification
Manually verified on Ghostty 1.x / Linux x86_64 / Wayland: with this patch, fresh windows render the first prompt correctly without any zoom workaround. Built with
cargo install --path crates/ghost-complete --locked --forceand exercised across ~20 fresh windows.cargo fmt --checkpasses on the changed file.cargo clippy -p gc-pty --all-targets -- -D warningspasses on the changed file (there's a preexisting clippy warning ingc-overlay/src/render.rs:67from clippy 1.93's stricter doc-list rules — unrelated to this PR, worth a separate cleanup).Test plan
%indicator