Files
dotfiles/platforms/home-manager/modules/nmasur/presets/programs/zellij.nix
T
Noah Masur a72e81fcce fix post-TUI typing lag: latch fish query-term off in zellij pane shells
Root cause (proven via live lag-triage capture + deterministic PTY repro):
fish latches feature flags from its startup env before config.fish runs, so
the existing no-query-term settings never applied to zellij-spawned shells.
With query-term on, fish queries the terminal after every command; one reply
zellij fails to relay permanently degrades that fish process's reader.
Subshells were immune (inherited the exported var), which is why the lag
always 'disappeared' when tested in a new shell.

Also adds the lag-triage/unlag/term-probe diagnostic toolkit used to find
this, and upstream_repro.py for filing the fish-shell bug.
2026-08-29 17:48:23 -06:00

422 lines
14 KiB
Nix

{
config,
pkgs,
lib,
...
}:
let
inherit (config.nmasur.settings) username;
cfg = config.nmasur.presets.programs.zellij;
# fish latches feature flags from its startup ENVIRONMENT before config.fish
# runs, so the `set -gx fish_features no-query-term` in config.fish only
# protects CHILD fish processes — which is why subshells/exec fish were always
# immune to the post-TUI typing lag while zellij-spawned pane shells were not.
# With query-term latched on, fish sends OSC 11 + CPR + DA1 queries after
# every command and waits for replies; if zellij fails to relay even one
# reply (a race during TUI teardown or heavy output), that fish process's
# reader is PERMANENTLY degraded — reproduced deterministically in a PTY
# harness on fish 4.8.1 (see docs/CHANGELOG.md 2026-08-29 and
# presets/programs/lag-triage/upstream_repro.py). Spawning fish with the
# variable already exported makes every pane shell immune.
fish-no-query-term = pkgs.writeShellScriptBin "fish-no-query-term" ''
export fish_features=no-query-term
exec ${lib.getExe pkgs.fish} "$@"
'';
zellij-switch-to-last = pkgs.writeShellScriptBin "zellij-switch-to-last" ''
TARGET_SESSION=$(cat ~/.local/state/zellij-last-session)
if [ -z "$TARGET_SESSION" ]; then
return 1
fi
echo "$ZELLIJ_SESSION_NAME" > ~/.local/state/zellij-last-session
${lib.getExe pkgs.zellij} action switch-session $TARGET_SESSION
'';
in
{
options.nmasur.presets.programs.zellij.enable = lib.mkEnableOption "Zellij terminal multiplexer";
config = lib.mkIf cfg.enable {
home.packages = [ pkgs.zellij-switch ];
programs.fish = {
shellAbbrs.z = "zellij";
functions = {
zellij-session = {
# description = "Open a session in Zellij";
body = # fish
''
set TARGET_DIR $( \
zoxide query --list --score | \
fzf --filter="$query" --no-sort | \
fzf \
--prompt="zoxide > " \
--nth=2.. \
--ansi \
--height=60% \
--info=inline \
--border=rounded \
--layout=reverse \
--preview-window=down:40%:wrap \
--preview='ls -F -C --color=always {2..}' \
--bind 'ctrl-z:ignore,btab:up,tab:down,enter:become:echo {2..}' \
--cycle \
--keep-right \
--tabstop=1 \
)
if test -z $TARGET_DIR
return 0
end
if test "$TARGET_DIR" = $(pwd)
return 1
end
# Zellij names each session's IPC socket $TMPDIR/zellij-<uid>/<ver>/<name>.
# On macOS the socket path is capped at 103 bytes; the $TMPDIR prefix under
# /var/folders/... eats ~79 of those, leaving only ~24 chars for the name.
# A longer basename overflows the socket path, and because switch-session has
# already detached from the current session by the time the new one fails to
# bind, it takes the whole terminal down. Truncate to stay well under the limit.
set SESSION_NAME (basename $TARGET_DIR | string sub --length 20)
echo "$ZELLIJ_SESSION_NAME" > ~/.local/state/zellij-last-session
${lib.getExe pkgs.zellij} action switch-session $SESSION_NAME --cwd $TARGET_DIR --layout default
'';
};
gh-run = {
body = # fish
''
${lib.getExe pkgs.zellij} action new-pane --start-suspended -- gh run watch
'';
};
__fish_update_cwd_osc = {
description = "Notify Zellij/terminal of CWD changes without hostname mismatch";
body = # fish
''
if test "$TERM" != "dumb"
printf \e\]7\;file://%s\a (string escape --style=url -- $PWD)
end
'';
onVariable = "PWD";
onEvent = "fish_prompt";
};
};
};
programs.ghostty.settings.initial-command = lib.getExe pkgs.zellij;
xdg.configFile."zellij/layouts/compact-top.kdl".text = # kdl
''
layout {
pane size=1 borderless=true {
plugin location="compact-bar"
}
pane
}
'';
xdg.configFile."zellij/layouts/default.kdl".text = # kdl
''
layout {
pane size=1 borderless=true {
plugin location="tab-bar"
}
pane
pane size=1 borderless=true {
plugin location="status-bar"
}
}
'';
programs.zellij = {
enable = true;
# Auto start on shell init
enableBashIntegration = true;
enableFishIntegration = true;
enableZshIntegration = true;
attachExistingSession = true;
exitShellOnExit = true;
settings = {
default_mode = "locked";
# Spawn fish directly instead of trusting $SHELL, which inherits the
# macOS login shell. On darwin that login shell is no longer managed by
# nix-darwin, so $SHELL can point at a stale /run/current-system path.
# Wrapped to export fish_features=no-query-term BEFORE fish starts —
# see the fish-no-query-term comment above for why this must happen in
# the environment rather than in config.fish.
default_shell = lib.getExe fish-no-query-term;
# default_layout = "compact-top";
# Remove border
pane_frames = false;
# Ghostty + Zellij have several open upstream bugs where the Kitty
# keyboard protocol's "enhancement" flags get left in a stuck/elevated
# state after a full-screen TUI exits (e.g. zellij-org/zellij#3887,
# #3723, #4178), causing every subsequent keystroke to be sent as a
# CSI-u sequence that fish has to wait out an escape-timeout to
# disambiguate. This shows up as typing lag that builds up the longer
# you were inside the TUI, until the pane's protocol state resets.
# Disabling it entirely avoids the whole bug class.
support_kitty_keyboard_protocol = false;
# Scrollback
scrollback_editor = config.home.sessionVariables.EDITOR;
show_startup_tips = false;
keybinds = {
locked = lib.mkIf (pkgs.stdenv.hostPlatform.isLinux && !config.programs.ghostty.enable) {
# For servers that I SSH into, use c-a-G to unlock
"bind \"Ctrl Alt G\"" = lib.mkIf (pkgs.stdenv.hostPlatform.isLinux && !config.programs.ghostty.enable) {
SwitchToMode = {
_args = [ "normal" ];
};
};
};
normal = lib.mkIf (pkgs.stdenv.hostPlatform.isLinux && !config.programs.ghostty.enable) {
# For servers that I SSH into, use c-a-G to lock
"bind \"Ctrl Alt G\"" = lib.mkIf (pkgs.stdenv.hostPlatform.isLinux && !config.programs.ghostty.enable) {
SwitchToMode = {
_args = [ "locked" ];
};
};
};
session = {
"bind \"w\"" = {
LaunchOrFocusPlugin = {
_args = [ "session-manager" ];
floating = true;
move_to_focused_tab = true;
};
SwitchToMode = {
_args = [ "locked" ];
};
};
};
scroll = {
"bind \"e\"" = {
EditScrollback = { };
SwitchToMode = {
_args = [ "locked" ];
};
};
};
shared = {
"bind \"Alt Shift s\"" = {
Run = {
_args =
if pkgs.stdenv.hostPlatform.isDarwin then
[
# "env"
# "PATH=/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin"
(lib.getExe zellij-switch-to-last)
]
else
[ (lib.getExe zellij-switch-to-last) ];
close_on_exit = true;
};
};
"bind \"Alt Shift p\"" = {
Run = {
_args = [
"${pkgs.fish}/bin/fish"
"-c"
"zellij-session"
];
close_on_exit = true;
};
};
"bind \"Alt Shift h\"" = {
Run = {
_args = [
(lib.getExe config.nmasur.presets.programs.nixpkgs.commands.rebuildHome)
];
# close_on_exit = false;
};
};
"bind \"Alt Shift r\"" = {
Run = {
_args = [
(lib.getExe config.nmasur.presets.programs.nixpkgs.commands.rebuildNixos)
];
# close_on_exit = false;
};
};
"bind \"Alt Shift w\"" = {
Run = {
_args =
(if config.nmasur.presets.programs.noti.enable then [ (lib.getExe pkgs.noti) ] else [ ])
++ [
(lib.getExe pkgs.gh)
"run"
"watch"
];
# direction = "Right";
# close_on_exit = false;
# start_suspended = true;
};
};
"bind \"Alt Shift l\"" = {
Run = {
_args = [
(lib.getExe pkgs.gh)
"run"
"view"
"--log"
];
};
};
"bind \"Alt Shift f\"" = {
Run = {
_args = [
(lib.getExe pkgs.gh)
"run"
"view"
"--log-failed"
];
};
};
"bind \"Alt Shift j\"" = {
Run = {
_args =
if pkgs.stdenv.hostPlatform.isDarwin then
[
"env"
"PATH=${config.home.homeDirectory}/.nix-profile/bin:/etc/profiles/per-user/${username}/bin:/usr/bin"
(lib.getExe pkgs.jjui)
]
else
[ (lib.getExe pkgs.jjui) ];
close_on_exit = true;
floating = true;
x = "1%";
y = "1%";
width = "99%";
height = "99%";
};
};
"bind \"Alt Shift y\"" = {
Run = {
_args = [
(lib.getExe pkgs.yazi)
];
close_on_exit = true;
floating = true;
x = "1%";
y = "1%";
width = "99%";
height = "99%";
};
};
"bind \"Super Shift ]\"" = {
GoToNextTab = { };
};
"bind \"Alt ]\"" = {
GoToNextTab = { };
};
"bind \"Alt }\"" = {
GoToNextTab = { };
};
"bind \"Alt Shift ]\"" = {
GoToNextTab = { };
};
"bind \"Alt Shift }\"" = {
GoToNextTab = { };
};
"bind \"Super Shift [\"" = {
GoToPreviousTab = { };
};
"bind \"Alt [\"" = {
GoToPreviousTab = { };
};
"bind \"Alt {\"" = {
GoToPreviousTab = { };
};
"bind \"Alt Shift [\"" = {
GoToPreviousTab = { };
};
"bind \"Alt Shift {\"" = {
GoToPreviousTab = { };
};
"bind \"Ctrl Tab\"" = {
GoToNextTab = { };
};
"bind \"Ctrl Shift Tab\"" = {
GoToPreviousTab = { };
};
"bind \"Super t\"" = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
NewTab = { };
};
"bind \"Alt t\"" = {
NewTab = { };
};
"bind \"Super k\"" = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
SwitchToMode = {
_args = [ "scroll" ];
};
};
"bind \"Alt Shift k\"" = {
SwitchToMode = {
_args = [ "scroll" ];
};
};
"bind \"Super Shift e\"" = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin {
EditScrollback = { };
SwitchToMode = {
_args = [ "locked" ];
};
};
"bind \"Alt Shift e\"" = {
EditScrollback = { };
SwitchToMode = {
_args = [ "locked" ];
};
};
"bind \"Alt l\"" = {
MoveFocusOrTab = {
_args = [ "Right" ];
};
};
"bind \"Alt h\"" = {
MoveFocusOrTab = {
_args = [ "Left" ];
};
};
"bind \"Alt j\"" = {
MoveFocus = {
_args = [ "Down" ];
};
};
"bind \"Alt k\"" = {
MoveFocus = {
_args = [ "Up" ];
};
};
};
};
theme = "custom";
themes.custom = {
fg = "${config.theme.colors.base03}";
bg = "${config.theme.colors.base02}";
black = "${config.theme.colors.base00}";
red = "${config.theme.colors.base08}";
green = "${config.theme.colors.base04}";
yellow = "${config.theme.colors.base0A}";
blue = "${config.theme.colors.base0D}";
magenta = "${config.theme.colors.base0E}";
cyan = "${config.theme.colors.base0C}";
white = "${config.theme.colors.base04}";
orange = "${config.theme.colors.base09}";
};
};
};
};
}