Jedes Projekt, das ich anfasse, will dieselbe Bühne: ein Fenster mit Claude oben und einer Shell für task unten, daneben ein Git-Fenster, ein Datei-Browser, vielleicht ein 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 # Split: oben claude, unten eine Shell, die `task` fährt.
13 startup_script = ''bottom=$(tmux split-window -v -t "$TMUX_PANE" -P -F '#{pane_id}'); tmux send-keys -t "$bottom" task Enter; tmux select-pane -t "$TMUX_PANE"; claude --resume || claude'';
14 }
15 {
16 name = "theme";
17 path = "${config.xdg.userDirs.projects}/src/hugo/fuchsbau";
18 startup_script = ''tmux split-window -v -t "$TMUX_PANE"; tmux select-pane -t "$TMUX_PANE"; claude --resume || claude'';
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 (Claude + task-Shell), Theme (Claude + Shell) und ein preview-Fenster, 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 claude-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 = "claude"; startup_script = "claude --resume || claude"; }
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 Claude-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 Claude-Code wieder her:
1set -g @resurrect-processes 'ssh lazygit task "~yazi" "~claude-code->claude --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)\""
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: Es 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.
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.
