Jedes Projekt, das ich anfasse, will dieselbe Bühne: mehrere Shell Fenster, vielleicht ein AI-Agent, daneben Git und Datei-Browser, vielleicht noch eine Live-Preview. Das von Hand aufzubauen — tmux new, splitten, cd, Tools starten — dauert jedes Mal eine Minute, die ich nicht habe. Also habe ich es in fr0st
deklariert: tmux für die persistente Bühne,
sesh
als Session-Manager darüber, und einen Hyprland-Keybind, der das Ganze per rofi aufruft. Ein Tastendruck, ein fertiger Workspace.
Die Rollenverteilung#
Die drei Teile machen jeweils genau eine Sache:
- tmux hält die Sessions am Leben — Panes, Fenster, laufende Prozesse überleben das Schließen des Terminals (und mit
tmux-resurrect/continuumsogar einen Reboot). - sesh ist der Manager davor: Es kennt vordefinierte Sessions, listet sie, verbindet per Name — und kann sie aus einer Konfiguration erzeugen, statt sie nur wiederzufinden.
- rofi ist der Picker im Hyprland-Desktop:
SUPER+s, Session wählen, fertig.
Der Reiz liegt darin, dass alle drei in Nix-Modulen leben. sesh wird über
home-manager
konfiguriert, die sesh.toml ist nur ein Render-Ergebnis aus einem Nix-Attributset. Damit ist ein Session-Layout kein Eintrag in einer Dotfile, die ich vergesse zu pflegen, sondern Teil derselben deklarativen Wurzel wie der Rest des Systems.
sesh: Sessions als Datensatz#
Eine Session ist bei mir eine Liste von Fenstern mit Pfad und Startbefehl. Die BLOG-Session — die, aus der dieser Text entsteht — sieht in Nix so aus:
1sessions = [
2 {
3 name = "BLOG";
4 # sesh behält immer das initiale new-session-Fenster zusätzlich zu den
5 # gelisteten; startup_command läuft darin — also killen, damit exakt die
6 # Fenster unten übrig bleiben.
7 startup_command = ''tmux next-window; tmux kill-window -t "$TMUX_PANE"'';
8 windows = [
9 {
10 name = "content";
11 path = "${config.xdg.userDirs.projects}/src/hugo/content";
12 # Zwei Shells übereinander: oben das content-Repo, unten das Theme.
13 startup_script = ''tmux split-window -v -c "${config.xdg.userDirs.projects}/src/hugo/fuchsbau" -t "$TMUX_PANE"; tmux select-pane -t "$TMUX_PANE"'';
14 }
15 {
16 name = "agent";
17 path = "${config.xdg.userDirs.projects}/src/hugo/content";
18 startup_script = "oc --continue";
19 }
20 {
21 name = "preview";
22 path = "${config.xdg.userDirs.projects}/src/hugo/content";
23 startup_script = "task serve";
24 }
25 ];
26 }
27];
SUPER+s → BLOG wählen, und ich habe drei Fenster: content (zwei Shells übereinander — oben das content-Repo, unten das Theme fuchsbau), agent (Agent im content-Repo per oc --continue) und preview, in dem task serve
den Hugo-Live-Server fährt. Weil die Panes interaktive Shells sind, lädt direnv die jeweiligen devShells automatisch — ich muss nichts aktivieren.
startup_command mit tmux next-window; tmux kill-window ist nicht kosmetisch. sesh legt beim Erzeugen einer Session immer zuerst das Standard-new-session-Fenster an und hängt die deklarierten Fenster daneben. Ohne das Wegräumen bliebe ein leeres Fenster 1 übrig. Damit die Indizes danach lückenlos bleiben, steht in der tmux-Config ein set -g renumber-windows on.Der Window-Pool-Trick#
Hier wurde es interessant — und ein bisschen subtil. sesh löst Fenster nicht pro Session auf, sondern über einen globalen Pool, der nach Fensternamen indiziert ist. Heißt: Ein Fenster shell kann es global nur einmal geben. Will ich aber in jeder Session ein shell-, git- oder agent-Fenster, kollidieren die Namen.
Die Lösung ist ein kleiner Nix-Helfer, der jeden Pool-Key mit seiner Session namespaced (<session>/<window>) und per vorangestelltem tmux rename-window den kurzen Namen auf der Statuszeile wiederherstellt:
1mkPoolWindow =
2 sessionName: w:
3 let
4 # -t "$TMUX_PANE" ist Pflicht: ein nacktes tmux-Kommando im startup_script
5 # zielt sonst auf das *aktive* Fenster der Session, nicht auf das Pane,
6 # in dem das Skript läuft.
7 rename = ''tmux rename-window -t "$TMUX_PANE" ${lib.escapeShellArg w.name}'';
8 in
9 (builtins.removeAttrs w [ "startup_script" ])
10 // {
11 name = "${sessionName}/${w.name}";
12 startup_script = if w ? startup_script then "${rename} && ${w.startup_script}" else rename;
13 };
Im Pool heißt das Fenster also BLOG/content, auf der tmux-Statuszeile steht aber wieder content. Damit müssen Fensternamen nur noch innerhalb einer Session eindeutig sein — die saubere Eigenschaft, die sesh von Haus aus nicht bietet. Die windows-Liste jeder Session wird parallel auf ihre namespaced Pool-Keys gemappt, und der Pool selbst ist die Konkatenation aller Session-Fenster.
Eine Vorlage für jeden Projektordner#
Vordefinierte Sessions sind schön für die paar Dinge, die ich täglich öffne. Für jeden anderen Projektordner will ich aber nicht für jeden eine Session schreiben. Dafür gibt es in sesh die Wildcard-Konfiguration — und eine Fenster-Layout-Vorlage, die ich PROJECTS getauft habe. Sie ist bewusst keine echte Session, sondern nur ein Template, dessen Fenster im Pool liegen:
1projectsTemplate = {
2 name = "PROJECTS";
3 startup_command = ''tmux next-window; tmux kill-window -t "$TMUX_PANE"'';
4 windows = [
5 { name = "shell"; startup_script = "clear"; }
6 { name = "agent"; startup_script = "oc --continue"; }
7 { name = "git"; startup_script = "rad issue; rad patch"; }
8 { name = "files"; startup_script = "y"; }
9 ];
10};
Die Wildcard hängt das Layout an jeden Ordner unter ~/projects/src/*:
1wildcard = [
2 {
3 pattern = "${config.xdg.userDirs.projects}/src/*";
4 preview_command = "ls -A --color=always {}";
5 inherit (projectsLayout) windows startup_command;
6 }
7];
Wähle ich also irgendein Projekt unter ~/projects/src, bekomme ich vier Fenster: eine Shell, ein Agent-Fenster, ein Git-Fenster, das mir Radicle
-Issues und -Patches zeigt (rad issue; rad patch), und einen
yazi
-Dateibrowser (y). Eine Vorlage, beliebig viele Sessions — genau dasselbe „Grenzkosten gegen null"-Muster, das den ganzen Flake trägt.
tmux: die Bühne, poliert#
sesh setzt auf tmux auf, also bekam tmux denselben deklarativen Feinschliff. Die Statuszeile trägt jetzt den fr0st-Akzent — monochrom mit orangem Highlight (base09, #fe8019):
1set -g status-left "#[fg=${colors.base00},bg=${colors.accent},bold] #S #[…] "
2set -g window-status-current-format "#[fg=${colors.base00},bg=${colors.accent},bold] #I:#W "
3set -g pane-active-border-style "fg=${colors.accent}"
Die Farben kommen aus lib.theme.mkColors — dieselbe Palette, die WeeChat
und der Rest des Desktops tragen, nur durchgereicht. Dazu ein paar Gewohnheiten: Prefix auf C-a, baseIndex = 1, clock24, Maus aus (mouse = false — ich navigiere per Tastatur), und tmux-resurrect/continuum für Persistenz über Reboots hinweg. Resurrect stellt dabei sogar task und den AI-Agent wieder her:
1set -g @resurrect-processes 'ssh lazygit task "~yazi" "~oc->oc --continue"'
2set -g @resurrect-dir '${config.xdg.cacheHome}/tmux/resurrect'
3set -g @continuum-restore 'on'
4set -g @continuum-save-interval '5'
Aus tmux heraus reicht ein Binding — prefix + S öffnet denselben fzf-Picker als Popup (die l/L-Tasten sind hier für Pane-Navigation und -Resize reserviert):
1bind-key S display-popup -E -w 80% -h 70% "sesh connect \"$(sesh list --hide-duplicates | fzf)\""
Follow-up: Radicle- und Git-Status in der Statuszeile#
Seit dem ursprünglichen Artikel hat die Statuszeile zwei selbstgebaute Segmente bekommen, die den früheren zsh-RPROMPT ersetzen. Statt den Prompt jeder Shell mit Branch- und Dirty-Info vollzumüllen, zeigt tmux das jetzt einmal pro Pane — rechts in der Statuszeile, nur wenn relevant.
Git-Segment: Branch, SHA, Dirty-Marker#
Das Git-Segment (tmux-git-status) reproduziert den alten zsh-Prompt pixelgenau, nur eben in der tmux-Statuszeile. Es prüft per git rev-parse --is-inside-work-tree, ob das aktive Pane in einem Git-Repo sitzt — wenn nicht, bleibt es stumm. Wenn ja, zeigt es:
+!/<branch>/<shortsha>
+= staged changes (git diff --cached --quietschlägt fehl)!= unstaged changes (git diff --quietschlägt fehl)<branch>= aktueller Branch (fett), oder der Short-SHA bei detached HEAD<shortsha>= gekürzter Commit-Hash
Die Farben sind dieselben wie im alten Prompt — Branch in Fett, Dirty-Marker in Warnfarbe, SHA dezent. Das Segment ist ein writeShellApplication, das tmux den pane_current_path durchreicht:
1set -g status-right "#(${lib.getExe radStatus} '#{pane_current_path}')#(${lib.getExe gitStatus} '#{pane_current_path}')"
Radicle-Segment: Ahead/Behind vs. Canonical#
Das Radicle-Segment (tmux-rad-status) ist das interessantere der beiden. Es zeigt, wie der lokale Branch zum kanonischen rad/<branch>-Ref steht — also ob ich dem Rest des Netzwerks voraus bin, hinterherhänge oder synchron laufe:
rad:✓ — synchron
rad:⇡3 — 3 Commits ahead (unpushed)
rad:⇣2 — 2 Commits behind (braucht Rebase)
rad:⇡1⇣4 — divergiert
Das Symbol erscheint in Fuchsia (#fe4164), der Rest in den tmux-Statusfarben. Außerhalb von Radicle-Repos (kein refs/remotes/rad/<branch>) bleibt das Segment komplett stumm — kein rad:-Label, kein Platzhalter.
Der Vergleich läuft über git rev-list --left-right --count HEAD...refs/remotes/rad/<branch> — zwei Zahlen, eine für ahead, eine für behind, und daraus das Symbol. Kein teurer rad-Aufruf im Hot-Path; die einzige Git-Plumbing-Query ist billig.
Hintergrund-Sync: die kanonischen Refs frisch halten#
Damit die Ahead/Behind-Zahlen stimmen, müssen die rad/<branch>-Refs aktuell sein. Ein explizites rad sync vor jedem Blick auf die Statuszeile wäre lästig — also macht das Segment das selbst, gedrosselt und komplett entkoppelt:
Alle 120 Sekunden pro Repo (gesteuert über einen Timestamp in $XDG_CACHE_HOME/tmux-rad-status/) feuert das Segment einen detached Hintergrundjob:
1( { cd "$toplevel" \
2 && { timeout 30 rad sync --fetch --replicas 1 || true; } \
3 && { git fetch --quiet rad || true; }
4 } >/dev/null 2>&1 & ) >/dev/null 2>&1
--replicas 1 holt die Refs von genau einem Peer (dem Seed), timeout 30 verhindert hängende Prozesse, und das doppelte >/dev/null 2>&1 & schiebt alles in den Hintergrund — die Statuszeile blockiert nie. Der Sync läuft nur, wenn rad überhaupt im $PATH ist; auf Maschinen ohne Radicle-Client passiert nichts.
Das Ergebnis: Ich wechsle in ein Pane, und die Statuszeile zeigt mir ohne einen Tastendruck, ob ich noch Commits zum Pushen habe oder ob der Rest des Netzwerks schon weiter ist. Der alte zsh-RPROMPT ist damit endgültig im Ruhestand — die tmux-Statuszeile macht denselben Job, nur einmal pro Pane statt in jeder Shell.

Die Direktsprünge liegen stattdessen als Shell-Aliase auf der Hand: sl öffnet den fzf-Picker, sL springt zur letzten Session, und sN ist der bewusst clevere: Er hängt sich an eine bestehende Session an, die nach dem Git-Root des aktuellen Verzeichnisses benannt ist — und erzeugt nur dann eine neue (--root, die ~/projects/src/*-Wildcard greift), wenn es noch keine gibt:
1sN = ''root="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || pwd)";
2 if tmux has-session -t "=''${root##*/}" 2>/dev/null;
3 then sesh connect "''${root##*/}";
4 else sesh connect --root "$PWD"; fi'';
Der Umweg über den Namen statt --root ist kein Zufall: Verbindet man eine bereits existierende Session erneut per --root, läuft sesh ihre startup_command wieder durch — und hängt die Template-Fenster bei jedem Aufruf erneut an. „Erst nachsehen, dann verbinden" verhindert genau dieses Aufblähen.
So sieht das in der Praxis aus — aus einem Projektordner heraus ein sN, und die ~/projects/src/*-Wildcard zieht die PROJECTS-Vorlage hoch:
Die Tür: ein Hyprland-rofi-Prompt#
Das letzte Stück holt sesh aus dem Terminal heraus auf den Desktop. Ein neues Modul sesh-menu.nix baut ein kleines Shell-Programm: sesh listen, durch
rofi
picken, die gewählte Session in einem frischen kitty öffnen:
1launchSession = mkStartCommand { slice = "b"; } ''kitty --class sesh -e sesh connect "$selection"'';
2in
3pkgs.writeShellApplication {
4 name = "sesh-menu";
5 runtimeInputs = [ pkgs.sesh rofiPkg pkgs.coreutils ];
6 text = ''
7 selection="$(sesh list --hide-attached --hide-duplicates --tmux | rofi -dmenu -i -p sesh)" || exit 0
8 [ -n "$selection" ] || exit 0
9 exec ${launchSession}
10 '';
11}
Es spiegelt den fzf-Flow aus dem Terminal, nur mit rofi als Picker. Zwei Details, die es rund machen: --tmux lässt sesh die Auswahl tmux-bewusst rendern, und mkStartCommand { slice = "b"; } startet kitty als uwsm-scoped App — in derselben systemd-Slice wie jede andere GUI-App im Setup, sauber abgerechnet und beim Logout sauber beendet.
Verdrahtet ist es bedingt: Der Keybind und das Paket erscheinen nur, wenn sesh überhaupt aktiv ist —
1++ lib.optional (config.fr0st.apps.sesh.enable or false) (
2 mkLuaBind "$HYPR, s, exec, ${lib.getExe seshMenu}"
3)
$HYPR ist SUPER. Also: SUPER+s, die Session-Liste poppt mittig auf, Enter — und ein kitty öffnet sich in genau dem Layout, das oben in Nix steht. Kein cd, kein Split-Aufbau, kein Tool-Starten.
Kitty-Sessions adé: Musik, Monitoring und Debug als sesh-Templates#
Der ursprüngliche Artikel zeigte nur die BLOG- und PROJECTS-Sessions. Inzwischen sind drei weitere dazugekommen — und das ist mehr als nur „mehr Sessions". Es ist eine komplette Ablösung der alten kitty-Session-Dateien durch sesh.
Vorher lagen drei statische kitty-Session-Dateien in ~/.config/kitty/sessions/: MUSIC_PLAYER, SYSTEM_MONITOR und SYSTEM_DEBUG. Jede deklarierte ein fat:bias=30-Layout mit zwei Panes — cava über roon-tui, journalctl über btop, und so weiter. Das funktionierte, aber es war eine zweite, parallele Session-Welt neben sesh — kitty managte die Fenster, nicht tmux. Die Hyprland-Keybinds riefen kitty --session sessions/MUSIC_PLAYER statt sesh connect.
Jetzt sind alle drei als sesh-Templates deklariert, und die kitty-Session-Dateien sind gelöscht. Die Templates:
1MUSIC = {
2 name = "MUSIC";
3 startup_command = ''tmux next-window; tmux kill-window -t "$TMUX_PANE"'';
4 windows = [
5 {
6 name = "music";
7 startup_script = "tmux split-window -v -b -p 66 cava && ${musicPlayer}";
8 }
9 ];
10};
11
12MONITOR = {
13 name = "MONITOR";
14 startup_command = ''tmux next-window; tmux kill-window -t "$TMUX_PANE"'';
15 windows = [
16 { name = "journal"; startup_script = "sudo journalctl --boot=0 --lines 150 --follow"; }
17 { name = "processes"; startup_script = "btop"; }
18 ];
19};
20
21DEBUG = {
22 name = "DEBUG";
23 startup_command = ''tmux next-window; tmux kill-window -t "$TMUX_PANE"'';
24 windows = [
25 { name = "journal"; startup_script = "sudo journalctl --priority 1..4 --boot=0 --lines 500 --follow"; }
26 ];
27};
Die MUSIC-Session ist die interessanteste: split-window -v -b -p 66 cava teilt das Fenster vertikal, legt cava vor (-b) das aktuelle Pane und gibt ihm 66% der Höhe. Danach startet ${musicPlayer} im unteren Drittel — das ist roon-tui, wenn Roon aktiviert ist, sonst ncmpcpp. Die Entscheidung trifft Nix zur Build-Zeit, nicht der Nutzer zur Laufzeit.
Die Hyprland-Keybinds sind entsprechend umgestellt — $music_player, $system_monitor und $system_debug expandieren jetzt zu kitty --class music -e sesh connect MUSIC_PLAYER (und analog für die anderen). Die Window-Rules routen music- und cava-Fenster auf Workspace 9, system-Fenster auf Workspace 10. MOD+SHIFT+r lädt nicht nur Hyprland neu, sondern sourced auch die tmux-Config — nützlich, wenn man an den Binds schraubt.
Vim-Navigation in tmux: C-j/C-k statt Plugin#
Parallel zur Session-Migration habe ich den vim-tmux-navigator-Plugin rausgeworfen. Der Plugin machte C-h/C-j/C-k/C-l für Pane-Navigation — aber ich habe keine horizontalen Splits mehr, also brauche ich h/l nicht. Die Ersatz-Binds sind direkt in der tmux-Config, ohne Plugin:
# Mit Prefix
bind j select-pane -D
bind k select-pane -U
# Ohne Prefix (direkt)
bind -n C-j select-pane -D
bind -n C-k select-pane -U
C-j/C-k ohne Prefix — das ist die Muskelgedächtnis-Brücke, die vim-tmux-navigator hinterlassen hat, nur ohne die Abhängigkeit. Die Prefix-Varianten j/k bleiben für den Fall, dass C-j/C-k von einer Anwendung im Pane gefangen werden. Die Resize-Binds J/K (mit -r für Repeat) sind ebenfalls auf vertikal beschränkt.
Dieselbe vim-Logik habe ich gleich in roon-tui
durchgezogen: j/k für Navigation in allen Listen-Views (Browse, Queue, Zones, Grouping), h/l für prev/next Track im NowPlaying — ein Overlay-Patch, der die hartkodierten Keybindings des Upstreams überschreibt. Die Idee ist dieselbe wie bei tmux: ein Satz Navigation, der sich überall gleich anfühlt.
Was bleibt#
Der Bogen gefällt mir, weil er klein ist und trotzdem jeden Tag trägt. tmux liefert die Persistenz, sesh macht Session-Layouts deklarativ, rofi gibt ihnen eine Tür im Desktop — und alle drei liegen im selben Flake wie der Rest der Maschine. Die zwei Erkenntnisse, die ich mitnehme:
- Ein Session-Layout gehört in Code, nicht in Muskelgedächtnis. Sobald „die Panes aufbauen" ein Datensatz ist, baut man sie nicht mehr auf — man wählt sie.
- Der globale Pool war die einzige echte Hürde. seshs namensbasierter Window-Pool ist eine überraschende Einschränkung; der
<session>/<window>-Namespace plusrename-windowlöst sie sauber und ganz in Nix. Ein Helfer, einmal geschrieben, und wiederholte Fensternamen sind kein Thema mehr.
Das Repo liegt offen auf meinem Radicle
-Seed — rad:zVi9VheaDwbEgCUQUQ9sLwpHuaMo. Die sesh-, tmux- und Hyprland-Module sind unter modules/home/apps/ zu finden, jeder Trick als Kommentar direkt daneben.
