diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 79472dc1..a973365b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-08-29 (later) + +- Added a diagnostic toolkit (`lag-triage` / `unlag` fish functions + `term-probe` binary, `presets/programs/lag-triage/`) for the still-recurring post-TUI typing lag in fish + Zellij + Ghostty, instead of another blind fix. Findings that motivated it: + - All three prior fixes were either no-ops or insufficient: `fish_features = no-query-term` is a **no-op** because `query-term` already defaults to *off* in fish 4.8.1 (verified with `status features`); disabling Ghostty's fish integration inside Zellij and setting `support_kitty_keyboard_protocol = false` did not stop recurrence. + - PTY captures of fish 4.8.1 (`TERM=xterm-256color`, with and without `$ZELLIJ`) show fish never writes Kitty keyboard sequences to the wire — it uses modifyOtherKeys (`\e[>4;1m`), application keypad (`\e=`), bracketed paste (`?2004`), and color-theme reporting (`?2031`), enabling them at every prompt and disabling them before every external command. Crucially, a fresh subshell's startup bytes are identical to the parent's post-command re-enable bytes, so "a subshell fixes the lag" cannot be explained by a simple terminal-state reset — leaving two competing hypotheses that only live capture can separate: (1) fish-internal reader state poisoned by stray/partial escape bytes (e.g. leaked from a closing floating pane), cleared only by a new fish process; (2) Zellij/Ghostty-level stuck state (Zellij 0.45's `StdinAnsiParser` is already a proven source of input delays — see the Alt-Shift-P fix below). + - Also note: the floating-pane TUIs (jjui via Alt-Shift-J, yazi via Alt-Shift-Y, scrollback editor) run in their own panes and never pass through the shell's fish process at all, while `nvim` runs inside the shell pane — the triage log records which path preceded the lag. + - **Next occurrence: run `lag-triage` in the lagging shell BEFORE starting a new shell.** It snapshots the environment, queries pane terminal state (kitty flags, modifyOtherKeys, DEC modes, DA1 round-trip latency), captures raw keystroke bytes+timing bypassing fish, then applies staged resets (kitty pop/clear, modifyOtherKeys off, keypad/cursor, mouse/focus/sync, altscreen, stty, DECSTR) — the stage that cures it names the stuck layer. Logs to `~/.local/state/lag-triage/` for an upstream issue. `unlag` is the one-shot convenience version (if `unlag` never helps but `exec fish` does, the bug is fish-internal). + ## 2026-08-29 - Fixed 1.5-second latency when pressing `Alt-Shift-P` to trigger `zellij-session` in Zellij 0.45.0 + Ghostty: diff --git a/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/default.nix b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/default.nix new file mode 100644 index 00000000..733e6f35 --- /dev/null +++ b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/default.nix @@ -0,0 +1,38 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + cfg = config.nmasur.presets.programs.lag-triage; + + term-probe = pkgs.writeScriptBin "term-probe" '' + #!${lib.getExe pkgs.python3} + ${builtins.readFile ./term_probe.py} + ''; +in + +{ + + options.nmasur.presets.programs.lag-triage.enable = + lib.mkEnableOption "Terminal input-lag triage tools"; + + config = lib.mkIf cfg.enable { + + home.packages = [ term-probe ]; + + programs.fish.functions = { + lag-triage = { + description = "Diagnose post-TUI typing lag in the current shell"; + body = builtins.readFile ./lag-triage.fish; + }; + unlag = { + description = "Reset terminal state left behind by a TUI"; + body = builtins.readFile ./unlag.fish; + }; + }; + + }; +} diff --git a/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/lag-triage.fish b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/lag-triage.fish new file mode 100644 index 00000000..60f12ce6 --- /dev/null +++ b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/lag-triage.fish @@ -0,0 +1,161 @@ +# Guided diagnosis for the post-TUI typing-lag problem (Ghostty + Zellij + fish). +# Run this IN THE LAGGING SHELL the moment you notice the lag, BEFORE starting +# a new shell. It captures evidence, then applies targeted resets one at a time +# so the stage that cures the lag identifies the layer holding stuck state. +# Everything is logged for filing an upstream issue. + +set -l logdir ~/.local/state/lag-triage +mkdir -p $logdir +set -l logfile $logdir/(date +%Y%m%d-%H%M%S).log + +function _lt --inherit-variable logfile + echo $argv | tee -a $logfile +end + +function _lt_ask --inherit-variable logfile + # usage: _lt_ask VARNAME prompt... -> sets global $VARNAME (default: skip) + set -l __name $argv[1] + read -g -P "$argv[2..] " $__name + or set -g $__name skip + test -z "$$__name"; and set -g $__name skip + echo "ANSWER $__name: $$__name" >>$logfile +end + +_lt "== lag-triage "(date)" ==" +_lt "Log: $logfile" +_lt "Answer y / n, or press Enter to skip a question." +_lt "" + +# ---- 1. Context ------------------------------------------------------------- +_lt_ask ans_tui "Which TUI did you just exit (nvim/jjui/yazi/other)?" +_lt_ask ans_launch "Launched via (f)loating-pane keybind or (c)ommand typed in this shell?" + +# ---- 2. Snapshot ------------------------------------------------------------ +begin + echo "-- snapshot --" + fish --version + echo "fish pid: $fish_pid, started: "(ps -o lstart= -p $fish_pid 2>/dev/null) + zellij --version 2>/dev/null + echo "escape delay: '$fish_escape_delay_ms' sequence delay: '$fish_sequence_key_delay_ms'" + env | grep -iE '^(TERM|ZELLIJ|GHOSTTY|COLORTERM)' | sort + echo "-- status features --" + status features + echo "-- stty -a --" + stty -a +end >>$logfile 2>&1 +_lt "Captured shell + environment snapshot." + +# ---- 3. Terminal state below the shell -------------------------------------- +_lt "" +_lt "Querying terminal state (takes a few seconds)..." +term-probe report 2>&1 | tee -a $logfile +_lt "" +_lt " ^ Things to look for: kitty flags with a reply > 0, modifyOtherKeys > 1," +_lt " any mouse/alternate-screen mode SET while at a shell prompt, or a slow" +_lt " DA1 round-trip (> 100 ms means the input path itself is delayed)." + +# ---- 4. Raw keystroke capture (bypasses fish entirely) ---------------------- +_lt "" +_lt "Raw input capture: type ~10 characters at a steady pace, including one" +_lt "ESC press and one arrow key. This shows the exact bytes this pane delivers" +_lt "and their timing, with fish's input handling out of the picture." +term-probe keylog 2>&1 | tee -a $logfile +_lt_ask ans_keylog_instant "Did each keypress appear INSTANTLY in the capture? (y/n)" +_lt_ask ans_keylog_plain "Were plain letters single plain bytes like b'a' (not escape sequences)? (y/n)" + +# ---- 5. Scope --------------------------------------------------------------- +_lt "" +_lt_ask ans_scope_pane "Optional: open a NEW zellij pane/tab and type — laggy there too? (y/n)" +_lt_ask ans_scope_window "Optional: type in a separate Ghostty window (outside this zellij session) — laggy? (y/n)" + +# ---- 6. Staged resets ------------------------------------------------------- +# Each stage resets one category of state a TUI could have left behind. +# The first stage that cures the lag names the culprit. +set -l fixed none + +_lt "" +_lt "Now applying resets one at a time. After each, type into the test prompt" +_lt "to judge whether the lag is gone." + +if test $fixed = none + _lt "" + _lt "Stage A - kitty keyboard protocol: pop stack + clear all flags" + printf '\e[<9u\e[=0;1u' + _lt_ask ans_stage_a " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_a" = y; and set fixed "A (kitty keyboard state)" +end + +if test $fixed = none + _lt "Stage B - modifyOtherKeys off" + printf '\e[>4;0m' + _lt_ask ans_stage_b " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_b" = y; and set fixed "B (modifyOtherKeys)" +end + +if test $fixed = none + _lt "Stage C - normal keypad + normal cursor keys" + printf '\e>\e[?1l' + _lt_ask ans_stage_c " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_c" = y; and set fixed "C (application keypad/cursor mode)" +end + +if test $fixed = none + _lt "Stage D - disable mouse, focus reporting, synchronized output" + printf '\e[?1000l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1004l\e[?2026l' + _lt_ask ans_stage_d " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_d" = y; and set fixed "D (mouse/focus/sync modes)" +end + +if test $fixed = none + _lt "Stage E - leave alternate screen" + printf '\e[?1049l' + _lt_ask ans_stage_e " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_e" = y; and set fixed "E (alternate screen)" +end + +if test $fixed = none + _lt "Stage F - stty sane (line-discipline reset)" + stty sane + _lt_ask ans_stage_f " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_f" = y; and set fixed "F (termios/line discipline)" +end + +if test $fixed = none + _lt "Stage G - DECSTR soft terminal reset" + printf '\e[!p' + _lt_ask ans_stage_g " Test typing here, then Enter — lag gone? (y/n)" + test "$ans_stage_g" = y; and set fixed "G (DECSTR-resettable mode)" +end + +# ---- 7. Verdict -------------------------------------------------------------- +_lt "" +_lt "== Verdict ==" +if test $fixed != none + _lt "Lag cleared by stage $fixed." + _lt "That state was stuck BELOW fish — in the Zellij pane or relayed to" + _lt "Ghostty — and the TUI you exited ($ans_tui) failed to restore it, or" + _lt "Zellij failed to restore it when the pane closed." + _lt "Re-probing terminal state after the fix for comparison:" + term-probe report 2>&1 | tee -a $logfile + _lt "" + _lt "-> File this log against zellij (or ghostty, if a separate window also" + _lt " lagged). The before/after probe diff pinpoints the exact stuck mode." +else if test "$ans_keylog_instant" = y; and test "$ans_keylog_plain" = y + _lt "Raw input reaches this pane instantly as plain bytes, and no terminal" + _lt "state reset helps: the lag lives INSIDE this fish process (reader state)." + _lt "Confirm now: run 'exec fish' — if that cures it, it is fish-internal." + _lt "" + _lt "-> To catch it in the act, run your next long-lived shell as:" + _lt " FISH_DEBUG='reader,term-support' FISH_DEBUG_OUTPUT=$logdir/fish-debug.log fish" + _lt " then re-run lag-triage when it recurs and file both logs to fish-shell." +else + _lt "Keystrokes were delayed or arrived as escape sequences BEFORE fish saw" + _lt "them: the problem is in Zellij (client stdin parser / server) or Ghostty." + _lt " new pane also laggy: $ans_scope_pane (y -> session-wide, not this pane)" + _lt " separate window laggy: $ans_scope_window (y -> Ghostty itself)" + _lt "-> File this log against zellij; include the keylog byte capture." +end +_lt "" +_lt "Full log: $logfile" + +functions -e _lt _lt_ask diff --git a/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/term_probe.py b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/term_probe.py new file mode 100644 index 00000000..84098290 --- /dev/null +++ b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/term_probe.py @@ -0,0 +1,165 @@ +"""Probe the terminal state of the current pane, below the shell. + +Modes: + report - query kitty-keyboard flags, modifyOtherKeys, and DEC private + modes directly on /dev/tty, reporting each reply (or lack of + one) and its round-trip latency. Answers may come from Zellij + (pane state) or be relayed from Ghostty (window state). + keylog - raw-mode keystroke capture: prints the exact bytes and + inter-key latency for every keypress, bypassing the shell's + input machinery entirely. Press q to finish. + +Used by the `lag-triage` fish function to pin down which layer +(fish / zellij / ghostty) is holding stuck state when typing lags +after a TUI exits. +""" + +import os +import re +import select +import sys +import termios +import time +import tty + +# DECRQM reply values +DECRQM_VALUES = { + "0": "not recognized", + "1": "SET", + "2": "reset", + "3": "permanently set", + "4": "permanently reset", +} + +DEC_MODES = [ + (1, "application cursor keys (DECCKM)"), + (25, "cursor visible"), + (1000, "mouse click reporting"), + (1002, "mouse drag reporting"), + (1003, "mouse all-motion reporting"), + (1004, "focus reporting"), + (1006, "SGR mouse encoding"), + (1049, "alternate screen"), + (2004, "bracketed paste"), + (2026, "synchronized output"), + (2031, "color theme reporting"), +] + +QUERIES = [ + ("kitty keyboard flags (\\e[?u)", b"\x1b[?u", rb"\x1b\[\?(\d+)u", None), + ("modifyOtherKeys (XTQMODKEYS)", b"\x1b[?4m", rb"\x1b\[>4;(\d+)m", None), + ("background color (OSC 11)", b"\x1b]11;?\x1b\\", rb"\x1b\]11;([^\x07\x1b]+)", None), +] + [ + ( + f"DEC mode {num} — {desc}", + b"\x1b[?%d$p" % num, + rb"\x1b\[\?%d;(\d+)\$y" % num, + DECRQM_VALUES, + ) + for num, desc in DEC_MODES +] + + +def read_for(fd, seconds): + buf = b"" + end = time.monotonic() + seconds + while True: + remaining = end - time.monotonic() + if remaining <= 0: + break + r, _, _ = select.select([fd], [], [], remaining) + if not r: + break + buf += os.read(fd, 4096) + return buf + + +def report(fd): + lines = [] + raw_dump = b"" + for label, query, pattern, value_names in QUERIES: + raw_dump += read_for(fd, 0.02) # drain stragglers + start = time.monotonic() + os.write(fd, query) + buf = b"" + match = None + deadline = time.monotonic() + 0.35 + while time.monotonic() < deadline: + buf += read_for(fd, 0.05) + match = re.search(pattern, buf) + if match: + break + raw_dump += buf + if match: + latency = (time.monotonic() - start) * 1000 + value = match.group(1).decode("ascii", "replace") + if value_names: + value = f"{value} ({value_names.get(value, '?')})" + lines.append(f" {label:45s} = {value:24s} [{latency:6.1f} ms]") + else: + lines.append(f" {label:45s} = (no reply)") + + # DA1 as a fence: every terminal answers it, so its round-trip time + # measures the whole input path (ghostty -> zellij -> pane -> here). + start = time.monotonic() + os.write(fd, b"\x1b[c") + buf = b"" + match = None + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + buf += read_for(fd, 0.05) + match = re.search(rb"\x1b\[\?([0-9;]*)c", buf) + if match: + break + raw_dump += buf + if match: + latency = (time.monotonic() - start) * 1000 + lines.append( + f" {'device attributes (DA1) round-trip':45s} = " + f"{match.group(1).decode():24s} [{latency:6.1f} ms]" + ) + else: + lines.append(f" {'device attributes (DA1) round-trip':45s} = (NO REPLY in 2s!)") + lines.append(f" raw bytes received: {raw_dump!r}") + return lines + + +def keylog(fd): + sys.stdout.write("keylog: capturing raw bytes from the tty. Press q to finish.\r\n") + sys.stdout.flush() + last = time.monotonic() + while True: + select.select([fd], [], [], None) + data = os.read(fd, 4096) + now = time.monotonic() + delta_ms = (now - last) * 1000 + last = now + sys.stdout.write(f" +{delta_ms:8.1f} ms {data!r} hex={data.hex(' ')}\r\n") + sys.stdout.flush() + if data in (b"q", b"\x03", b"\x04"): + break + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else "report" + fd = os.open("/dev/tty", os.O_RDWR) + old = termios.tcgetattr(fd) + lines = None + try: + tty.setraw(fd) + if mode == "report": + lines = report(fd) + elif mode == "keylog": + keylog(fd) + else: + raise SystemExit(f"unknown mode: {mode}") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + os.close(fd) + if lines: + print("terminal state as seen from this pane:") + print("\n".join(lines)) + + +if __name__ == "__main__": + main() diff --git a/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/unlag.fish b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/unlag.fish new file mode 100644 index 00000000..1329044e --- /dev/null +++ b/platforms/home-manager/modules/nmasur/presets/programs/lag-triage/unlag.fish @@ -0,0 +1,15 @@ +# One-shot reset of terminal state a TUI may have left behind (kitty keyboard +# flags, modifyOtherKeys, application keypad/cursor, mouse/focus reporting, +# alternate screen, termios). fish re-enables the modes it wants at the next +# prompt, so this is safe to run any time. +# +# Diagnostic value: if this cures the lag, the stuck state was below fish +# (run lag-triage next time to find which mode). If only `exec fish` cures +# it, the lag is inside the fish process itself. +printf '\e[<9u\e[=0;1u' +printf '\e[>4;0m' +printf '\e>\e[?1l' +printf '\e[?1000l\e[?1001l\e[?1002l\e[?1003l\e[?1005l\e[?1006l\e[?1015l\e[?1016l\e[?1004l\e[?2026l' +printf '\e[?1049l' +stty sane +echo "terminal state reset — if typing still lags, run lag-triage (before exec fish!)" diff --git a/platforms/home-manager/modules/nmasur/profiles/power-user.nix b/platforms/home-manager/modules/nmasur/profiles/power-user.nix index 97c725bb..c0d9136c 100644 --- a/platforms/home-manager/modules/nmasur/profiles/power-user.nix +++ b/platforms/home-manager/modules/nmasur/profiles/power-user.nix @@ -63,6 +63,7 @@ in git.enable = lib.mkDefault true; helix.enable = lib.mkDefault true; jujutsu.enable = lib.mkDefault true; + lag-triage.enable = lib.mkDefault true; lazygit.enable = lib.mkDefault true; # neovim.enable = lib.mkDefault true; nix-index.enable = lib.mkDefault true;