Terminal setup (Ghostty + tmux + zsh), Claude Code extras, and a GNOME desktop look. One command on a fresh machine, Ubuntu or macOS:
curl -fsSL https://xiaweiliu.com/dotfiles/install.sh | bashIt opens a menu — pick what you want with the arrow keys and space, Enter to start. Only the parts that work on the current machine are listed. Same script straight from GitHub, if the site is ever down:
curl -fsSL https://raw.githubusercontent.com/williamliu0516/dotfiles/main/install.sh | bashTo update a machine, run the same command again. If something has edited a file inside ~/.dotfiles (say conda init appending to the symlinked .zshrc), the edit is moved into git stash first so the update can go through; put machine-only shell settings in ~/.config/zsh/local.zsh instead. A running tmux server is told to reload its config. If that server is still an older tmux binary (the apt one from before the Nix switch), the installer says so; tmux kill-server (alias tk) replaces it, and also closes whatever is running inside tmux.
| id | Linux | macOS | |
|---|---|---|---|
terminal |
Ghostty + tmux + zsh, fonts, plugins | ✓ | ✓ |
claude-tmux |
tmux window numbers coloured by Claude Code state; Claude panes reopen their conversation after a restore (needs terminal) |
✓ | ✓ |
claude-push |
Phone / iPad push via ntfy whenever Claude stops or waits for approval | ✓ | ✓ |
claude-img |
Images Claude reads or screenshots appear right in the transcript, as in the mobile app, also inside tmux; needs Ghostty or kitty (needs terminal) |
✓ | ✓ |
claude-sims |
At most 3 iOS simulators booted across all Claude Code sessions; shuts them down when their session ends or after 45 min unused | ✓ | |
claude-killguard |
Lists what a pkill / killall / kill would hit before Claude Code runs it; refuses options after the pattern, and kills that reach another session, Docker or the system until Claude has seen the list |
✓ | |
claude-statusline |
Claude Code status line — folder, branch, model, 5h and weekly usage. From claude-status-bar | ✓ | ✓ |
keyboard-display |
Claude Code sessions on a keyboard's 142×428 panel. From context-keyboard-display; the hotkeys are macOS-only | ✓ | ✓ |
gnome-desktop |
Orchis theme, Tela icons, lighter top bar and dock, auto-hiding top bar, Super+Enter drop-down Ghostty | GNOME |
Skip the menu:
bash install.sh --list # what this machine can install
bash install.sh --only terminal,claude-tmux # dependencies are added for you
bash install.sh --allEvery module is safe to re-run: it skips what's already there and backs up anything it replaces.
| Terminal | Ghostty — snappy theme (dark/light, follows the system), Maple Mono NF CN, cursor-trail shader (off — it keeps a core 18% busy while idle), semi-transparent with a frosted-glass blur of the real desktop (Ghostty's own blur on macOS, Blur my Shell on GNOME) |
| Shell | zsh + powerlevel10k, autosuggestions, fast-syntax-highlighting, history prefix search |
| Multiplexer | tmux — one menu key instead of a wall of shortcuts, sessions survive reboot |
| Tools | eza, bat, zoxide, fzf with fd and ripgrep, btop (Alt+b), poppler for yazi's PDF preview, yazi (y in the shell drops you in the directory you quit from; Alt+y opens it in its own tmux window, since quitting from a popup stalls 5 s on yazi 26.x) |
Right-arrow accepts the greyed-out suggestion; Tab completes as usual. Up-arrow filters history by what you've already typed. Ctrl+R fuzzy-searches all of it, with the full command previewed below. Ctrl+T picks a file (fd lists them: fast, follows .gitignore; Ctrl+H inside fzf toggles hidden files in) with a bat preview on the right; Alt+C outside tmux jumps to a directory. Tab itself opens fzf (fzf-tab): type a few letters to filter any completion — paths, git branches, ssh hosts, command options — with a preview of directories, files and branches; / while completing a path dives into the chosen directory.
A command that runs longer than 30 seconds sends a desktop notification when it finishes (with the exit code if it failed), but only if you're not looking at that terminal. Editors, pagers, ssh and other interactive programs are excluded. bin/notify is the helper; notify --if-away title body from any script gets the same behaviour.
yazi shows hidden files, gives the preview pane the most room, and shows sizes in the list (config/yazi/yazi.toml). The list is ordered regular directories → regular files → hidden directories → hidden files, each group newest first; yazi cannot do "hidden last" on its own, so config/yazi/plugins/hidden-last.yazi computes the order through yazi's custom sort (needs a build newer than 26.9.1, so the installer puts yazi's prebuilt nightly in ~/.local/bin/yazi on both systems; re-running it fetches the latest nightly).
btop draws no background of its own (theme_background = false), so the frosted glass shows through it like everywhere else, and its colours come from the same snappy palette as Ghostty (config/btop/themes/). btop can't follow the system's light/dark mode by itself and has no flag to pick a theme, so bin/btop (linked to ~/.local/bin/btop, ahead of the real one) reads the system appearance at launch and starts btop with a copy of the config pointing at snappy or snappy_light. Settings changed inside btop aren't saved on exit; edit config/btop/btop.conf instead.
On GNOME, Ctrl+Enter maximizes rather than going fullscreen: GNOME stops compositing what's behind a fullscreen window, which would kill the blur. With the auto-hiding top bar it looks the same.
On macOS, Option works as Alt, so the tmux keys below are the same on both. Ghostty's own Option+←/→ (word movement) is unbound so tmux gets them for window switching, as on Linux; outside tmux they still move by word. Ctrl+←/→ is taken by Mission Control on macOS — turn off "Move left/right a space" in System Settings › Keyboard › Keyboard Shortcuts › Mission Control if you want it back in the shell.
On macOS, Ctrl+Enter (or Cmd+Enter) uses non-native fullscreen: the window fills the screen on the current Space instead of moving to a new one, so the blur of what's behind it survives. Native fullscreen would put it on an empty Space with nothing to blur. Native macOS tabs don't work in this mode; use tmux windows.
You only need one key: Alt+m opens a menu with every action labelled; pressing it again closes the menu. Ctrl+b ?, or Alt+/ anywhere in the shell (inside tmux or not), shows a cheatsheet covering the shell keys, the tmux keys and what happens automatically; the tmux part is generated from the config itself, so it can't drift out of date.
What the menu holds (the letter in brackets picks the item directly):
| Group | Items |
|---|---|
| Panes | new pane s · zoom / unzoom f |
| Tools | Claude Code in a new window C · pick a Claude session to resume r · continue the last Claude session c · yazi y · btop b (each in its own window, closes when you quit) |
| Layout | make this the main pane m · auto-tiling on / off for this window t · cycle tiling mode row / dwm / grid / dwm2 T |
| Text | scroll back / copy mode v · search this pane / · paste p · clipboard history P · save pane output to a file S |
Sessions z |
detach d · switch session / open project s · new n · rename r · kill this session X · pick another session to kill k (only lists sessions nobody is attached to) · kill all others K |
| Close | kill pane x · kill window X |
cheatsheet ? · close menu Alt+m |
Window management is deliberately not in the menu: Alt+n new window, Alt+1–5 or Alt+←/→ to switch, Alt+; back to the previous one, and tmux's own Ctrl+b , to rename.
Direct keys worth knowing: Alt+b btop · Alt+s session / project switcher (fzf over open sessions and your project folders; picking a folder starts a session named after it; list your roots one per line in ~/.config/tmux/projects, default ~/projects ~/code ~/src ~/dev ~/.dotfiles) · Alt+c Claude Code here · Alt+y yazi · Alt+h/j/k/l panes · Alt+Shift+←/→/↑/↓ resize the current pane, neighbours shrink to match · Alt+←/→ windows · Alt+f zoom · Alt+d detach · Ctrl+b K pick a session to kill. On macOS, Alt is Option.
No Alt on your keyboard? Every Alt+key also works as Ctrl+b then the same key — the prefix table is mirrored from the Alt bindings at startup (bin/tmux/alt-fallback.sh), so it can't drift. Meant for iPad SSH clients, where iPadOS eats Option unless the app's "Option as Meta" switch is on (Termius: Profile › Settings › Keyboard; Moshi: keyboard settings). The mirror overrides a few tmux defaults in the prefix table: n is new window (not next), ; previous window (not pane), ←/→ switch windows (not panes), Space new pane (not next layout), x kills without asking.
Panes tile themselves the way dwm does (idea borrowed from ausbxuse's tmux config): one main pane on the left takes half the width, every other pane stacks top to bottom on the right, and the layout is redone whenever a pane opens or closes, however it was opened. Alt+Space opens a new pane at the bottom of the stack · Alt+Enter swaps the current pane with the main one (on the main pane, it swaps with the top of the stack) · Alt+x closes the pane · Alt+t turns tiling off for this window when you want a hand-made layout, and on again. Widen or narrow the main pane with Alt+Shift+←/→ and later retiles keep that width.
Alt+Shift+t cycles the current window through the tiling modes (the menu has it under T): row (parallel, for a 32:9 ultrawide: every pane is a full-height column of equal width, side by side in one line, new panes appended at the right edge) → dwm → and, only when the window has exactly four panes, grid (2×2) → dwm2 (main pane on the left, one pane top-right, the bottom-right split left / right) → back to row. With any other pane count the two four-pane modes fall back to dwm and come back once the window is at four panes again. set -g @tiling-mode row in the config makes row the default on that machine. Alt+Enter still swaps with the leftmost pane.
Split a window and each pane gets a title bar — index, running command, path — with the active one in the accent colour. A single pane has no title bar, so it doesn't cost a row.
Window numbers are colour-coded by Claude Code state — cyan running, yellow needs approval, green done, red error. When any window is waiting on you, a yellow bell with the count appears at the left of the status bar, and a desktop notification fires when Claude is waiting, finished or failed, if you're not looking at that window (it's not the current tmux window, or Ghostty isn't the front app).
claude-push pushes to your phone every time Claude stops, and when it waits for approval: the session title, the folder and the first line of Claude's last reply. Settings live in ~/.config/claude-push/config.json, outside the repo: on ntfy.sh the topic name is the password. The reply excerpt passes through the ntfy server; set excerpt to false to send only the title. Copy that file to another machine to share the topic.
claude-img puts the images a Claude Code session looks at into the transcript, under the "Read 2 files" line or the tool's result, where the mobile app shows them. Claude Code itself prints only that line. The inline-images plugin (config/claude/inline-images, loaded through CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json) draws each image with Claude Code's own picture element, which speaks the kitty graphics protocol with Unicode placeholders; claude-img png turns JPEG / WebP into the PNG that protocol takes. Inside tmux that element is off, and forcing it on (CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1) is not enough, because tmux swallows the image commands. So in tmux the zsh claude function starts Claude Code under tmux-kgp, a small pty in between that wraps each image command in tmux passthrough (allow-passthrough on) and gives every session its own image ids. Claude Code in tmux numbers images from 1 in 256-colour mode, so two panes would show each other's pictures; tmux-kgp adds a per-session high byte, which the kitty protocol carries as a third placeholder diacritic. Ctrl+Z and fg work as before, and the pane still shows claude as its command. Nothing is drawn when no Ghostty / kitty client is looking at the session (Blink, Termius, plain mosh); with both attached, such a client shows the placeholder characters where the picture would be. webmosh does show them, in mosh tabs too: tmux-kgp lists each picture as ~/.cache/webmosh/kitty/<id> and the page fetches it through the host's webmosh-gate, and a tmux client started in a webmosh session (WEBMOSH=1 in its environment) counts as one that can show pictures.
claude-sims keeps parallel Claude Code sessions from piling up iOS simulators: each one runs about 150 processes, and five at once froze a 10-core Mac. A PreToolUse hook refuses a command that would boot a fourth simulator (CLAUDE_SIMS_MAX changes the limit) unless it names one that is already booted, and tells Claude who owns what. The session whose boot command brought a simulator up owns it; it is shut down when that session ends or when none of its commands has touched a simulator for 45 minutes, and one that no session owns (booted by hand, or before the hook existed) after 4 hours. A launchd job checks every 10 minutes. Simulators are only shut down, never deleted. claude-sims lists them; shutdowns are logged in ~/.cache/claude-sims/log.
claude-killguard checks a pkill, killall or kill before a Claude Code session runs it. macOS pkill and pgrep stop reading options at the first pattern, so pkill -f "run.sh --one" -P 1 takes -P and 1 as two more patterns and signals every process whose command line contains a 1; on 2026-10-14 that took down Docker's VM, another session's builds and twenty-odd system widgets. A PreToolUse hook refuses options after the pattern, more than one pkill pattern, kill -1 / 0, and patterns built from variables. Otherwise it lists the targets with pgrep first (also for kill $(pgrep …), pgrep … | xargs kill and for p in $(pgrep …) loops). It refuses outright when they include the session itself, or another Claude Code / Codex session that is still running or anything it started. Docker, tmux / ssh / mosh, macOS system processes, simulators, whatever ~/.config/claude-killguard/protect lists (one regex per line, matched against the command line), and more than 20 targets (CLAUDE_KILLGUARD_LIMIT) need KILLGUARD_OK=<count> in front of the command, after Claude has seen the list. Processes the session started itself are never protected, and kill -0 is not checked. claude-killguard check '<command>' shows the verdict for a command; refusals are logged in ~/.cache/claude-killguard/log.
Sessions auto-save every 15 minutes and restore on start (tmux-resurrect + continuum). resurrect brings a Claude Code pane back as a plain shell, so with claude-tmux installed each pane also records its Claude session ID, the save carries it, and after a restore claude --resume <id> runs in the same pane. A save is at most 15 minutes old, so a session started after the last save doesn't come back.
mosh is installed with the terminal module, so Blink Shell, Termius or Moshi on an iPad can connect over mosh and survive sleeping the iPad or changing networks. Two things make that work out of the box: config/zsh/zshenv is sourced by every zsh, including the non-interactive one sshd starts for mosh-server new, so Homebrew is on the PATH and a UTF-8 LANG is set even when the client sends none. Nothing else to configure: mosh uses UDP 60000–61000 on the server, which a Tailscale link passes as-is. Pair it with a startup command of tmux new -As main in the client and you land in the same session every time.
The status bar starts with the Claude Code account this tmux is billing (personal, or the organization's name; from claude-status-bar's --account, so Claude panes don't repeat it), then CPU, memory in use and GPU load (amber above 70%, red above 90%; the GPU segment appears where it can be read — Apple Silicon, NVIDIA, AMD), then battery level with time to empty, or time to full while charging (/sys and upower on Linux, pmset on macOS). On a desktop with no battery the segment disappears entirely.
Everything goes into your home directory — nothing under /usr is touched. Before changing anything the module dumps all of dconf to ~/desktop-backup-<timestamp>.dconf; dconf load / < that-file puts it all back.
- Orchis-Dark-Compact shell and GTK theme, Tela-circle-dark icons.
- Top bar and dock a lighter translucent grey instead of near-black. The patch matches CSS selectors rather than line numbers and keeps
gnome-shell.css.orig; reinstalling Orchis undoes it, re-running the module redoes it. - Dock: upstream Dash to Dock replaces Ubuntu Dock, whose own stylesheet overrides the theme.
- Hide Top Bar in intellihide mode: the bar hides only when a window covers it, and slides back when the pointer touches the top edge.
- Quake Terminal:
Super+Enterdrops Ghostty down from the top; press again to put it away. - Blur my Shell blurs only behind Ghostty; panel, dock and overview blur stay off to save power.
Log out and back in afterwards — GNOME on Wayland only discovers new extensions at login.
install.sh menu, then runs the chosen modules in order
lib/common.sh shared helpers (output, link, apt/brew)
modules/<id>/install.sh
one per module; the header declares name, desc, os, needs
nix/
flake.nix pins nixpkgs + Home Manager; flake.lock is the version lock
home.nix the Linux CLI tool list and the font; configs stay symlinked, not managed by Home Manager
config/
zsh/ zshrc, p10k.zsh, zshenv (PATH + locale for non-interactive shells: ssh commands, mosh-server)
tmux/ tmux.conf
claude/ inline-images: Claude Code plugin that draws images in the transcript
ghostty/ config.ghostty (shared), linux.ghostty / macos.ghostty (linked as platform.ghostty), themes/, shaders/
gnome/ extensions.dconf — extension settings, merged with dconf load
bin/
claude-push Claude Code hook → ntfy push; `claude-push --test` sends one
claude-img PNG conversion and terminal facts for the inline-images plugin
tmux-kgp pty in front of Claude Code that lets its kitty images through tmux, ids kept apart per session
claude-sims Claude Code hooks + launchd job that cap and shut down iOS simulators
claude-killguard Claude Code hook that lists what a pkill / killall / kill would hit and refuses the wrong ones
theme-preview preview any Ghostty theme in the terminal, ranked by contrast
tmux/cheatsheet.sh the Ctrl+b ? popup
tmux/claude-state.sh Claude Code hook → tmux window colour, and the pane's session ID
tmux/claude-resume.sh resurrect hooks: save each pane's Claude session, resume it after a restore
tmux/save-pane.sh dump the current pane's scrollback to a file
tmux/battery.sh battery segment for the status bar
tmux/claude-account.sh the Claude Code account this tmux uses, for the status bar
index.html the page at xiaweiliu.com/dotfiles
To add a module, create modules/<id>/install.sh with the header comments; the menu picks it up. Add the id to ORDER in install.sh if it should sort before the others.
Everything is symlinked from ~/.dotfiles, so edits in the repo are live. Existing files are backed up as <name>.bak.<timestamp> before being replaced.
dev/ubuntu.yaml is a Lima definition for a headless Ubuntu 24.04 VM with ~/.dotfiles mounted at the same path, read-write. Edit on the Mac, test in the VM, no pull needed.
brew install lima
limactl start --name ubuntu ~/.dotfiles/dev/ubuntu.yaml # first time: downloads the image
limactl shell ubuntu -- env DOTFILES_DIR=/Users/$USER/.dotfiles bash /Users/$USER/.dotfiles/install.sh --only terminal,claude-tmux
limactl shell ubuntu # then: tmux, Alt+m, Ctrl+b ?
limactl stop ubuntu # limactl delete ubuntu to remove itOpen the shell from Ghostty on the Mac and the Alt keys travel through Ghostty into the VM's tmux, so the key bindings get a real test. What it can't test is GNOME itself — Blur my Shell, Ctrl+Enter maximize, the gnome-desktop module.
- Linux: Debian/Ubuntu with
aptandsudo. apt installs only what has to be system-level (zsh as the login shell, mosh,notify-send, git, curl). Everything else in the Tools row comes from Nix + Home Manager, pinned innix/flake.lock, so every machine gets the same versions whatever its Ubuntu release. The installer sets up Nix with the Determinate installer if it isn't there (/nix/nix-installer uninstallremoves it). Ghostty is a GUI app and stays out of Nix (Nix-built GUI programs can't find the system's OpenGL drivers on non-NixOS): it comes from apt on Ubuntu 26.04+, otherwise the official snap (snap install ghostty --classic). - macOS: Homebrew and the Xcode command line tools (
xcode-select --install). Ghostty and the font come from brew casks; zsh stays the system/bin/zsh. Before installing, the script checks which packages Homebrew has a prebuilt bottle for on this Mac (many have none on Intel). Without one, fd, ripgrep, bat, zoxide and fzf come from their official GitHub release builds into~/.local/bin; small source builds go ahead; a long one (anything pulling in LLVM or Rust, or more than five packages) is asked about up front, and skipped without a terminal. - Both: yazi is the prebuilt nightly from its GitHub releases, not brew or Nix, because the config needs a build newer than 26.9.1.
DOTFILES_DIR=~/src/dotfiles bash install.sh # clone somewhere else
MAPLE_FONT_VERSION=v7.8 bash install.sh # pin the font version (macOS / fallback download only; Linux gets it from nixpkgs)
YAZI_TAG=latest bash install.sh # yazi's latest release instead of the nightly (or a tag like v26.9.1)
DOTFILES_SOURCE_BUILD=1 bash install.sh # macOS: compile long source builds without askingEach slow step (a brew install, a download, a git clone) shows on one line while it runs. Ctrl-C skips just that step; pressing it twice within two seconds stops the installer. The full output of the last run is in ~/.cache/dotfiles-install.log.
The versions are whatever nix/flake.lock says. To move every machine forward, update the lock on one, commit it, and re-run the installer (or home-manager switch) on the others:
cd ~/.dotfiles/nix
nix flake update # newest nixpkgs / Home Manager
home-manager switch --flake path:.#linux --impure # apply here
home-manager generations # list previous states; each has a rollback scriptTo add a tool, put it in home.packages in nix/home.nix and switch. --impure is needed because home.nix reads $USER and $HOME, which is what lets one config serve any username.
terminal changes your login shell to zsh (chsh) on Linux. claude-tmux merges its hooks into ~/.claude/settings.json, backing the file up first, and leaves it alone if they're already there. claude-statusline asks before replacing a local statusline.py that differs from the published one. Nothing outside $HOME is touched except the packages themselves.
theme-preview ranks every installed Ghostty theme by WCAG contrast and renders a real sample of each — useful when a theme looks nice but reads badly.