Skip to content

Instantly share code, notes, and snippets.

@fersilva16
Last active March 19, 2026 19:11
Show Gist options
  • Select an option

  • Save fersilva16/73ca20e9119fef5768b0c3446127accf to your computer and use it in GitHub Desktop.

Select an option

Save fersilva16/73ca20e9119fef5768b0c3446127accf to your computer and use it in GitHub Desktop.
nix-darwin + OpenCode + tmux + Git Worktrees: complete macOS dev environment from scratch

nix-darwin + OpenCode + tmux + Git Worktrees

A complete macOS dev environment managed by Nix. Everything is declarative — one darwin-rebuild switch rebuilds your entire setup from scratch. This guide walks you through creating every file from zero.

┌─────────────────────────────────────────────────────────────┐
│ Ghostty terminal                                            │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ tmux                                                    ││
│  │  ┌──────────────────────┬──────────────────────────────┐││
│  │  │ nvim                 │ opencode (prefix+o)          │││
│  │  │  opencode.nvim       │  oh-my-opencode agents       │││
│  │  │  supermaven          │  notifier → tmux status bar  │││
│  │  │  Ctrl+. toggle       │  /lin → Linear context       │││
│  │  ├──────────────────────┤                              │││
│  │  │ lazygit (prefix+l)   │                              │││
│  │  │  Ctrl+a = AI commit  │                              │││
│  │  └──────────────────────┴──────────────────────────────┘││
│  │  status: λ session │ 1 nvim │ path │  main  +2 -1     ││
│  └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘

Worktree workflow:
  main session ──wt feature──→ main/feature session (new tmux session + git worktree)
                 ──wt fix────→ main/fix session
                 ──wtl ENG-1─→ main/fix session (from Linear issue, marks In Progress)

Table of Contents

  1. What you're building
  2. How the workflow works
  3. Keybinding quick reference
  4. Prerequisites
  5. Install Nix
  6. Create your config repo
  7. Infrastructure files
  8. System modules
  9. Shell & CLI modules
  10. Dev tool modules
  11. Editor module (Neovim)
  12. Terminal module (Ghostty)
  13. Custom overlay packages (tmux scripts)
  14. First build!
  15. Set up API keys
  16. Adding new apps
  17. Day-to-day commands

1. What you're building

After following this guide, you'll have:

  • Fish shell with git aliases (g, ga, gc, gp), PR workflow (ghpc, ghpm), and project tools (ds, pj, envsource)
  • tmux with Ctrl+Space prefix, git status bar, notification system, and one-key panes for OpenCode and Lazygit
  • OpenCode AI coding assistant with oh-my-opencode agents, tmux notifications, and Linear issue integration
  • Git worktrees (wt) that create isolated tmux sessions per branch — switch contexts instantly
  • Linear integration (wtl) that creates worktrees from issues, marking them In Progress
  • Neovim with LSP for 16 languages, Telescope, opencode.nvim, Supermaven AI autocomplete
  • Lazygit with Ctrl+a to generate commits via AI, and PR shortcuts
  • Ghostty terminal that auto-launches tmux with Flexoki Light theme
  • Declarative Homebrew — GUI apps managed via Nix (anything not declared gets removed on rebuild)
  • Touch ID for sudo (even inside tmux)

Everything is managed by Nix. Change a .nix file, run one command, done.


2. Prerequisites

  • macOS on Apple Silicon (M1/M2/M3/M4)
  • An Anthropic API key (for OpenCode)
  • A Supermaven account (free tier, for AI autocomplete in nvim — optional)
  • A 1Password account (for SSH key signing — or you can remove the signing config from git.nix)

3. Install Nix

# Install Nix (the package manager)
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

# Install Rosetta (needed for some Homebrew x86 packages)
softwareupdate --install-rosetta

Close and reopen your terminal after installing Nix.


4. Create your config repo

Run these commands to set up the directory structure. Every file you'll create is listed here.

mkdir -p ~/nix-config && cd ~/nix-config
git init

# Infrastructure
mkdir -p lib
mkdir -p overlay/pkgs/{flexoki-tmux,tmux-extras,tmux-nerd-font-window-name}

# Modules
mkdir -p modules/{hosts,users}
mkdir -p modules/system
mkdir -p modules/darwin
mkdir -p modules/fonts
mkdir -p modules/cli
mkdir -p modules/dev
mkdir -p modules/editor
mkdir -p modules/terminal
mkdir -p modules/browser

Now follow each section below and create every file. The comment at the top of each code block shows the file path.


5. Infrastructure files

flake.nix — the entry point

This declares all your dependencies (nixpkgs, home-manager, nix-darwin, Homebrew) and wires them together.

# flake.nix
{
  description = "My nix-darwin configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    utils.url = "github:numtide/flake-utils";

    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    darwin = {
      url = "github:lnl7/nix-darwin/master";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    nix-homebrew = {
      url = "github:zhaofengli/nix-homebrew";
      inputs.nixpkgs.follows = "nixpkgs";
    };

    # Homebrew taps (pinned via flake, not mutable)
    homebrew-core = { url = "github:homebrew/homebrew-core"; flake = false; };
    homebrew-cask = { url = "github:homebrew/homebrew-cask"; flake = false; };
    homebrew-bundle = { url = "github:homebrew/homebrew-bundle"; flake = false; };
  };

  outputs = { nixpkgs, utils, ... }@inputs:
    let
      overlay = import ./overlay/overlay.nix;
      overlays = [ overlay ];
      mkDarwinHost = import ./lib/mkDarwinHost.nix { inherit inputs overlays; };
    in
    {
      inherit overlay overlays;

      darwinConfigurations = {
        # CHANGE THIS: replace "mymac" with your hostname
        # Run: scutil --get LocalHostName
        mymac = mkDarwinHost ./modules/hosts/mymac.nix;
      };
    }
    // utils.lib.eachDefaultSystem (system:
      let pkgs = import nixpkgs { inherit system overlays; };
      in {
        packages = pkgs;
        devShell = pkgs.mkShell {
          buildInputs = with pkgs; [ nil statix nixfmt-rfc-style shellcheck nodePackages.prettier pre-commit ];
          shellHook = ''pre-commit install -f'';
        };
      }
    );
}

lib/mkDarwinHost.nix — system factory

# lib/mkDarwinHost.nix
{ inputs, overlays }:
let
  system = "aarch64-darwin";
  inherit (inputs) nixpkgs home-manager darwin nix-homebrew;
in
host:
darwin.lib.darwinSystem {
  inherit system;
  specialArgs = { inherit inputs system; };
  modules = [
    home-manager.darwinModules.home-manager
    nix-homebrew.darwinModules.nix-homebrew
    host
    {
      nixpkgs = { inherit overlays; config.allowUnfree = true; };
      home-manager = { useGlobalPkgs = true; };
    }
  ];
}

lib/mkUserImports.nix — threads your username through every module

# lib/mkUserImports.nix
{ lib, inputs, ... }@args:
let inherit (inputs) home-manager;
in
username: imports:
lib.forEach imports (imp:
  import imp (args // { inherit (home-manager) lib; inherit username; })
)

overlay/overlay.nix and overlay/pkgs/pkgs.nix

# overlay/overlay.nix
self: super: { } // import ./pkgs/pkgs.nix self
# overlay/pkgs/pkgs.nix
pkgs: {
  flexoki-tmux = pkgs.callPackage ./flexoki-tmux/flexoki-tmux.nix { };
  tmux-extras = pkgs.callPackage ./tmux-extras/tmux-extras.nix { };
  tmux-nerd-font-window-name =
    pkgs.callPackage ./tmux-nerd-font-window-name/tmux-nerd-font-window-name.nix { };
}

The overlay packages (flexoki-tmux, tmux-extras, tmux-nerd-font-window-name) are in section 11 below.


6. System modules

Host config

# modules/hosts/mymac.nix
# CHANGE THIS: rename the file to match your hostname
_: {
  networking.hostName = "mymac"; # CHANGE THIS: run scutil --get LocalHostName

  imports = [
    ../users/mymac-yourname.nix    # CHANGE THIS: point to your user file

    ../darwin/darwin-default.nix
    ../darwin/sudo-touchid.nix
    ../darwin/watcher.nix
    ../fonts/caskaydia-cove.nix
    ../system/nix.nix
    ../system/pkg-config.nix
    ../cli/wget.nix
    ../cli/unrar.nix
  ];
}

User config (the import list)

# modules/users/mymac-yourname.nix
# CHANGE THIS: rename the file
{ pkgs, ... }@args:
let
  mkUserImports = import ../../lib/mkUserImports.nix args;
  username = "yourname"; # CHANGE THIS: run whoami
in
{
  imports = mkUserImports username [
    # System
    ../system/home.nix
    ../darwin/primary-user.nix
    ../darwin/homebrew.nix

    # Shell & CLI
    ../cli/fish.nix
    ../cli/starship.nix
    ../cli/direnv.nix
    ../cli/tmux.nix
    ../cli/zoxide.nix
    ../cli/bat.nix
    ../cli/eza.nix
    ../cli/fzf.nix
    ../cli/ripgrep.nix

    # Dev tools
    ../dev/git.nix
    ../dev/lazygit.nix
    ../dev/opencode.nix
    ../dev/claude-code.nix

    # Editor
    ../editor/nvim.nix

    # Browser
    ../browser/firefox.nix

    # Terminal
    ../terminal/ghostty.nix

    # ── Add your own below ──
    # Create a file like ../chat/slack.nix with: _: { homebrew.casks = [ "slack" ]; }
    # Then add the import here and rebuild.
  ];
}

Small system modules (create each file)

# modules/system/nix.nix
{ pkgs, ... }:
{
  nix = {
    package = pkgs.nixVersions.latest;
    extraOptions = ''
      experimental-features = nix-command flakes
    '';
  };
}
# modules/system/home.nix
{ username, ... }:
let homeDirectory = "/Users/${username}";
in
{
  users.users.${username}.home = homeDirectory;
  home-manager.users.${username} = {
    home = { inherit homeDirectory username; stateVersion = "25.05"; };
  };
}
# modules/system/pkg-config.nix
{ pkgs, ... }: { environment.systemPackages = with pkgs; [ pkg-config ]; }
# modules/darwin/primary-user.nix
{ username, ... }: { system.primaryUser = username; }
# modules/darwin/darwin-default.nix — macOS trackpad, dock, finder settings
_: {
  system.stateVersion = 5;
  system.defaults = {
    trackpad = { Clicking = true; TrackpadThreeFingerDrag = true; };
    dock = { wvous-br-corner = 1; mru-spaces = false; autohide = true; show-recents = false; };
    finder = { AppleShowAllExtensions = true; AppleShowAllFiles = true; };
    menuExtraClock = { ShowAMPM = true; };
  };
}
# modules/darwin/sudo-touchid.nix — Touch ID for sudo, even inside tmux
{ pkgs, ... }:
{
  environment.systemPackages = with pkgs; [ pam-reattach ];
  environment.etc = {
    "pam.d/sudo_local" = {
      text = ''
        auth       optional       ${pkgs.pam-reattach}/lib/pam/pam_reattach.so
        auth       sufficient     pam_tid.so
      '';
    };
  };
}
# modules/darwin/watcher.nix — increase file watcher limits
{ pkgs, ... }:
{
  environment.etc = {
    "sysctl.conf" = {
      enable = true;
      text = ''
        kern.maxfiles=131072
        kern.maxfilesperproc=65536
      '';
    };
  };
}
# modules/darwin/homebrew.nix — declarative Homebrew
{ username, inputs, config, ... }:
let inherit (inputs) homebrew-core homebrew-cask homebrew-bundle;
in
{
  nix-homebrew = {
    enable = true;
    enableRosetta = true;
    user = username;
    taps = {
      "homebrew/homebrew-core" = homebrew-core;
      "homebrew/homebrew-cask" = homebrew-cask;
      "homebrew/homebrew-bundle" = homebrew-bundle;
    };
    mutableTaps = false;
  };
  homebrew = {
    enable = true;
    # WARNING: any cask NOT declared in your modules will be REMOVED on rebuild
    onActivation.cleanup = "zap";
    taps = builtins.attrNames config.nix-homebrew.taps;
  };
}
# modules/fonts/caskaydia-cove.nix
{ pkgs, ... }: { fonts.packages = with pkgs; [ nerd-fonts.caskaydia-cove ]; }
# modules/browser/firefox.nix
_: { homebrew.casks = [ "firefox" ]; }
# modules/cli/wget.nix
{ pkgs, ... }: { environment.systemPackages = with pkgs; [ wget ]; }
# modules/cli/unrar.nix
{ pkgs, ... }: { environment.systemPackages = with pkgs; [ unrar ]; }

7. Shell & CLI modules

Fish shell — aliases, functions, worktree commands

This is the big one. It sets Fish as your default shell and defines all the git workflow functions.

# modules/cli/fish.nix
{ username, pkgs, lib, ... }:
{
  environment = {
    systemPackages = [ pkgs.fish ];
    shells = [ pkgs.fish ];
  };

  users.users.${username} = { shell = pkgs.fish; };
  programs.fish.enable = true;

  home-manager.users.${username} = {
    programs.fish = {
      enable = true;

      interactiveShellInit = ''
        ssh-add --apple-load-keychain 2> /dev/null

        set fish_cursor_default block
        set fish_cursor_insert line
        set -U fish_greeting

        fish_add_path -amP /usr/bin
        fish_add_path -amP /opt/homebrew/bin
        fish_add_path -amP /opt/local/bin
        fish_add_path -m /run/current-system/sw/bin
        fish_add_path -m /Users/${username}/.nix-profile/bin
      '';

      shellAliases = {
        g = "git";
        ga = "git add";
        gaa = "git add .";
        gb = "git branch";
        gc = "git commit";
        gp = "git push";
        ds = "nix develop . --command $SHELL";
        ls = "eza -lag";
        cat = "bat";
      };

      functions = {
        # Push + create PR + open in browser
        ghpc = "git push && gh pr create --fill $argv && gh pr view --web";

        # Merge PR (squash) + pull main + cleanup worktree
        ghpm = ''
          gh pr merge -s --admin $argv
          or return 1
          set main_root (git worktree list --porcelain | head -1 | string replace "worktree " "")
          set current_root (git rev-parse --show-toplevel)
          git -C "$main_root" pull
          if test "$main_root" != "$current_root"; and set -q TMUX
            set wt_name (basename $current_root)
            wtrm --force $wt_name
          end
        '';

        # Push + create + merge in one command
        ghpcm = "ghpc $argv && ghpm";

        # Jump to project and enter dev shell
        pj = "cd $argv; ds";

        fish_command_not_found = "__fish_default_command_not_found_handler $argv";

        # Checkout branch with auto-pull
        gco = ''
          set current_branch (git rev-parse --abbrev-ref HEAD)
          git checkout $argv; and if not string match -q -- '-*' $argv && test "$current_branch" != "$argv"
            git pull
          end
        '';

        # Source a .env file into current shell
        envsource = ''
          for line in (cat $argv | grep -v '^#' | grep -v '^\s*$')
            set item (string trim $line | string replace -r '\s*=\s*' '=' | string split -m 1 '=')
            set -gx $item[1] $item[2]
            echo \"Exported key $item[1]\"
          end
        '';

        _git_clean_stale_lock = ''
          set -l git_dir (git rev-parse --git-dir 2>/dev/null)
          or return 0
          set -l lock "$git_dir/index.lock"
          if test -f "$lock"
            if not lsof "$lock" >/dev/null 2>&1
              rm -f "$lock"
              echo "Removed stale index.lock"
            end
          end
        '';

        # ── Git worktree management (see "How the workflow works" section) ──

        wt = ''
          set git_root (git rev-parse --show-toplevel 2>/dev/null)
          or begin; echo "wt: not a git repo"; return 1; end
          _git_clean_stale_lock
          set name $argv[1]
          set branch $argv[2]
          set main_root (git worktree list --porcelain | head -1 | string replace "worktree " "")
          set repo_name (basename $main_root)
          set wt_base (dirname $main_root)
          if test -z "$name"
            if not set -q TMUX; echo "wt: not in tmux"; return 1; end
            set wt_dir "$wt_base/$repo_name.worktrees"
            if not test -d "$wt_dir"; or test (count (command ls "$wt_dir" 2>/dev/null)) -eq 0
              echo "wt: no worktrees found"; return 1
            end
            set name (command ls "$wt_dir" | fzf --prompt="worktree> " --height=40%)
            or return 1
          end
          set wt_path "$wt_base/$repo_name.worktrees/$name"
          if set -q TMUX
            set parent_session (command tmux display-message -p '#{session_name}' | string split -m 1 '/')[1]
            set session_name "$parent_session/$name"
            if command tmux has-session -t "=$session_name" 2>/dev/null
              command tmux switch-client -t "=$session_name"; return 0
            end
          end
          set -l is_new 0
          if not test -d "$wt_path"
            git fetch origin 2>/dev/null
            set base_branch (git rev-parse --abbrev-ref HEAD 2>/dev/null)
            if test -z "$base_branch" -o "$base_branch" = "HEAD"
              echo "wt: detached HEAD — checkout a branch first"; return 1
            end
            if test -n "$branch"
              if git show-ref --verify --quiet "refs/heads/$branch"
                git worktree add "$wt_path" "$branch"
              else if git show-ref --verify --quiet "refs/remotes/origin/$branch"
                git worktree add --track -b "$branch" "$wt_path" "origin/$branch"
              else
                git worktree add -b "$branch" "$wt_path" "$base_branch"
              end
            else
              git worktree add -b "$name" "$wt_path" "$base_branch"
            end
            or begin; echo "wt: failed to create worktree"; return 1; end
            echo "Created worktree at $wt_path (from $base_branch)"
            direnv allow "$wt_path" 2>/dev/null
            set is_new 1
          end
          if set -q TMUX
            command tmux new-session -d -s "$session_name" -c "$wt_path"
            if test $is_new -eq 1
              set -l setup_file "$wt_base/$repo_name.worktrees/.setup"
              if test -f "$setup_file"
                command tmux send-keys -t "=$session_name" "sh '$setup_file'" Enter
              end
            end
            command tmux switch-client -t "=$session_name"
          else
            echo "Not in tmux — run: cd $wt_path && opencode"
          end
        '';

        lin = ''
          if test (count $argv) -eq 0
            linear issue view
          else
            linear issue $argv
          end
        '';

        wtl = ''
          _git_clean_stale_lock
          set issue_id $argv[1]
          if test -z "$issue_id"
            set -l selection (linear issue list --no-pager 2>/dev/null | fzf --ansi --prompt="issue> " --height=40%)
            or return 1
            set issue_id (string match -r '[A-Z]+-\d+' -- $selection)
          end
          set name $argv[2]
          if test -z "$name"
            read -P "worktree name> " name
            or return 1
            if test -z "$name"; echo "wtl: name required"; return 1; end
          end
          if not set -q TMUX; echo "wtl: requires tmux"; return 1; end
          set parent_session (command tmux display-message -p '#{session_name}' | string split -m 1 '/')[1]
          set session_name "$parent_session/$name"
          if command tmux has-session -t "=$session_name" 2>/dev/null
            command tmux switch-client -t "=$session_name"; return 0
          end
          set original_branch (git rev-parse --abbrev-ref HEAD)
          linear issue start $issue_id
          or return 1
          set branch_name (git rev-parse --abbrev-ref HEAD)
          git checkout $original_branch 2>/dev/null
          wt $name $branch_name
        '';

        wtls = "git worktree list $argv";

        wtrm = ''
          set git_root (git rev-parse --show-toplevel 2>/dev/null)
          or begin; echo "wtrm: not a git repo"; return 1; end
          set -l force 0
          set -l args
          for arg in $argv
            if test "$arg" = "--force" -o "$arg" = "-f"; set force 1
            else; set -a args $arg; end
          end
          set name $args[1]
          set main_root (git worktree list --porcelain | head -1 | string replace "worktree " "")
          set repo_name (basename $main_root)
          set wt_base (dirname $main_root)
          set -l auto_name 0
          if test -z "$name"
            if set -q TMUX
              set -l current (command tmux display-message -p '#{session_name}')
              if string match -q '*/*' -- "$current"
                set name (string split -m 1 '/' -- "$current")[2]
                set auto_name 1
              end
            end
          end
          if test -z "$name"
            set wt_dir "$wt_base/$repo_name.worktrees"
            if not test -d "$wt_dir"; or test (count (command ls "$wt_dir" 2>/dev/null)) -eq 0
              echo "wtrm: no worktrees found"; return 1
            end
            set name (command ls "$wt_dir" | fzf --prompt="remove> " --height=40%)
            or return 1
          end
          set wt_path "$wt_base/$repo_name.worktrees/$name"
          if not test -d "$wt_path"; echo "wtrm: no worktree found for '$name'"; return 1; end
          if test $auto_name -eq 1
            read -P "wtrm: remove worktree '$name'? [y/N] " confirm
            if not string match -qi 'y' -- "$confirm"; return 0; end
          end
          set -l self_rm 0; set -l current_session ""; set -l parent_session ""
          if set -q TMUX
            set current_session (command tmux display-message -p '#{session_name}')
            set -l target_session (command tmux list-sessions -F '#{session_name}' | grep "/$name\$" | head -1)
            if test -n "$target_session" -a "$current_session" = "$target_session"
              set self_rm 1
              set parent_session (string split -m 1 '/' -- "$current_session")[1]
            end
          end
          if test $self_rm -eq 1
            if not command tmux has-session -t "=$parent_session" 2>/dev/null
              set parent_session (command tmux list-sessions -F '#{session_name}' | grep -v "^$current_session\$" | head -1)
              if test -z "$parent_session"; echo "wtrm: no other session to switch to"; return 1; end
            end
            set -l branch (git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null)
            if test $force -eq 0
              if test -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)"
                echo "wtrm: worktree has uncommitted changes — use 'wtrm --force' to force"; return 1
              end
            end
            set -l cleanup "tmux kill-session -t '=$current_session'"
            if test $force -eq 1
              set cleanup "$cleanup; git -C '$main_root' worktree remove --force '$wt_path'"
            else
              set cleanup "$cleanup; git -C '$main_root' worktree remove '$wt_path'"
            end
            if test -n "$branch" -a "$branch" != "HEAD"
              if test $force -eq 1; set cleanup "$cleanup; git -C '$main_root' branch -D '$branch' 2>/dev/null"
              else; set cleanup "$cleanup; git -C '$main_root' branch -d '$branch' 2>/dev/null"; end
            end
            command tmux switch-client -t "=$parent_session"
            command tmux run-shell -b "$cleanup"
          else
            if set -q TMUX
              set -l target_session (command tmux list-sessions -F '#{session_name}' | grep "/$name\$" | head -1)
              if test -n "$target_session"; command tmux kill-session -t "=$target_session"; end
            end
            set -l branch (git -C "$wt_path" rev-parse --abbrev-ref HEAD 2>/dev/null)
            if test $force -eq 1; git worktree remove --force "$wt_path"
            else; git worktree remove "$wt_path"; end
            or begin; echo "wtrm: worktree has changes — use 'wtrm --force $name' to force"; return 1; end
            if test -n "$branch" -a "$branch" != "HEAD"
              if test $force -eq 1; git branch -D "$branch" 2>/dev/null
              else; git branch -d "$branch" 2>/dev/null; end
              and echo "Removed worktree '$name' and branch '$branch'"
              or echo "Removed worktree '$name' (branch '$branch' not fully merged)"
            else; echo "Removed worktree '$name'"; end
          end
        '';
      };

      shellInit = ''
        complete -f -c gco -a '(git branch --all | string replace -r "^[\*\s]+" "" | string replace -r "^remotes/" "")'
        complete -f -c wt -n "test (count (commandline -opc)) -eq 1" -a '(
          set -l mr (git worktree list --porcelain 2>/dev/null | head -1 | string replace "worktree " "")
          set -l rn (basename $mr 2>/dev/null)
          set -l wd (dirname $mr 2>/dev/null)/$rn.worktrees
          test -d $wd 2>/dev/null; and command ls $wd 2>/dev/null
        )'
        complete -f -c wt -n "test (count (commandline -opc)) -eq 2" -a '(git branch -a --format="%(refname:short)" 2>/dev/null | string replace -r "^origin/" "" | sort -u | grep -v "^HEAD")'
        complete -f -c lin -n "test (count (commandline -opc)) -eq 1" -a "view list start create pr"
        complete -f -c wtrm -l force -s f -d "Force remove even with uncommitted changes"
        complete -f -c wtrm -a '(
          set -l mr (git worktree list --porcelain 2>/dev/null | head -1 | string replace "worktree " "")
          set -l rn (basename $mr 2>/dev/null)
          set -l wd (dirname $mr 2>/dev/null)/$rn.worktrees
          test -d $wd 2>/dev/null; and command ls $wd 2>/dev/null
        )'
      '';
    };
  };
}

Small CLI modules (create each file)

# modules/cli/starship.nix — prompt with worktree awareness
{ username, ... }:
{
  home-manager.users.${username} = {
    programs.starship = {
      enable = true;
      settings = {
        format = "\${custom.worktree}$all";
        character = {
          success_symbol = "[λ](bold green)";
          error_symbol = "[λ](bold red)";
          vicmd_symbol = "[λ](bold blue)";
        };
        nix_shell = { format = "via [$symbol]($style) "; symbol = " "; };
        custom.worktree = {
          command = "basename (dirname (git rev-parse --show-toplevel)) | string replace '.worktrees' ''";
          when = "string match -q '*.worktrees*' (git rev-parse --show-toplevel 2>/dev/null)";
          format = "[$output/]()";
          shell = [ "fish" ];
        };
        aws.disabled = true;
        gcloud.disabled = true;
      };
    };
  };
}
# modules/cli/direnv.nix
{ username, ... }:
{ home-manager.users.${username} = { programs.direnv = { enable = true; nix-direnv.enable = true; }; }; }
# modules/cli/bat.nix
{ username, ... }:
{ home-manager.users.${username} = { programs.bat = { enable = true; }; }; }
# modules/cli/eza.nix
{ username, ... }:
{ home-manager.users.${username} = { programs.eza = { enable = true; }; }; }
# modules/cli/fzf.nix
{ username, pkgs, ... }:
{ home-manager.users.${username} = { home.packages = with pkgs; [ fzf ]; }; }
# modules/cli/ripgrep.nix
{ username, pkgs, ... }:
{ home-manager.users.${username} = { home.packages = with pkgs; [ ripgrep ]; }; }
# modules/cli/zoxide.nix — smart cd that learns your directories
{ username, pkgs, ... }:
{
  home-manager.users.${username} = {
    programs.zoxide = {
      enable = true;
      enableFishIntegration = true;
      options = [ "--cmd=cd" ]; # replaces cd with zoxide
    };
  };
}

tmux

# modules/cli/tmux.nix
{ username, pkgs, ... }:
let inherit (pkgs) tmux-extras;
in
{
  home-manager.users.${username} = {
    xdg.configFile."tmux/tmux-nerd-font-window-name.yml".text = ''
      config:
        fallback-icon: "?"
        show-name: false
        always-show-fallback-name: true
      icons:
        .opencode-wrapp: ""
        task: "󱓞"
    '';

    programs.tmux = {
      enable = true;
      shell = "${pkgs.fish}/bin/fish";
      prefix = "C-space";
      terminal = "screen-256color";
      keyMode = "vi";
      mouse = true;
      baseIndex = 1;
      historyLimit = 50000;
      sensibleOnTop = false;
      extraConfig = ''
        set -g renumber-windows on
        set -g escape-time 1
        set -g display-time 4000
        set -g status-interval 5
        set -g focus-events on
        setw -g aggressive-resize on
        set -ga terminal-overrides ",*-256color*:Tc"
        set -g extended-keys on
        set -g allow-passthrough on

        bind-key R source-file ~/.config/tmux/tmux.conf \; display-message "Config reloaded"
        set -g status-right "#(${tmux-extras}/bin/tmux-status-right #{pane_current_path})"
        set -g status-right-length 200

        bind-key '?' display-popup -w 64 -h 80% -E "${tmux-extras}/bin/tmux-cheatsheet"
        bind-key 'n' display-popup -w 60 -h 20 -E "${tmux-extras}/bin/tmux-notify-panel"
        bind-key 'N' run-shell "${tmux-extras}/bin/tmux-notify goto"

        # Open opencode / lazygit in a pane at nearest git root
        bind-key o run-shell 'tmux split-window -h -c "$(${tmux-extras}/bin/tmux-git-root-path "#{pane_current_path}")" opencode'
        bind-key l run-shell 'tmux split-window -h -c "$(${tmux-extras}/bin/tmux-git-root-path "#{pane_current_path}")" lazygit'

        bind-key c run-shell 'tmux new-window -c "$(${tmux-extras}/bin/tmux-git-root-path "#{pane_current_path}")"'
        bind-key '"' split-window -c "#{pane_current_path}"
        bind-key % split-window -h -c "#{pane_current_path}"
        bind-key s choose-tree -sZO name
        bind-key 'g' run-shell "${tmux-extras}/bin/tmux-group"
        bind-key 'G' run-shell "${tmux-extras}/bin/tmux-ungroup"
        bind-key 'C-R' run-shell "${tmux-extras}/bin/tmux-remote toggle"
      '';

      plugins = with pkgs; [
        { plugin = flexoki-tmux; }
        tmuxPlugins.better-mouse-mode
        { plugin = tmux-nerd-font-window-name; }
        {
          plugin = tmuxPlugins.resurrect;
          extraConfig = ''
            set -g @resurrect-capture-pane-contents 'on'
            set -g @resurrect-strategy-nvim 'session'
            set -g @resurrect-restore-cwd 'on'
          '';
        }
        {
          plugin = tmuxPlugins.continuum;
          extraConfig = ''
            set -g @continuum-restore 'on'
            set -g @continuum-save-interval '10'
          '';
        }
      ];
    };
  };
}

8. Dev tool modules

Git

# modules/dev/git.nix
{ username, pkgs, ... }:
{
  home-manager.users.${username} = {
    programs.git = {
      enable = true;
      lfs.enable = true;
      package = pkgs.git;
      settings = {
        user = {
          email = "you@example.com";           # CHANGE THIS
          name = "Your Name";                  # CHANGE THIS
          signingkey = "ssh-ed25519 AAAA...";  # CHANGE THIS: your SSH public key
        };
        gpg.format = "ssh";
        "gpg \"ssh\"" = {
          program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign";
        };
        commit.gpgsign = true;
        init.defaultBranch = "main";
        pull.rebase = false;
        push.autoSetupRemote = true;
        core.ignorecase = false;
      };
    };
    # If you don't use 1Password for signing, remove the gpg, "gpg \"ssh\"", and commit.gpgsign lines.

    home.packages = with pkgs; [ gh ];
    programs.gh = { enable = true; settings.git_protocol = "ssh"; };
  };
}

OpenCode

# modules/dev/opencode.nix
{ username, pkgs, ... }:
let
  jsonFormat = pkgs.formats.json { };
  inherit (pkgs) tmux-extras;
in
{
  home-manager.users.${username} = {
    programs.opencode = {
      enable = true;
      settings = {
        theme = "flexoki";
        plugin = [
          "@simonwjackson/opencode-direnv"
          "@mohak34/opencode-notifier@latest"
          "@kdcokenny/opencode-worktree@latest"
          "oh-my-opencode@latest"
        ];
        command = {
          lin = {
            template = "Here is the Linear issue for this branch:\n\n!`linear issue view $ARGUMENTS`\n\nSummarize the issue and ask what I want to work on.";
            description = "Load Linear issue context";
          };
        };
        permission = { external_directory = "allow"; };
      };
    };

    xdg.configFile."opencode/oh-my-opencode.json".source = jsonFormat.generate "oh-my-opencode.json" {
      "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
      agents = {
        build           = { model = "anthropic/claude-opus-4-6"; };
        sisyphus        = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        oracle          = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        explore         = { model = "anthropic/claude-haiku-4-5"; };
        multimodal-looker = { model = "opencode/glm-4.7-free"; };
        prometheus      = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        metis           = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        momus           = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        atlas           = { model = "anthropic/claude-sonnet-4-5"; };
        sisyphus-junior = { model = "anthropic/claude-sonnet-4-6"; };
      };
      categories = {
        visual-engineering = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        ultrabrain         = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        quick              = { model = "anthropic/claude-haiku-4-5"; };
        unspecified-low    = { model = "anthropic/claude-sonnet-4-5"; };
        unspecified-high   = { model = "anthropic/claude-opus-4-6"; variant = "max"; };
        writing            = { model = "anthropic/claude-sonnet-4-5"; };
      };
    };

    xdg.configFile."opencode/opencode-notifier.json".source =
      jsonFormat.generate "opencode-notifier.json" {
        sound = true;
        notification = false;
        suppressWhenFocused = false;
        command = {
          enabled = true;
          path = "${tmux-extras}/bin/tmux-notify";
          args = [ "add" "--event" "{event}" "{message}" ];
          minDuration = 0;
        };
      };
  };
}

Lazygit

# modules/dev/lazygit.nix
{ username, pkgs, ... }:
{
  home-manager.users.${username} = {
    programs.lazygit = {
      enable = true;
      settings = {
        customCommands = [
          { key = "<c-a>"; context = "files";
            command = ''opencode run "Look at the staged changes and create a commit following conventional commit conventions. Just commit directly."'';
            output = "terminal"; description = "Generate commit with OpenCode"; }
          { key = "O"; context = "localBranches";
            command = "git push && gh pr create --web";
            description = "Create PR (push + open)"; output = "log"; loadingText = "Creating PR..."; }
          { key = "<c-o>"; context = "localBranches";
            command = ''fish -c "ghpc"''; description = "Create PR (push + fill + open)"; output = "log"; }
          { key = "<c-x>"; context = "localBranches";
            command = ''fish -c "ghpm"''; description = "Merge PR (squash)"; output = "log"; }
          { key = "<c-p>"; context = "localBranches";
            command = ''fish -c "ghpcm"''; description = "Create + Merge PR"; output = "terminal"; }
        ];
        gui = {
          nerdFontsVersion = "3";
          theme = {
            activeBorderColor = [ "#205EA6" "bold" ]; inactiveBorderColor = [ "#CECDC3" ];
            optionsTextColor = [ "#205EA6" ]; selectedLineBgColor = [ "#E6E4D9" ];
            selectedRangeBgColor = [ "#DAD8CE" ]; cherryPickedCommitBgColor = [ "#24837B" ];
            cherryPickedCommitFgColor = [ "#FFFCF0" ]; unstagedChangesColor = [ "#AF3029" ];
            defaultFgColor = [ "#100F0F" ];
          };
        };
      };
    };
  };
}
# modules/dev/claude-code.nix
{ username, pkgs, ... }:
{ home-manager.users.${username} = { home.packages = with pkgs; [ claude-code ]; }; }

9. Editor module (Neovim)

The Neovim config is 1000+ lines — too large to inline here. See the nvim.nix file in this gist. Copy it to modules/editor/nvim.nix.

What it includes:

  • LSP for 16 languages (Lua, Nix, TypeScript, Python, Go, Rust, Dart, Elixir, Tailwind, HTML, CSS, JSON, Bash, YAML, TOML, Markdown) — all installed via Nix, no Mason needed
  • Formatters (stylua, nixfmt, prettierd, black, gofumpt, rustfmt)
  • Plugins: Telescope, which-key, Treesitter, nvim-cmp, gitsigns, neogit, diffview, oil, supermaven, opencode.nvim
  • Flexoki Light theme
  • VS Code-like keybindings (Cmd+P, Cmd+Shift+F, Alt+j/k)
  • Built-in cheatsheet popup (Space+?)

OpenCode keybindings in Neovim:

Key Action
Ctrl+. Toggle OpenCode TUI
Ctrl+a Ask OpenCode about selection/cursor
Ctrl+x OpenCode actions menu
go / goo Send range/line to OpenCode
Space oa Toggle OpenCode

10. Terminal module (Ghostty)

# modules/terminal/ghostty.nix
{ username, pkgs, ... }:
let inherit (pkgs) tmux-extras;
in
{
  home-manager.users.${username} = {
    programs.ghostty = {
      enable = true;
      enableFishIntegration = true;
      package = pkgs.ghostty-bin;
      settings = {
        command = "${tmux-extras}/bin/tmux-attach"; # Auto-launch tmux on open
        theme = "Flexoki Light";
        font-family = "CaskaydiaCove Nerd Font";
        font-family-bold = "CaskaydiaCove Nerd Font";
        font-family-italic = "auto";
        font-family-bold-italic = "auto";
        font-size = 12;
        cursor-style = "bar";
        cursor-style-blink = true;
        adjust-cursor-thickness = 2;
        macos-option-as-alt = true;
        macos-titlebar-style = "transparent";
        window-theme = "auto";
        gtk-titlebar = false;
        window-padding-x = 8;
        window-padding-y = 8;
        keybind = [
          "super+p=text:\\x1b[80;6u"        # Cmd+P → Telescope find_files
          "super+shift+f=text:\\x1b[70;6u"   # Cmd+Shift+F → Telescope live_grep
        ];
      };
    };
  };
}

11. Custom overlay packages (tmux scripts)

The overlay has three custom packages. The shell scripts and Nix derivations for all three are included as separate files in this gist. Copy them to the paths shown below.

What to copy where

Flexoki tmux themeoverlay/pkgs/flexoki-tmux/:

  • flexoki-tmux.nix (from this gist)
  • flexoki-bar.conf (from this gist)
  • flexoki.tmux (from this gist)

tmux-extrasoverlay/pkgs/tmux-extras/:

  • tmux-extras.nix (from this gist)
  • All .sh files from this gist (tmux-attach.sh, git-status.sh, cheatsheet.sh, etc.)

tmux-nerd-font-window-nameoverlay/pkgs/tmux-nerd-font-window-name/:

  • tmux-nerd-font-window-name.nix (from this gist)

What each script does

Script Purpose
tmux-attach Attach to most recent session or create main
tmux-git-status Git branch + changes/insertions/deletions in status bar
tmux-git-root-path Find nearest git root for window/pane commands
tmux-path-widget Current path display in status bar
tmux-status-right Compose all widgets into the right side of the status bar
tmux-cheatsheet Scrollable keybinding reference popup
tmux-notify Notification state manager (add/dismiss/list/goto)
tmux-notify-panel Interactive floating notification panel
tmux-notify-widget Notification count badge (bell icon) in status bar
tmux-group / tmux-ungroup Multi-monitor grouped sessions
tmux-remote / tmux-remote-widget Remote access mode (SSH + battery saving)
tmux-battery-widget Battery level in status bar (remote mode)

Notification flow

OpenCode finishes a task
  → opencode-notifier plugin fires
    → tmux-notify add --event complete "Task done: ..."
      → stored in /tmp/tmux-notifications.json
        → tmux status bar shows 󰂞 1
          → prefix+n opens panel → Enter jumps to the source window

12. First build!

Make sure you've created every file above, then:

cd ~/nix-config

# Commit everything (nix flakes require a git repo)
git add -A
git commit -m "initial nix-darwin config"

# First build — this takes a while (downloads everything)
sudo nix run nix-darwin \
  --extra-experimental-features nix-command \
  --extra-experimental-features flakes \
  -- switch --flake .#mymac

# All subsequent rebuilds are just:
sudo darwin-rebuild switch --flake .#mymac

Open Ghostty. It auto-launches tmux. You're in.


13. Set up API keys

# OpenCode needs an Anthropic API key
export ANTHROPIC_API_KEY="sk-ant-..."
# Add to your shell config permanently:
# In fish: set -Ux ANTHROPIC_API_KEY "sk-ant-..."

# First run of OpenCode (inside tmux):
opencode
# It will prompt for the API key if not set

# GitHub CLI login (for ghpc, ghpm, PR commands):
gh auth login

14. How the workflow works

Git worktrees + tmux sessions

Git worktrees let you have multiple branches checked out in separate directories. Combined with tmux, each worktree gets its own session.

~/projects/
  my-app/                          # Main checkout (main branch)
  my-app.worktrees/
    feature-auth/                  # Worktree: feature-auth branch
    fix-bug/                       # Worktree: fix-bug branch
    .setup                         # Optional: runs on new worktree creation
Command What it does
wt FZF picker to switch to an existing worktree session
wt feature Create worktree + tmux session, or switch if exists
wt fix existing-branch Create worktree from existing branch
wtls List all worktrees
wtrm Remove worktree + session + branch (FZF or auto-detect)
wtrm --force name Force remove with uncommitted changes

Typical flow:

  1. wt feature-auth → creates worktree + switches to tmux session my-app/feature-auth
  2. Code, commit, ghpc → pushes + creates PR + opens browser
  3. ghpm → merges PR + pulls main + cleans up worktree

Linear integration

Install the Linear CLI (brew tap schpet/tap && brew install linear), then:

Command What it does
lin View current branch's Linear issue
wtl FZF pick issue → create worktree (marks In Progress)
wtl ENG-123 my-feat Create worktree from specific issue
/lin in OpenCode Load Linear issue context into AI conversation

The .setup file

Create my-app.worktrees/.setup to auto-run on new worktrees:

#!/bin/bash
npm install
cp .env.example .env

15. Keybinding quick reference

tmux (prefix = Ctrl+Space)

Key Action
prefix + o Open OpenCode pane (at git root)
prefix + l Open Lazygit pane (at git root)
prefix + ? Cheatsheet popup
prefix + n Notification panel
prefix + N Jump to last notification
prefix + s Session picker
prefix + c New window (at git root)
prefix + " Split horizontal
prefix + % Split vertical
prefix + g Grouped session (multi-monitor)
prefix + G Leave grouped session

Neovim (leader = Space)

Key Action
Ctrl+. Toggle OpenCode TUI
Ctrl+a Ask OpenCode about selection
Ctrl+x OpenCode actions menu
go / goo Send range/line to OpenCode
Cmd+P Find file (Telescope)
Cmd+Shift+F Search in project
Space ? Cheatsheet
Space ff Find file
Space g Live grep
Space Gg Open Neogit (source control)
Space Gd Diff view

Lazygit

Key Action
Ctrl+a Generate commit with OpenCode
O Push + create PR in browser
Ctrl+o Push + fill PR + open
Ctrl+x Merge PR (squash)
Ctrl+p Create + merge PR

Fish shell

Command Action
g ga gaa gb gc gp Git shortcuts
gco branch Checkout + auto-pull
ghpc Push + create PR + open browser
ghpm Merge PR + pull main + cleanup
ghpcm Push + create + merge
wt / wt name Switch/create worktree
wtrm Remove worktree + session + branch
wtl Create worktree from Linear issue
lin View Linear issue
ds Enter nix dev shell
pj path cd + enter dev shell
envsource .env Load .env into shell

16. Adding new apps

GUI app (Homebrew cask):

# modules/chat/slack.nix
_: { homebrew.casks = [ "slack" ]; }

CLI tool (Nix package):

# modules/dev/jq.nix
{ username, pkgs, ... }:
{ home-manager.users.${username} = { home.packages = with pkgs; [ jq ]; }; }

Then add the import to your user file and rebuild.


17. Day-to-day commands

# Rebuild after changing any .nix file
sudo darwin-rebuild switch --flake .#mymac

# Update all inputs (nixpkgs, home-manager, etc.)
nix flake update
sudo darwin-rebuild switch --flake .#mymac

# Enter a project's dev shell
cd ~/projects/my-app
ds

# Format / lint nix files
nixfmt modules/cli/fish.nix
statix check .
#!/usr/bin/env bash
# tmux-battery-widget: Status bar widget showing battery percentage and state.
# Only shown when remote access mode is active (to keep status bar clean normally).
# Uses pmset to read battery info on macOS.
STATE_FILE="/tmp/tmux-remote-state"
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
GREEN="#879a39"
YELLOW="#d0a215"
RED="#d14d41"
ORANGE="#da702c"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
# Only show battery when remote mode is active
if [[ ! -f "$STATE_FILE" ]]; then
exit 0
fi
# Read battery info from pmset
BATTERY_INFO=$(pmset -g batt 2>/dev/null)
if [[ -z "$BATTERY_INFO" ]]; then
exit 0
fi
# Extract percentage (e.g., "85%")
PERCENT=$(echo "$BATTERY_INFO" | grep -oE '[0-9]+%' | head -1 | tr -d '%')
if [[ -z "$PERCENT" ]]; then
exit 0
fi
# Determine charging state
CHARGING=""
if echo "$BATTERY_INFO" | grep -q "AC Power"; then
CHARGING="󰂄"
elif echo "$BATTERY_INFO" | grep -q "charged"; then
CHARGING="󰂄"
fi
# Pick icon and color based on percentage
if [[ "$PERCENT" -ge 80 ]]; then
ICON="󰁹"
COLOR="$GREEN"
elif [[ "$PERCENT" -ge 60 ]]; then
ICON="󰂀"
COLOR="$GREEN"
elif [[ "$PERCENT" -ge 40 ]]; then
ICON="󰁾"
COLOR="$YELLOW"
elif [[ "$PERCENT" -ge 20 ]]; then
ICON="󰁻"
COLOR="$ORANGE"
else
ICON="󰁺"
COLOR="$RED"
fi
# Use charging icon if plugged in
if [[ -n "$CHARGING" ]]; then
ICON="$CHARGING"
fi
echo "#[fg=${COLOR},bg=${BG},bold] ${ICON} ${PERCENT}%${RESET} "
#!/usr/bin/env bash
# Flexoki light theme colors via ANSI escapes
# Using tput/ANSI for terminal coloring inside the popup
BOLD=$(tput bold)
DIM=$(tput dim)
RESET=$(tput sgr0)
CYAN=$(tput setaf 6)
YELLOW=$(tput setaf 3)
MAGENTA=$(tput setaf 5)
header() {
echo "${BOLD}${CYAN}$1${RESET}"
}
key() {
printf " ${YELLOW}%-20s${RESET} %s\n" "$1" "$2"
}
sep() {
echo ""
}
SEPARATOR="${DIM}$(printf '%0.s─' $(seq 1 58))${RESET}"
# Build content into a temp file (avoids mapfile/bash-ism issues)
CONTENT=$(mktemp)
trap 'rm -f "$CONTENT"' EXIT
{
echo "${BOLD}${MAGENTA} tmux cheatsheet${RESET} ${DIM}prefix = Ctrl+Space${RESET}"
echo "$SEPARATOR"
sep
header "Sessions"
key "prefix s" "list sessions"
key "prefix d" "detach"
key "prefix \$" "rename session"
key ":new -s name" "new session"
key ":kill-session -t X" "delete session X"
key "x (in prefix s)" "delete hovered session"
sep
header "Windows"
key "prefix c" "new window"
key "prefix ," "rename window"
key "prefix n / p" "next / previous window"
key "prefix 1-9" "go to window #"
key "prefix &" "close window"
key "prefix w" "window preview"
sep
header "Panes"
key "prefix %" "split vertical"
key "prefix \"" "split horizontal"
key "prefix o" "open opencode pane"
key "prefix arrow" "move between panes"
key "prefix z" "toggle zoom"
key "prefix x" "close pane"
key "prefix !" "pane → window"
key "prefix { / }" "swap pane left / right"
key "prefix space" "cycle layouts"
sep
header "Copy Mode (vi)"
key "prefix [" "enter copy mode"
key "v" "begin selection"
key "y" "yank selection"
key "prefix ]" "paste"
key "/" "search forward"
key "?" "search backward"
sep
header "Multi-Monitor"
key "prefix g" "group session (new view)"
key "prefix G" "leave grouped session"
sep
header "Remote Access"
key "prefix Ctrl+R" "toggle remote mode"
sep
header "Misc"
key "prefix ?" "this cheatsheet"
key "prefix n" "notification panel"
key "prefix N" "jump to last notification"
key "prefix :" "command prompt"
key "prefix t" "show clock"
key "prefix ~" "show messages"
} > "$CONTENT"
TOTAL=$(wc -l < "$CONTENT")
# Scrollable viewer
TOP=1
draw() {
local height visible
height=$(tput lines)
visible=$((height - 1))
clear
sed -n "${TOP},$((TOP + visible - 1))p" "$CONTENT"
# Footer
tput cup $((height - 1)) 0
printf '%s j/k scroll esc/q close%s' "${DIM}" "${RESET}"
}
draw
while true; do
read -rsn1 key
# Handle escape sequences (esc key sends \e, arrow keys send \e[A etc.)
if [[ "$key" == $'\e' ]]; then
# Read any remaining bytes of escape sequence (non-blocking)
read -rsn2 -t 0.01 extra || true
# If no extra bytes, it was a bare Esc press
if [[ -z "$extra" ]]; then
exit 0
fi
# Otherwise ignore the escape sequence (arrow keys etc.)
continue
fi
case "$key" in
j)
height=$(tput lines)
visible=$((height - 1))
if ((TOP + visible <= TOTAL)); then
TOP=$((TOP + 1))
draw
fi
;;
k)
if ((TOP > 1)); then
TOP=$((TOP - 1))
draw
fi
;;
q) exit 0 ;;
esac
done
# status
set -g status "on"
set -g status-bg $color_bg_2
set -g status-justify "left"
set -g status-left-length "100"
set -g status-right-length "100"
# messages
set -g message-style fg=$color_cyan,bg=$color_tx_1,align="centre"
set -g message-command-style fg=$color_cyan,bg=$color_ui_3,align="centre"
# panes
set -g pane-border-style fg=$color_ui_3
set -g pane-active-border-style fg=$color_blue
# windows
setw -g window-status-activity-style fg=$color_tx_1,bg=$color_bg_1,none
setw -g window-status-separator ""
setw -g window-status-style fg=$color_tx_1,bg=$color_bg_1,none
# statusline
set -g status-left "#{?client_prefix,#[fg=#$color_bg_2#,bg=#$color_orange],#[fg=#$color_orange#,bg=#$color_bg_2]} λ #S "
# status-right is set in tmux.nix extraConfig (after all plugins load)
# window-status
setw -g window-status-format "#[bg=#$color_bg_2,fg=#$color_tx_3] #I #W "
setw -g window-status-current-format "#[bg=#$color_bg_1,fg=#$color_tx_1] #I #W "
# Modes
setw -g clock-mode-colour $color_blue
setw -g mode-style fg=$color_magenta,bg=$color_tx_1,bold
{ pkgs }:
pkgs.tmuxPlugins.mkTmuxPlugin {
pluginName = "flexoki-tmux";
version = "2.0.0";
rtpFilePath = "flexoki.tmux";
preInstall = ''
cd tmux
cp ${./flexoki-bar.conf} flexoki-bar.conf
cp ${./flexoki.tmux} flexoki.tmux
chmod +x flexoki.tmux
'';
src = pkgs.fetchFromGitHub {
owner = "kepano";
repo = "flexoki";
rev = "8d723bac4a9ac46adfdf99d42155286977aac72a";
sha256 = "sha256-IxnvoZ9hGEvwq/PBbHTL5L2a2kxMSXSINIfd5Dg9ttA=";
};
}
#!/usr/bin/env bash
CURRENT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
get_tmux_option() {
local option="$1"
local default_value="$2"
local option_value
option_value="$(tmux show-option -gqv "$option")"
if [[ -z "$option_value" ]]; then
echo "$default_value"
else
echo "$option_value"
fi
}
main() {
local theme
theme="$(get_tmux_option "@flexoki-theme" "")"
if [[ -z "$theme" ]]; then
theme="light"
fi
tmux source-file "$CURRENT_DIR/flexoki-${theme}.tmuxtheme"
tmux source-file "$CURRENT_DIR/flexoki-bar.conf"
}
main
#!/usr/bin/env bash
# Print the nearest git repo root for a given directory.
# Falls back to the directory itself if not inside a git repo.
dir="${1:-.}"
cd "$dir" && git rev-parse --show-toplevel 2>/dev/null || echo "$dir"
#!/usr/bin/env bash
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
FG_MUTED="#6f6e69"
RED="#d14d41"
GREEN="#879a39"
YELLOW="#d0a215"
MAGENTA="#8b7ec8"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
cd "$1" || exit 1
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
[[ -z "$BRANCH" ]] && exit 0
STATUS=$(git status --porcelain 2>/dev/null | grep -cE "^(M| M)")
SYNC_MODE=0
if [[ ${#BRANCH} -gt 25 ]]; then
BRANCH="${BRANCH:0:25}…"
fi
STATUS_CHANGED=""
STATUS_INSERTIONS=""
STATUS_DELETIONS=""
STATUS_UNTRACKED=""
if [[ $STATUS -ne 0 ]]; then
read -r CHANGED_COUNT INSERTIONS_COUNT DELETIONS_COUNT < <(
git diff --numstat 2>/dev/null | awk 'NF==3 {changed+=1; ins+=$1; del+=$2} END {printf("%d %d %d", changed, ins, del)}'
)
SYNC_MODE=1
fi
UNTRACKED_COUNT="$(git ls-files --other --directory --exclude-standard 2>/dev/null | wc -l | tr -d ' ')"
if [[ ${CHANGED_COUNT:-0} -gt 0 ]]; then
STATUS_CHANGED="${RESET}#[fg=${YELLOW},bg=${BG},bold] ${CHANGED_COUNT} "
fi
if [[ ${INSERTIONS_COUNT:-0} -gt 0 ]]; then
STATUS_INSERTIONS="${RESET}#[fg=${GREEN},bg=${BG},bold] ${INSERTIONS_COUNT} "
fi
if [[ ${DELETIONS_COUNT:-0} -gt 0 ]]; then
STATUS_DELETIONS="${RESET}#[fg=${RED},bg=${BG},bold] ${DELETIONS_COUNT} "
fi
if [[ ${UNTRACKED_COUNT:-0} -gt 0 ]]; then
STATUS_UNTRACKED="${RESET}#[fg=${FG_MUTED},bg=${BG},bold] ${UNTRACKED_COUNT} "
fi
# Determine repository sync status
if [[ $SYNC_MODE -eq 0 ]]; then
# shellcheck disable=SC1083
NEED_PUSH=$(git log @{push}.. 2>/dev/null | wc -l | tr -d ' ')
if [[ ${NEED_PUSH:-0} -gt 0 ]]; then
SYNC_MODE=2
elif [[ -f .git/FETCH_HEAD ]]; then
LAST_FETCH=$(stat -c %Y .git/FETCH_HEAD 2>/dev/null || stat -f %m .git/FETCH_HEAD 2>/dev/null || echo 0)
NOW=$(date +%s)
if [[ $((NOW - LAST_FETCH)) -gt 300 ]]; then
git fetch --atomic origin --negotiation-tip=HEAD 2>/dev/null
fi
REMOTE_DIFF="$(git diff --numstat "${BRANCH}" "origin/${BRANCH}" 2>/dev/null)"
if [[ -n $REMOTE_DIFF ]]; then
SYNC_MODE=3
fi
fi
fi
# Set the status indicator based on the sync mode
case "$SYNC_MODE" in
1)
REMOTE_STATUS="$RESET#[bg=${BG},fg=${RED},bold] 󱓎"
;;
2)
REMOTE_STATUS="$RESET#[bg=${BG},fg=${RED},bold] 󰛃"
;;
3)
REMOTE_STATUS="$RESET#[bg=${BG},fg=${MAGENTA},bold] 󰛀"
;;
*)
REMOTE_STATUS="$RESET#[bg=${BG},fg=${GREEN},bold] "
;;
esac
echo "$REMOTE_STATUS $RESET$BRANCH $STATUS_CHANGED$STATUS_INSERTIONS$STATUS_DELETIONS$STATUS_UNTRACKED"
#!/usr/bin/env bash
# tmux-notify-panel: Floating popup that displays notifications
# Renders a scrollable list with keybindings to dismiss or jump to source.
#
# Keybindings:
# j/k or arrows Navigate up/down
# Enter Jump to the tmux window that triggered the notification
# d Dismiss selected notification
# D Dismiss all notifications
# q/Escape Close panel
set -eu
NOTIFY_FILE="${TMUX_NOTIFY_FILE:-/tmp/tmux-notifications.json}"
# Terminal styling
BOLD=$(tput bold)
DIM=$(tput dim)
RESET=$(tput sgr0)
CYAN=$(tput setaf 6)
YELLOW=$(tput setaf 3)
MAGENTA=$(tput setaf 5)
REVERSE=$(tput rev)
# State
SELECTED=0
SCROLL_OFFSET=0
get_notifications() {
if [[ ! -f "$NOTIFY_FILE" ]]; then
echo '[]'
return
fi
# Return newest first
jq 'sort_by(.timestamp) | reverse' "$NOTIFY_FILE" 2>/dev/null || echo '[]'
}
render() {
local notifications="$1"
local count
count=$(echo "$notifications" | jq 'length')
local term_height
term_height=$(tput lines)
local max_items=$((term_height - 5)) # Reserve lines for header/footer
clear
# Header
echo "${BOLD}${MAGENTA} Notifications${RESET} ${DIM}(${count})${RESET}"
echo "${DIM}$(printf '%.0s─' $(seq 1 56))${RESET}"
if [[ "$count" -eq 0 ]]; then
echo ""
echo " ${DIM}No notifications${RESET}"
echo ""
echo "${DIM}$(printf '%.0s─' $(seq 1 56))${RESET}"
echo " ${DIM}press q to close${RESET}"
return
fi
# Ensure selected is within bounds
if [[ $SELECTED -ge $count ]]; then
SELECTED=$((count - 1))
fi
if [[ $SELECTED -lt 0 ]]; then
SELECTED=0
fi
# Adjust scroll offset to keep selected visible
if [[ $SELECTED -lt $SCROLL_OFFSET ]]; then
SCROLL_OFFSET=$SELECTED
fi
if [[ $SELECTED -ge $((SCROLL_OFFSET + max_items)) ]]; then
SCROLL_OFFSET=$((SELECTED - max_items + 1))
fi
local i
for ((i = SCROLL_OFFSET; i < count && i < SCROLL_OFFSET + max_items; i++)); do
local entry
entry=$(echo "$notifications" | jq -r ".[$i]")
local session
session=$(echo "$entry" | jq -r '.session')
local message
message=$(echo "$entry" | jq -r '.message')
local timestamp
timestamp=$(echo "$entry" | jq -r '.timestamp')
# Format timestamp to just time
local time_display
time_display=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$timestamp" "+%H:%M" 2>/dev/null || echo "${timestamp:11:5}")
# Truncate message to fit
local max_msg_len=32
if [[ ${#message} -gt $max_msg_len ]]; then
message="${message:0:$max_msg_len}…"
fi
local prefix=" "
local style=""
if [[ $i -eq $SELECTED ]]; then
style="${REVERSE}"
prefix="▸"
fi
printf " %s ${CYAN}●${RESET} ${style}${YELLOW}%-8s${RESET}${style} %s ${DIM}%s${RESET}\n" \
"$prefix" "$session" "$message" "$time_display"
done
# Scroll indicator
if [[ $count -gt $max_items ]]; then
local visible_end=$((SCROLL_OFFSET + max_items))
if [[ $visible_end -gt $count ]]; then
visible_end=$count
fi
echo "${DIM} [$((SCROLL_OFFSET + 1))-${visible_end} of ${count}]${RESET}"
fi
echo "${DIM}$(printf '%.0s─' $(seq 1 56))${RESET}"
echo " ${DIM}j/k${RESET} navigate ${DIM}Enter${RESET} goto ${DIM}d${RESET} dismiss ${DIM}D${RESET} all ${DIM}q${RESET} close"
}
dismiss_selected() {
local notifications="$1"
local count
count=$(echo "$notifications" | jq 'length')
if [[ $count -eq 0 ]]; then
return
fi
local id
id=$(echo "$notifications" | jq -r ".[$SELECTED].id")
if [[ -n "$id" && "$id" != "null" ]]; then
tmux-notify dismiss "$id"
fi
}
dismiss_all() {
tmux-notify dismiss all
SELECTED=0
SCROLL_OFFSET=0
}
# Jump to the tmux window that triggered the selected notification
goto_selected() {
local notifications="$1"
local count
count=$(echo "$notifications" | jq 'length')
if [[ $count -eq 0 ]]; then
return
fi
local target
target=$(echo "$notifications" | jq -r ".[$SELECTED].target // empty")
if [[ -n "$target" ]]; then
# Dismiss the notification and switch to the target window
dismiss_selected "$notifications"
# select-window works across sessions (target is "session:window")
tmux select-window -t "$target" 2>/dev/null || true
tmux switch-client -t "$target" 2>/dev/null || true
exit 0
fi
}
# Hide cursor
tput civis 2>/dev/null || true
trap 'tput cnorm 2>/dev/null || true' EXIT
# Main loop
while true; do
notifications=$(get_notifications)
render "$notifications"
# Read a single key
IFS= read -rsn1 key
case "$key" in
j)
SELECTED=$((SELECTED + 1))
;;
k)
SELECTED=$((SELECTED - 1))
;;
'')
goto_selected "$notifications"
;;
d)
dismiss_selected "$notifications"
;;
D)
dismiss_all
;;
q)
exit 0
;;
$'\e')
# Handle escape sequences (arrow keys send \e[A, \e[B, etc.)
read -rsn1 -t 0.1 next_key || true
if [[ "${next_key:-}" == "[" ]]; then
read -rsn1 -t 0.1 arrow_key || true
case "${arrow_key:-}" in
A) SELECTED=$((SELECTED - 1)) ;;
B) SELECTED=$((SELECTED + 1)) ;;
esac
else
# Plain escape key - close panel
exit 0
fi
;;
esac
done
#!/usr/bin/env bash
# tmux-notify-widget: Status bar widget showing notification count
# Displays a bell icon with count when there are pending notifications.
# Shows nothing when empty (clean status bar).
NOTIFY_FILE="${TMUX_NOTIFY_FILE:-/tmp/tmux-notifications.json}"
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
ORANGE="#da702c"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
if [[ ! -f "$NOTIFY_FILE" ]]; then
exit 0
fi
COUNT=$(jq 'length' "$NOTIFY_FILE" 2>/dev/null || echo 0)
if [[ "$COUNT" -gt 0 ]]; then
echo "#[fg=${ORANGE},bg=${BG},bold] 󰂞 ${COUNT} ${RESET}"
fi
#!/usr/bin/env bash
# tmux-notify: Notification state manager for tmux
# Stores notifications in a JSON file and provides subcommands to manage them.
#
# Usage:
# tmux-notify add [--event <type>] <message> Add a notification
# tmux-notify dismiss <id|all> Remove notification(s)
# tmux-notify list Output all notifications as JSON
# tmux-notify count Output notification count
# tmux-notify open Open the notification panel popup
#
# Events:
# Only "complete", "permission", "error", "question" events are recorded. Subagent and user_cancelled events are ignored.
#
# Environment:
# TMUX_NOTIFY_FILE Override notification file path (default: /tmp/tmux-notifications.json)
set -eu
NOTIFY_FILE="${TMUX_NOTIFY_FILE:-/tmp/tmux-notifications.json}"
LOCK_FILE="${NOTIFY_FILE}.lock"
# Ensure the notification file exists
init_file() {
if [[ ! -f "$NOTIFY_FILE" ]]; then
echo '[]' > "$NOTIFY_FILE"
fi
}
# Simple file locking using mkdir (atomic on all platforms)
lock() {
local attempts=0
while ! mkdir "$LOCK_FILE" 2>/dev/null; do
attempts=$((attempts + 1))
if [[ $attempts -gt 50 ]]; then
# Stale lock, remove and retry
rm -rf "$LOCK_FILE"
fi
sleep 0.01
done
}
unlock() {
rm -rf "$LOCK_FILE"
}
# Add a notification
cmd_add() {
local event=""
local message=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--event)
event="${2:-}"
shift 2
;;
*)
message="$1"
shift
;;
esac
done
if [[ -z "$message" ]]; then
echo "Usage: tmux-notify add [--event <type>] <message>" >&2
exit 1
fi
# Filter: only allow task completion events through
# Known events: complete, subagent_complete, error, permission, question, user_cancelled
if [[ -n "$event" ]]; then
case "$event" in
complete) ;; # allow
permission) ;; # allow
error) ;; # allow
question) ;; # allow
*)
# Silently ignore subagent and user_cancelled events
exit 0
;;
esac
fi
local id timestamp session window target
id="$(printf '%s%s' "$(date +%s)" "$$" | shasum | head -c 8)"
timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Try to detect the session and window from tmux
# Use $TMUX_PANE with -t flag for reliable detection from child processes
session="${TMUX_NOTIFY_SESSION:-}"
window=""
target=""
if command -v tmux &>/dev/null; then
local pane_target="${TMUX_PANE:-}"
# Skip notification if the pane is in the currently active window
# of an attached session (i.e., the user is actually looking at it)
if [[ -n "$pane_target" ]]; then
local pane_active session_attached
pane_active="$(tmux display-message -p -t "$pane_target" '#{window_active}' 2>/dev/null || true)"
session_attached="$(tmux display-message -p -t "$pane_target" '#{session_attached}' 2>/dev/null || true)"
if [[ "$pane_active" == "1" && "${session_attached:-0}" -gt 0 ]]; then
exit 0
fi
fi
if [[ -n "$pane_target" ]]; then
if [[ -z "$session" ]]; then
session="$(tmux display-message -p -t "$pane_target" '#S' 2>/dev/null || true)"
fi
window="$(tmux display-message -p -t "$pane_target" '#I' 2>/dev/null || true)"
else
if [[ -z "$session" ]]; then
session="$(tmux display-message -p '#S' 2>/dev/null || true)"
fi
window="$(tmux display-message -p '#I' 2>/dev/null || true)"
fi
if [[ -n "$session" && -n "$window" ]]; then
target="${session}:${window}"
fi
fi
session="${session:-opencode}"
init_file
lock
local entry
entry=$(jq -n \
--arg id "$id" \
--arg ts "$timestamp" \
--arg sess "$session" \
--arg msg "$message" \
--arg tgt "$target" \
'{id: $id, timestamp: $ts, session: $sess, message: $msg, target: $tgt}')
jq --argjson entry "$entry" '. += [$entry]' "$NOTIFY_FILE" > "${NOTIFY_FILE}.tmp" \
&& mv "${NOTIFY_FILE}.tmp" "$NOTIFY_FILE"
unlock
# Flash a styled message in the tmux status bar and refresh the widget
if command -v tmux &>/dev/null; then
tmux refresh-client -S 2>/dev/null || true
# Temporarily set a clean message style matching the flexoki light theme
tmux set -g message-style "fg=#da702c,bg=#f2f0e5" 2>/dev/null || true
tmux display-message -d 5000 "󰂞 ${session}: ${message}" 2>/dev/null || true
fi
# Send terminal bell when remote mode is active.
# Termius (and most iOS SSH clients) surface BEL as an iOS notification
# when the app is backgrounded, providing a vibration/alert on the phone.
if [[ -f "/tmp/tmux-remote-state" ]]; then
# Send BEL to all tmux clients so the SSH session receives it
local pane_target="${TMUX_PANE:-}"
if [[ -n "$pane_target" ]]; then
tmux send-keys -t "$pane_target" "" 2>/dev/null || true
else
printf '\a'
fi
fi
}
# Dismiss (remove) one or all notifications
cmd_dismiss() {
local target="${1:-}"
if [[ -z "$target" ]]; then
echo "Usage: tmux-notify dismiss <id|all>" >&2
exit 1
fi
init_file
lock
if [[ "$target" == "all" ]]; then
echo '[]' > "$NOTIFY_FILE"
else
jq --arg id "$target" '[.[] | select(.id != $id)]' "$NOTIFY_FILE" > "${NOTIFY_FILE}.tmp" \
&& mv "${NOTIFY_FILE}.tmp" "$NOTIFY_FILE"
fi
unlock
}
# List all notifications as JSON
cmd_list() {
init_file
cat "$NOTIFY_FILE"
}
# Count notifications
cmd_count() {
init_file
jq 'length' "$NOTIFY_FILE"
}
# Open the notification panel popup
cmd_open() {
if ! command -v tmux &>/dev/null; then
echo "tmux is not available" >&2
exit 1
fi
local panel_script
panel_script="$(command -v tmux-notify-panel)"
tmux display-popup -w 60 -h 20 -E "$panel_script"
}
# Jump to the most recent notification's window and dismiss it
cmd_goto() {
init_file
local count
count=$(jq 'length' "$NOTIFY_FILE")
if [[ "$count" -eq 0 ]]; then
tmux display-message "No notifications" 2>/dev/null || true
return
fi
# Get the newest notification (last in the array)
local target id
target=$(jq -r '.[-1].target // empty' "$NOTIFY_FILE")
id=$(jq -r '.[-1].id' "$NOTIFY_FILE")
# Dismiss it
if [[ -n "$id" && "$id" != "null" ]]; then
cmd_dismiss "$id"
fi
# Switch to the target window and refresh status bar
if command -v tmux &>/dev/null; then
tmux refresh-client -S 2>/dev/null || true
if [[ -n "$target" ]]; then
tmux select-window -t "$target" 2>/dev/null || true
tmux switch-client -t "$target" 2>/dev/null || true
fi
fi
}
# Main dispatch
case "${1:-}" in
add) shift; cmd_add "$@" ;;
dismiss) shift; cmd_dismiss "$@" ;;
goto) cmd_goto ;;
list) cmd_list ;;
count) cmd_count ;;
open) cmd_open ;;
*)
echo "Usage: tmux-notify <add|dismiss|goto|list|count|open> [args...]" >&2
exit 1
;;
esac
{
username,
pkgs,
lib,
...
}:
let
flexoki-neovim = pkgs.vimUtils.buildVimPlugin {
pname = "flexoki-neovim";
version = "2025-08-26";
src = pkgs.fetchFromGitHub {
owner = "kepano";
repo = "flexoki-neovim";
rev = "c3e2251e813d29d885a7cbbe9808a7af234d845d";
sha256 = "0j6r1rm9g6mm5b5x2wddwyhh6wjagk0x9babs73ky081sgvlyl2f";
};
};
nvim-treesitter = pkgs.vimPlugins.nvim-treesitter.withPlugins (
treesitter-plugins: with treesitter-plugins; [
bash
css
dart
elixir
go
gomod
gosum
html
javascript
json
lua
markdown
markdown_inline
nix
python
rust
toml
tsx
typescript
yaml
]
);
in
{
home-manager.users.${username} = {
# LSP servers and dev tools installed as Nix packages (no Mason needed)
home.packages = with pkgs; [
# Language servers
lua-language-server
nil # Nix LSP
typescript-language-server
vscode-langservers-extracted # HTML, CSS, JSON, ESLint
pyright
gopls
rust-analyzer
dart
elixir-ls
tailwindcss-language-server
nodePackages.bash-language-server
yaml-language-server
taplo # TOML LSP
marksman # Markdown LSP
# Formatters & linters
stylua
nixfmt-rfc-style
prettierd
black
gofumpt
rustfmt
];
programs.neovim = {
enable = true;
defaultEditor = true;
viAlias = true;
vimAlias = true;
vimdiffAlias = true;
initLua = ''
-- ╔══════════════════════════════════════════╗
-- ║ CORE OPTIONS ║
-- ╚══════════════════════════════════════════╝
vim.g.mapleader = ' '
vim.g.maplocalleader = ' '
vim.o.swapfile = false
vim.o.hlsearch = false
vim.wo.number = true
vim.wo.relativenumber = true
vim.o.mouse = 'a'
vim.o.clipboard = 'unnamedplus'
vim.o.breakindent = true
vim.o.undofile = true
vim.o.ignorecase = true
vim.o.smartcase = true
vim.wo.signcolumn = 'yes'
vim.o.updatetime = 250
vim.o.timeoutlen = 300
vim.o.completeopt = 'menuone,noselect'
vim.o.termguicolors = true
vim.o.scrolloff = 8
vim.o.sidescrolloff = 8
vim.o.cursorline = true
vim.o.splitbelow = true
vim.o.splitright = true
vim.o.wrap = false
vim.o.tabstop = 2
vim.o.shiftwidth = 2
vim.o.expandtab = true
vim.o.showmode = false -- lualine shows the mode
vim.keymap.set({ 'n', 'v' }, '<Space>', '<Nop>', { silent = true })
-- Remap for dealing with word wrap
vim.keymap.set('n', 'k', "v:count == 0 ? 'gk' : 'k'", { expr = true, silent = true })
vim.keymap.set('n', 'j', "v:count == 0 ? 'gj' : 'j'", { expr = true, silent = true })
-- ╔══════════════════════════════════════════╗
-- ║ VS CODE-LIKE KEYBINDINGS ║
-- ╚══════════════════════════════════════════╝
-- Move lines up/down (like Alt+Up/Down in VS Code)
vim.keymap.set('n', '<A-j>', ':m .+1<CR>==', { desc = 'Move line down', silent = true })
vim.keymap.set('n', '<A-k>', ':m .-2<CR>==', { desc = 'Move line up', silent = true })
vim.keymap.set('v', '<A-j>', ":m '>+1<CR>gv=gv", { desc = 'Move selection down', silent = true })
vim.keymap.set('v', '<A-k>', ":m '<-2<CR>gv=gv", { desc = 'Move selection up', silent = true })
-- Indent/dedent in visual mode (stay in visual)
vim.keymap.set('v', '<', '<gv', { desc = 'Dedent and reselect' })
vim.keymap.set('v', '>', '>gv', { desc = 'Indent and reselect' })
-- Window navigation (Ctrl+hjkl like VS Code pane switching)
vim.keymap.set('n', '<C-h>', '<C-w>h', { desc = 'Move to left window' })
vim.keymap.set('n', '<C-j>', '<C-w>j', { desc = 'Move to below window' })
vim.keymap.set('n', '<C-k>', '<C-w>k', { desc = 'Move to above window' })
vim.keymap.set('n', '<C-l>', '<C-w>l', { desc = 'Move to right window' })
-- Resize windows with Ctrl+arrows
vim.keymap.set('n', '<C-Up>', ':resize +2<CR>', { desc = 'Increase height', silent = true })
vim.keymap.set('n', '<C-Down>', ':resize -2<CR>', { desc = 'Decrease height', silent = true })
vim.keymap.set('n', '<C-Left>', ':vertical resize -2<CR>', { desc = 'Decrease width', silent = true })
vim.keymap.set('n', '<C-Right>', ':vertical resize +2<CR>', { desc = 'Increase width', silent = true })
-- Buffer navigation
vim.keymap.set('n', '<S-h>', ':bprevious<CR>', { desc = 'Previous buffer', silent = true })
vim.keymap.set('n', '<S-l>', ':bnext<CR>', { desc = 'Next buffer', silent = true })
-- Better paste (don't replace register content)
vim.keymap.set('v', 'p', '"_dP', { desc = 'Paste without yanking replaced text' })
-- Clear search with Escape
vim.keymap.set('n', '<Esc>', ':noh<CR>', { desc = 'Clear search highlight', silent = true })
-- ╔══════════════════════════════════════════╗
-- ║ WORD / LINE / FILE NAVIGATION ║
-- ╚══════════════════════════════════════════╝
-- Opt+Left/Right → move by word (like Option+arrows in VS Code)
vim.keymap.set({'n', 'v'}, '<A-Left>', 'b', { desc = 'Word back' })
vim.keymap.set({'n', 'v'}, '<A-Right>', 'w', { desc = 'Word forward' })
vim.keymap.set('i', '<A-Left>', '<C-o>b', { desc = 'Word back' })
vim.keymap.set('i', '<A-Right>', '<C-o>w', { desc = 'Word forward' })
-- Cmd+Left/Right → start/end of line (arrives as Home/End in terminal)
vim.keymap.set({'n', 'v'}, '<Home>', '^', { desc = 'Start of line' })
vim.keymap.set({'n', 'v'}, '<End>', '$', { desc = 'End of line' })
vim.keymap.set('i', '<Home>', '<C-o>^', { desc = 'Start of line' })
vim.keymap.set('i', '<End>', '<C-o>$', { desc = 'End of line' })
-- Cmd+Up/Down → start/end of file (arrives as CSI u via extended-keys)
vim.keymap.set({'n', 'v'}, '<C-Home>', 'gg', { desc = 'Start of file' })
vim.keymap.set({'n', 'v'}, '<C-End>', 'G', { desc = 'End of file' })
-- Opt+Backspace → delete word back (like Option+Backspace in VS Code)
vim.keymap.set('i', '<A-BS>', '<C-w>', { desc = 'Delete word back' })
-- ╔══════════════════════════════════════════╗
-- ║ QUICK ACCESS (VS Code muscle memory) ║
-- ╚══════════════════════════════════════════╝
-- Cmd+P → fuzzy find files in project (Ghostty sends CSI 80;6u)
vim.keymap.set('n', '<C-S-p>', '<cmd>Telescope find_files<cr>', { desc = 'Find file in project' })
-- Also bind Alt+p as fallback
vim.keymap.set('n', '<A-p>', '<cmd>Telescope find_files<cr>', { desc = 'Find file in project' })
-- Cmd+Shift+F → search text across project (Ghostty sends CSI 70;6u)
vim.keymap.set('n', '<C-S-f>', function() require('telescope.builtin').live_grep() end, { desc = 'Search in project' })
-- Also bind Alt+Shift+F as fallback
vim.keymap.set('n', '<A-F>', function() require('telescope.builtin').live_grep() end, { desc = 'Search in project' })
-- ╔══════════════════════════════════════════╗
-- ║ CHEATSHEET (floating popup) ║
-- ╚══════════════════════════════════════════╝
local function show_cheatsheet()
local s = ' '
local lines = {
' nvim cheatsheet leader = Space',
string.rep('─', 54),
s,
' Quick Access',
' Cmd+p find file in project',
' Cmd+Shift+f search text in project',
' Space Space switch buffer',
' Space / search in current buffer',
' Space g live grep',
s,
' Navigation (arrows) (native)',
' Opt+Left/Right b / w word',
' Cmd+Left/Right ^ / $ line',
' Cmd+Up/Down gg / G file',
' Opt+Backspace (insert) del word',
' Alt+j / Alt+k move line up/down',
s,
' Files',
' Space ff find file',
' Space fr recent files',
' Space fn new file',
' Space fs save file',
s,
' Code (LSP)',
' Space cd go to definition',
' Space cR go to references',
' Space ci go to implementation',
' Space cr rename symbol',
' Space ca code action',
' Space cf format file',
' Space ct type definition',
' Space csd document symbols',
' Space csw workspace symbols',
s,
' Diagnostics',
' Space xd line diagnostics',
' Space xl all diagnostics',
' Space xn / xp next / prev diagnostic',
s,
' Buffers & Tabs',
' Shift+h / Shift+l prev / next buffer',
' Space bd delete buffer',
' Space ett new tab',
' Space etc close tab',
s,
' Splits & Windows',
' Space sv vertical split',
' Space sh horizontal split',
' Ctrl+h/j/k/l move between windows',
' Ctrl+arrows resize windows',
s,
' Git (Source Control)',
' Space Gg open neogit (stage/commit)',
' Space Gc commit',
' Space Gp push',
' Space Gl pull',
' Space Gd diff view (all changes)',
' Space Gf current file history',
' Space GL repo log',
' Space Gq close diff view',
' Space Gb branches',
' ]h / [h next / prev hunk',
' Space Ghs stage hunk',
' Space Ghr reset hunk',
' Space Ghp preview hunk',
' Space Ghb blame line',
s,
' Editing',
' Alt+j / Alt+k move line down / up',
' < / > (visual) indent and reselect',
' gcc toggle comment',
' sa" / sd" / sr" surround add/del/replace',
s,
' AI',
' Ctrl+y accept supermaven suggestion',
' Ctrl+] clear suggestion',
' Ctrl+j accept word',
' Ctrl+. toggle opencode',
' Ctrl+a ask opencode',
' Ctrl+x opencode actions',
' go / goo send range/line to opencode',
s,
' File Explorer',
' Space oo open Oil',
' - (in Oil) go up directory',
s,
' General',
' Space hk search keymaps',
' Space ? this cheatsheet',
' Space qq force quit all',
s,
' press q to close',
}
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = 'wipe'
local width = 54
local height = #lines
local win = vim.api.nvim_open_win(buf, true, {
relative = 'editor',
width = width,
height = height,
col = math.floor((vim.o.columns - width) / 2),
row = math.floor((vim.o.lines - height) / 2),
style = 'minimal',
border = 'rounded',
title = ' cheatsheet ',
title_pos = 'center',
})
-- Highlight the header line
vim.api.nvim_buf_add_highlight(buf, -1, 'Title', 0, 0, -1)
-- Highlight section headers and dim separator/footer
for i, line in ipairs(lines) do
if line:match('^ [A-Z]') and not line:match('^ +[A-Z][a-z]+[+-]') then
vim.api.nvim_buf_add_highlight(buf, -1, 'Function', i - 1, 0, -1)
end
if line:match('^─') or line:match('press q') then
vim.api.nvim_buf_add_highlight(buf, -1, 'Comment', i - 1, 0, -1)
end
end
-- Close on q or Esc
local close = function() pcall(vim.api.nvim_win_close, win, true) end
vim.keymap.set('n', 'q', close, { buffer = buf, silent = true })
vim.keymap.set('n', '<Esc>', close, { buffer = buf, silent = true })
end
vim.keymap.set('n', '<leader>?', show_cheatsheet, { desc = 'Cheatsheet' })
-- Highlight on yank
local highlight_group = vim.api.nvim_create_augroup('YankHighlight', { clear = true })
vim.api.nvim_create_autocmd('TextYankPost', {
callback = function()
vim.highlight.on_yank()
end,
group = highlight_group,
pattern = '*',
})
-- ╔══════════════════════════════════════════╗
-- ║ LSP (Neovim 0.11+ native API) ║
-- ╚══════════════════════════════════════════╝
-- Shared capabilities (enhanced by cmp-nvim-lsp, loaded later)
-- We defer the actual capability merging to LspAttach
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if not client then return end
local bufnr = args.buf
-- Enable inlay hints
if client.server_capabilities.inlayHintProvider then
vim.lsp.inlay_hint.enable(true, { bufnr = bufnr })
end
-- Format on save
if client.server_capabilities.documentFormattingProvider then
vim.api.nvim_create_autocmd('BufWritePre', {
buffer = bufnr,
callback = function()
vim.lsp.buf.format({ bufnr = bufnr })
end,
})
end
end,
})
-- Default config applied to all servers
vim.lsp.config('*', {
capabilities = vim.lsp.protocol.make_client_capabilities(),
})
-- Per-server configuration
vim.lsp.config('lua_ls', {
settings = {
Lua = {
workspace = { checkThirdParty = false },
telemetry = { enable = false },
completion = { callSnippet = 'Replace' },
},
},
})
vim.lsp.config('nil_ls', {
settings = {
['nil'] = {
formatting = { command = { "nixfmt" } },
},
},
})
vim.lsp.config('gopls', {
settings = {
gopls = {
analyses = { unusedparams = true },
staticcheck = true,
gofumpt = true,
},
},
})
vim.lsp.config('rust_analyzer', {
settings = {
['rust-analyzer'] = {
checkOnSave = { command = 'clippy' },
cargo = { allFeatures = true },
},
},
})
vim.lsp.config('elixirls', {
cmd = { 'elixir-ls' },
})
-- Enable all servers (filetypes auto-detected via nvim-lspconfig definitions)
vim.lsp.enable({
'lua_ls',
'nil_ls',
'ts_ls',
'pyright',
'gopls',
'rust_analyzer',
'dartls',
'elixirls',
'tailwindcss',
'html',
'cssls',
'jsonls',
'bashls',
'yamlls',
'taplo',
'marksman',
})
-- Diagnostic UI
vim.diagnostic.config({
virtual_text = { spacing = 4, prefix = '●' },
signs = {
text = {
[vim.diagnostic.severity.ERROR] = ' ',
[vim.diagnostic.severity.WARN] = ' ',
[vim.diagnostic.severity.HINT] = '󰌵 ',
[vim.diagnostic.severity.INFO] = ' ',
},
},
underline = true,
update_in_insert = false,
severity_sort = true,
float = {
border = 'rounded',
source = true,
},
})
'';
plugins = with pkgs; [
# ── Core dependencies ──────────────────────────────
vimPlugins.plenary-nvim
# ── Colorscheme ────────────────────────────────────
{
plugin = flexoki-neovim;
config = ''
lua << EOF
vim.cmd.colorscheme "flexoki-light"
EOF
'';
}
# ── Completion engine (must be before LSP setup) ───
{
plugin = vimPlugins.cmp-nvim-lsp;
config = ''
lua << EOF
-- Enhance default LSP capabilities with cmp completions
vim.lsp.config('*', {
capabilities = require('cmp_nvim_lsp').default_capabilities(),
})
EOF
'';
}
vimPlugins.cmp-buffer
vimPlugins.cmp-path
vimPlugins.cmp_luasnip
{
plugin = vimPlugins.luasnip;
config = ''
lua << EOF
require('luasnip.loaders.from_vscode').lazy_load()
EOF
'';
}
vimPlugins.friendly-snippets
# ── LSP progress UI ────────────────────────────────
{
plugin = vimPlugins.fidget-nvim;
config = ''
lua << EOF
require('fidget').setup({})
EOF
'';
}
{
plugin = vimPlugins.lazydev-nvim;
config = ''
lua << EOF
require('lazydev').setup({})
EOF
'';
}
# ── Treesitter (grammars managed by Nix) ──────────
{
plugin = nvim-treesitter;
config = ''
lua << EOF
-- Neovim 0.11+: treesitter highlight/indent are built-in
-- Enable for all buffers that have a parser available
vim.api.nvim_create_autocmd('FileType', {
callback = function(args)
pcall(vim.treesitter.start, args.buf)
end,
})
EOF
'';
}
# ── Fuzzy finder ───────────────────────────────────
vimPlugins.telescope-fzf-native-nvim
{
plugin = vimPlugins.telescope-nvim;
config = ''
lua << EOF
local telescope = require('telescope')
local actions = require('telescope.actions')
telescope.setup {
defaults = {
mappings = {
i = {
['<C-u>'] = false,
['<C-d>'] = false,
['<C-j>'] = actions.move_selection_next,
['<C-k>'] = actions.move_selection_previous,
}
},
file_ignore_patterns = { 'node_modules', '.git/', 'target/' },
},
pickers = {
find_files = {
hidden = true,
},
},
}
telescope.load_extension('fzf')
EOF
'';
}
# ── Which-key (v3 API) ─────────────────────────────
{
plugin = vimPlugins.which-key-nvim;
config = ''
lua << EOF
local wk = require('which-key')
wk.setup({
plugins = {
spelling = { enabled = true },
},
})
wk.add({
-- Top-level leader bindings
{ "<leader><space>", "<cmd>Telescope buffers<cr>", desc = "Switch buffer" },
{ "<leader>/", function()
require('telescope.builtin').current_buffer_fuzzy_find(
require('telescope.themes').get_dropdown { winblend = 10, previewer = false }
)
end, desc = "Search in buffer" },
{ "<leader>g", function() require('telescope.builtin').live_grep() end, desc = "Live grep" },
-- File
{ "<leader>f", group = "file" },
{ "<leader>ff", "<cmd>Telescope find_files<cr>", desc = "Find file" },
{ "<leader>fr", "<cmd>Telescope oldfiles<cr>", desc = "Recent files" },
{ "<leader>fn", "<cmd>enew<cr>", desc = "New file" },
{ "<leader>fs", "<cmd>w<cr>", desc = "Save file" },
-- Code (LSP)
{ "<leader>c", group = "code" },
{ "<leader>ca", vim.lsp.buf.code_action, desc = "Code action" },
{ "<leader>cr", vim.lsp.buf.rename, desc = "Rename symbol" },
{ "<leader>cf", function() vim.lsp.buf.format({ async = true }) end, desc = "Format file" },
{ "<leader>cd", vim.lsp.buf.definition, desc = "Go to definition" },
{ "<leader>cD", vim.lsp.buf.declaration, desc = "Go to declaration" },
{ "<leader>ci", function() require('telescope.builtin').lsp_implementations() end, desc = "Go to implementation" },
{ "<leader>cR", function() require('telescope.builtin').lsp_references() end, desc = "Go to references" },
{ "<leader>ct", vim.lsp.buf.type_definition, desc = "Type definition" },
{ "<leader>cs", group = "symbols" },
{ "<leader>csd", function() require('telescope.builtin').lsp_document_symbols() end, desc = "Document symbols" },
{ "<leader>csw", function() require('telescope.builtin').lsp_dynamic_workspace_symbols() end, desc = "Workspace symbols" },
-- Editor
{ "<leader>e", group = "editor" },
{ "<leader>et", group = "tabs" },
{ "<leader>ett", "<cmd>tabnew<cr>", desc = "New tab" },
{ "<leader>etc", "<cmd>tabclose<cr>", desc = "Close tab" },
{ "<leader>eto", "<cmd>tabonly<cr>", desc = "Only this tab" },
{ "<leader>etp", "<cmd>tabprevious<cr>", desc = "Previous tab" },
{ "<leader>etn", "<cmd>tabnext<cr>", desc = "Next tab" },
-- Splits (VS Code-like)
{ "<leader>sv", "<cmd>vsplit<cr>", desc = "Vertical split" },
{ "<leader>sh", "<cmd>split<cr>", desc = "Horizontal split" },
{ "<leader>se", "<C-w>=", desc = "Equal size splits" },
{ "<leader>sc", "<cmd>close<cr>", desc = "Close split" },
-- Quit
{ "<leader>q", group = "quit" },
{ "<leader>qq", "<cmd>qa!<CR>", desc = "Force quit all" },
{ "<leader>qa", "<cmd>qa<CR>", desc = "Quit all" },
-- Help
{ "<leader>h", group = "help" },
{ "<leader>hk", "<cmd>Telescope keymaps<CR>", desc = "Keymaps" },
{ "<leader>hh", "<cmd>Telescope help_tags<CR>", desc = "Help tags" },
{ "<leader>hm", "<cmd>Telescope man_pages<CR>", desc = "Man pages" },
-- Open
{ "<leader>o", group = "open" },
{ "<leader>oo", "<cmd>Oil<CR>", desc = "Oil file explorer" },
{ "<leader>ot", "<cmd>Telescope<CR>", desc = "Telescope" },
{ "<leader>oa", function() require("opencode").toggle() end, desc = "Toggle opencode" },
-- Buffers
{ "<leader>b", group = "buffer" },
{ "<leader>bd", "<cmd>bdelete<CR>", desc = "Delete buffer" },
{ "<leader>bn", "<cmd>bnext<CR>", desc = "Next buffer" },
{ "<leader>bp", "<cmd>bprevious<CR>", desc = "Previous buffer" },
-- Diagnostics
{ "<leader>x", group = "diagnostics" },
{ "<leader>xd", function() vim.diagnostic.open_float() end, desc = "Line diagnostics" },
{ "<leader>xl", function() require('telescope.builtin').diagnostics() end, desc = "All diagnostics" },
{ "<leader>xn", function() vim.diagnostic.goto_next() end, desc = "Next diagnostic" },
{ "<leader>xp", function() vim.diagnostic.goto_prev() end, desc = "Previous diagnostic" },
-- Window management
{ "<leader>w", proxy = "<C-w>", group = "window" },
-- Git
{ "<leader>G", group = "git" },
{ "<leader>Gg", "<cmd>Neogit<cr>", desc = "Open Neogit (source control)" },
{ "<leader>Gc", "<cmd>Neogit commit<cr>", desc = "Commit" },
{ "<leader>Gp", "<cmd>Neogit push<cr>", desc = "Push" },
{ "<leader>Gl", "<cmd>Neogit pull<cr>", desc = "Pull" },
{ "<leader>Gd", "<cmd>DiffviewOpen<cr>", desc = "Diff view (all changes)" },
{ "<leader>Gf", "<cmd>DiffviewFileHistory %<cr>", desc = "File history" },
{ "<leader>GL", "<cmd>DiffviewFileHistory<cr>", desc = "Repo log" },
{ "<leader>Gq", "<cmd>DiffviewClose<cr>", desc = "Close diff view" },
{ "<leader>Gb", "<cmd>Telescope git_branches<cr>", desc = "Branches" },
{ "<leader>Gs", "<cmd>Telescope git_status<cr>", desc = "Status (telescope)" },
{ "<leader>Gh", group = "hunks" },
})
EOF
'';
}
# ── LSP setup (Neovim 0.11+ native vim.lsp.config) ──
#
# nvim-lspconfig is still needed for its /lsp/*.lua server
# definition files which register filetypes and default cmd/settings.
# We just don't call require('lspconfig') anymore.
vimPlugins.nvim-lspconfig
# ── Completion (nvim-cmp) ──────────────────────────
{
plugin = vimPlugins.nvim-cmp;
config = ''
lua << EOF
local cmp = require('cmp')
local luasnip = require('luasnip')
cmp.setup {
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert {
['<C-n>'] = cmp.mapping.select_next_item(),
['<C-p>'] = cmp.mapping.select_prev_item(),
['<C-d>'] = cmp.mapping.scroll_docs(-4),
['<C-f>'] = cmp.mapping.scroll_docs(4),
['<C-Space>'] = cmp.mapping.complete {},
['<CR>'] = cmp.mapping.confirm {
behavior = cmp.ConfirmBehavior.Replace,
select = true,
},
['<Tab>'] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
elseif luasnip.expand_or_locally_jumpable() then
luasnip.expand_or_jump()
else
fallback()
end
end, { 'i', 's' }),
['<S-Tab>'] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item()
elseif luasnip.locally_jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, { 'i', 's' }),
},
sources = cmp.config.sources({
{ name = 'nvim_lsp' },
{ name = 'luasnip' },
{ name = 'path' },
}, {
{ name = 'buffer' },
}),
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
}
EOF
'';
}
# ── UI & Navigation ───────────────────────────────
vimPlugins.nvim-web-devicons
{
plugin = vimPlugins.lualine-nvim;
config = ''
lua << EOF
require('lualine').setup({
options = {
theme = 'auto',
component_separators = { left = '│', right = '│' },
section_separators = { left = "", right = "" },
},
sections = {
lualine_a = { 'mode' },
lualine_b = { 'branch', 'diff', 'diagnostics' },
lualine_c = { { 'filename', path = 1 } },
lualine_x = { 'encoding', 'fileformat', 'filetype' },
lualine_y = { 'progress' },
lualine_z = {
'location',
{
function()
local ok, oc = pcall(require, "opencode")
if ok and oc.statusline then return oc.statusline() end
return ""
end,
},
},
},
})
EOF
'';
}
{
plugin = vimPlugins.bufferline-nvim;
config = ''
lua << EOF
require('bufferline').setup({
options = {
diagnostics = 'nvim_lsp',
show_buffer_close_icons = true,
show_close_icon = false,
separator_style = 'thin',
offsets = {
{
filetype = 'oil',
text = 'File Explorer',
text_align = 'center',
},
},
},
})
EOF
'';
}
{
plugin = vimPlugins.indent-blankline-nvim;
config = ''
lua << EOF
require('ibl').setup({
indent = { char = '│' },
scope = { enabled = true },
})
EOF
'';
}
{
plugin = vimPlugins.gitsigns-nvim;
config = ''
lua << EOF
require('gitsigns').setup({
signs = {
add = { text = '│' },
change = { text = '│' },
delete = { text = '_' },
topdelete = { text = '‾' },
changedelete = { text = '~' },
},
on_attach = function(bufnr)
local gs = package.loaded.gitsigns
local function map(mode, l, r, opts)
opts = opts or {}
opts.buffer = bufnr
vim.keymap.set(mode, l, r, opts)
end
-- Navigation
map('n', ']h', gs.next_hunk, { desc = 'Next git hunk' })
map('n', '[h', gs.prev_hunk, { desc = 'Previous git hunk' })
-- Actions
map('n', '<leader>Ghs', gs.stage_hunk, { desc = 'Stage hunk' })
map('n', '<leader>Ghr', gs.reset_hunk, { desc = 'Reset hunk' })
map('n', '<leader>Ghp', gs.preview_hunk, { desc = 'Preview hunk' })
map('n', '<leader>Ghb', function() gs.blame_line({ full = true }) end, { desc = 'Blame line' })
map('n', '<leader>Ghd', gs.diffthis, { desc = 'Diff this' })
end,
})
EOF
'';
}
# ── Git: neogit (source control panel) ─────────────
{
plugin = vimPlugins.diffview-nvim;
config = ''
lua << EOF
require('diffview').setup({
use_icons = true,
})
EOF
'';
}
{
plugin = vimPlugins.neogit;
config = ''
lua << EOF
require('neogit').setup({
integrations = {
telescope = true,
diffview = true,
},
signs = {
hunk = { " ", " " },
item = { "▸", "▾" },
section = { "▸", "▾" },
},
})
EOF
'';
}
# ── File explorer ──────────────────────────────────
{
plugin = vimPlugins.oil-nvim;
config = ''
lua << EOF
require('oil').setup({
default_file_explorer = true,
columns = {
"icon",
"permissions",
"size",
"mtime",
},
delete_to_trash = true,
cleanup_delay_ms = 1000,
use_default_keymaps = true,
view_options = {
show_hidden = true,
},
})
EOF
'';
}
# ── Editing helpers ────────────────────────────────
vimPlugins.nvim-comment
{
plugin = vimPlugins.nvim-autopairs;
config = ''
lua << EOF
require('nvim-autopairs').setup({})
-- Integrate with cmp
local cmp_autopairs = require('nvim-autopairs.completion.cmp')
local cmp = require('cmp')
cmp.event:on('confirm_done', cmp_autopairs.on_confirm_done())
EOF
'';
}
{
plugin = vimPlugins.mini-nvim;
config = ''
lua << EOF
-- Surround: add/change/delete surrounding brackets, quotes, etc.
-- sa" to add quotes, sd" to delete, sr"' to replace " with '
require('mini.surround').setup({})
-- Highlight word under cursor
require('mini.cursorword').setup({})
EOF
'';
}
# ── AI autocomplete (Supermaven) ───────────────────
{
plugin = vimPlugins.supermaven-nvim;
config = ''
lua << EOF
require('supermaven-nvim').setup({
keymaps = {
accept_suggestion = "<C-y>",
clear_suggestion = "<C-]>",
accept_word = "<C-j>",
},
color = {
suggestion_color = "#888888",
},
log_level = "off",
})
EOF
'';
}
# ── opencode.nvim (AI agent integration) ──────────
{
plugin = vimPlugins.opencode-nvim;
config = ''
lua << EOF
vim.o.autoread = true -- Required for opencode edit reloading
---@type opencode.Opts
vim.g.opencode_opts = {}
-- Ask opencode about selection/cursor
vim.keymap.set({ "n", "x" }, "<C-a>", function() require("opencode").ask("@this: ", { submit = true }) end, { desc = "Ask opencode" })
-- Select from opencode actions (prompts, commands)
vim.keymap.set({ "n", "x" }, "<C-x>", function() require("opencode").select() end, { desc = "opencode actions" })
-- Toggle opencode TUI
vim.keymap.set({ "n", "t" }, "<C-.>", function() require("opencode").toggle() end, { desc = "Toggle opencode" })
-- Operator mode: send range to opencode (supports dot-repeat)
vim.keymap.set({ "n", "x" }, "go", function() return require("opencode").operator("@this ") end, { desc = "Send range to opencode", expr = true })
vim.keymap.set("n", "goo", function() return require("opencode").operator("@this ") .. "_" end, { desc = "Send line to opencode", expr = true })
-- Remap increment/decrement since we took <C-a>/<C-x>
vim.keymap.set("n", "+", "<C-a>", { desc = "Increment", noremap = true })
vim.keymap.set("n", "-", "<C-x>", { desc = "Decrement", noremap = true })
EOF
'';
}
# ── Dashboard ──────────────────────────────────────
{
plugin = vimPlugins.alpha-nvim;
config = ''
lua << EOF
local alpha = require('alpha')
local dashboard = require('alpha.themes.dashboard')
dashboard.section.header.val = {
[[ ]],
[[ ▄▄▄ ]],
[[ ███ ▀▀ ▀▀ ▀▀ ]],
[[ ███ ██ ███▄███▄ ██ ██ ██ ██ ███▄███▄ ]],
[[ ███ ██ ██ ██ ██ ██ ██▄██ ██ ██ ██ ██ ]],
[[ ████████ ██▄ ██ ██ ██ ██▄ ▀█▀ ██▄ ██ ██ ██ ]],
[[ ]],
}
dashboard.section.buttons.val = {
dashboard.button("f", " Find file", "<cmd>Telescope find_files<CR>"),
dashboard.button("r", " Recent files", "<cmd>Telescope oldfiles<CR>"),
dashboard.button("g", " Live grep", "<cmd>Telescope live_grep<CR>"),
dashboard.button("e", " New file", "<cmd>enew<CR>"),
dashboard.button("q", " Quit", "<cmd>qa<CR>"),
}
alpha.setup(dashboard.opts)
EOF
'';
}
];
};
};
}
#!/usr/bin/env bash
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
BLUE="#4385be"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
SHOW_PATH=$(tmux show-option -gv @flexoki-tmux_show_path 2>/dev/null)
PATH_FORMAT=$(tmux show-option -gv @flexoki-tmux_path_format 2>/dev/null)
# Enabled by default; set @flexoki-tmux_show_path to "0" to disable
if [ "${SHOW_PATH}" = "0" ]; then
exit 0
fi
current_path="${1}"
PATH_FORMAT="${PATH_FORMAT:-relative}"
if [[ ${PATH_FORMAT} == "relative" ]]; then
home_dir="${HOME:-$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')}"
home_dir="${home_dir:-/Users/$(id -un)}"
current_path="${current_path/#${home_dir}/\~}"
fi
echo "#[fg=${BLUE},bg=${BG}]  ${RESET}${current_path} "
#!/usr/bin/env bash
# tmux-remote-widget: Status bar widget showing remote access state.
# Displays an SSH icon when remote access is active.
# Shows nothing when inactive (clean status bar).
STATE_FILE="/tmp/tmux-remote-state"
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
RED="#d14d41"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
if [[ -f "$STATE_FILE" ]]; then
echo "#[fg=${RED},bg=${BG},bold] 󰣀 SSH ${RESET}"
fi
#!/usr/bin/env bash
# tmux-remote: Toggle "remote access" mode for lid-closed SSH from iPhone.
#
# When enabled:
# - Prevents sleep on lid close (pmset disablesleep)
# - Keeps Wi-Fi alive during sleep (pmset womp + powernap)
# - Enables SSH (Remote Login)
# - Closes resource-heavy GUI apps to save battery
# - Stores list of closed apps for restoration on disable
#
# When disabled:
# - Re-enables normal sleep behavior
# - Disables SSH
# - Reopens apps that were closed
#
# Usage: tmux-remote on|off|toggle|status
STATE_FILE="/tmp/tmux-remote-state"
CLOSED_APPS_FILE="/tmp/tmux-remote-closed-apps"
# Apps to close for battery savings (process names as seen by osascript/pkill)
# These are the resource-heavy GUI apps; system utilities are left running.
APPS_TO_CLOSE=(
# Browsers
"Firefox"
"Google Chrome"
# Chat
"Slack"
"Microsoft Teams"
"Telegram"
"WhatsApp"
"Discord"
# Editors / IDEs
"Visual Studio Code"
"Cursor"
"IntelliJ IDEA CE"
"Android Studio"
# Dev tools
"DBeaver"
"Linear"
"Postman"
"Studio 3T"
"OrbStack"
# Media
"IINA"
"Spotify"
"Stremio"
# Productivity
"Anki"
"Anytype"
"calibre"
"Figma"
"LibreOffice"
"Loom"
"Microsoft Word"
"NetNewsWire"
"Notion Calendar"
"Obsidian"
"Spark"
# Games
"Steam"
# Terminal (if running the GUI terminal — you'll SSH in instead)
"Ghostty"
)
is_active() {
[[ -f "$STATE_FILE" ]]
}
get_running_apps() {
# Returns a list of currently running app names (one per line)
osascript -e 'tell application "System Events" to get name of every process whose background only is false' 2>/dev/null |
sed 's/, /\n/g'
}
close_apps() {
local running closed_count=0
running=$(get_running_apps)
# Clear previous closed apps list
: > "$CLOSED_APPS_FILE"
for app in "${APPS_TO_CLOSE[@]}"; do
if echo "$running" | grep -qxF "$app"; then
echo "$app" >> "$CLOSED_APPS_FILE"
# Graceful quit via osascript
osascript -e "tell application \"$app\" to quit" 2>/dev/null &
closed_count=$((closed_count + 1))
fi
done
# Wait for quit signals to be sent
wait
echo "$closed_count"
}
reopen_apps() {
if [[ ! -f "$CLOSED_APPS_FILE" ]]; then
return
fi
local count=0
while IFS= read -r app; do
[[ -z "$app" ]] && continue
open -gja "$app" 2>/dev/null &
count=$((count + 1))
done < "$CLOSED_APPS_FILE"
wait
rm -f "$CLOSED_APPS_FILE"
echo "$count"
}
enable_remote() {
if is_active; then
echo "Remote access is already enabled"
return 0
fi
# Close GUI apps first (before any sudo prompts)
local closed
closed=$(close_apps)
# Prevent sleep when lid is closed (battery mode)
sudo pmset -b disablesleep 1
# Keep network alive during lid-closed operation
# womp = Wake on Magic Packet (keeps network interface alive)
# powernap = allows periodic network activity during sleep
# tcpkeepalive = maintains TCP connections during sleep
sudo pmset -b womp 1
sudo pmset -b powernap 1
sudo pmset -b tcpkeepalive 1
# Enable SSH daemon via launchctl (avoids Full Disk Access requirement)
sudo launchctl load -w /System/Library/LaunchDaemons/ssh.plist 2>/dev/null
# Mark as active
touch "$STATE_FILE"
# Notify via tmux
if [[ -n "$TMUX" ]]; then
tmux display-message "Remote access ON — closed ${closed} apps — safe to close lid"
tmux refresh-client -S
else
echo "Remote access ON — closed ${closed} apps — safe to close lid"
fi
}
disable_remote() {
if ! is_active; then
echo "Remote access is already disabled"
return 0
fi
# Restore normal sleep behavior
sudo pmset -b disablesleep 0
sudo pmset -b womp 0
sudo pmset -b powernap 0
# tcpkeepalive is normally on by default, leave it
sudo pmset -b tcpkeepalive 1
# Disable SSH daemon via launchctl
sudo launchctl unload -w /System/Library/LaunchDaemons/ssh.plist 2>/dev/null
# Remove state file
rm -f "$STATE_FILE"
# Reopen apps that were closed
local reopened
reopened=$(reopen_apps)
# Notify via tmux
if [[ -n "$TMUX" ]]; then
tmux display-message "Remote access OFF — reopened ${reopened} apps"
tmux refresh-client -S
else
echo "Remote access OFF — reopened ${reopened} apps"
fi
}
toggle_remote() {
if is_active; then
disable_remote
else
enable_remote
fi
}
print_status() {
if is_active; then
echo "ACTIVE"
else
echo "INACTIVE"
fi
}
case "${1:-}" in
on) enable_remote ;;
off) disable_remote ;;
toggle) toggle_remote ;;
status) print_status ;;
*)
echo "Usage: tmux-remote on|off|toggle|status"
exit 1
;;
esac
#!/usr/bin/env bash
# tmux-status-right: Unified status bar right side.
# When remote mode is active, shows a minimal bar (SSH + battery).
# When inactive, shows the full bar (notifications + path + git).
STATE_FILE="/tmp/tmux-remote-state"
PANE_PATH="${1:-}"
# Flexoki light theme colors
BG="#f2f0e5"
FG="#100f0f"
RED="#d14d41"
GREEN="#879a39"
YELLOW="#d0a215"
ORANGE="#da702c"
RESET="#[fg=${FG},bg=${BG},nobold,noitalics,nounderscore,nodim]"
# --- Battery widget (inline, only in remote mode) ---
battery_widget() {
local info percent icon color charging=""
info=$(pmset -g batt 2>/dev/null)
[[ -z "$info" ]] && return
percent=$(echo "$info" | grep -oE '[0-9]+%' | head -1 | tr -d '%')
[[ -z "$percent" ]] && return
if echo "$info" | grep -q "AC Power"; then
charging="󰂄"
elif echo "$info" | grep -q "charged"; then
charging="󰂄"
fi
if [[ "$percent" -ge 80 ]]; then
icon="󰁹"; color="$GREEN"
elif [[ "$percent" -ge 60 ]]; then
icon="󰂀"; color="$GREEN"
elif [[ "$percent" -ge 40 ]]; then
icon="󰁾"; color="$YELLOW"
elif [[ "$percent" -ge 20 ]]; then
icon="󰁻"; color="$ORANGE"
else
icon="󰁺"; color="$RED"
fi
[[ -n "$charging" ]] && icon="$charging"
echo "#[fg=${color},bg=${BG},bold] ${icon} ${percent}%${RESET}"
}
if [[ -f "$STATE_FILE" ]]; then
# ---- Remote mode: minimal bar ----
# SSH indicator + battery + time
SSH="#[fg=${RED},bg=${BG},bold] 󰣀 SSH${RESET}"
BATT=$(battery_widget)
echo "${SSH}${BATT}"
else
# ---- Normal mode: full bar ----
# Notifications + path + git + date + time
# These call the existing widget scripts
SELF_DIR="$(dirname "$0")"
NOTIFY=$("${SELF_DIR}/tmux-notify-widget")
PATH_W=$("${SELF_DIR}/tmux-path-widget" "$PANE_PATH")
GIT=$("${SELF_DIR}/tmux-git-status" "$PANE_PATH")
echo "${NOTIFY}${PATH_W}${GIT}"
fi
#!/usr/bin/env bash
# tmux-attach: Attach to the most recent tmux session, or create one.
SESSION=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | head -1)
if [ -z "$SESSION" ]; then
exec tmux new-session -s main
fi
exec tmux attach-session -t "$SESSION"
{ pkgs }:
pkgs.stdenvNoCC.mkDerivation {
pname = "tmux-extras";
version = "1.0.0";
src = ./.;
nativeBuildInputs = [ pkgs.makeWrapper ];
dontBuild = true;
installPhase = ''
mkdir -p $out/bin
cp git-status.sh $out/bin/tmux-git-status
cp path-widget.sh $out/bin/tmux-path-widget
cp cheatsheet.sh $out/bin/tmux-cheatsheet
cp notify.sh $out/bin/tmux-notify
cp notify-panel.sh $out/bin/tmux-notify-panel
cp notify-widget.sh $out/bin/tmux-notify-widget
cp tmux-attach.sh $out/bin/tmux-attach
cp tmux-group.sh $out/bin/tmux-group
cp tmux-ungroup.sh $out/bin/tmux-ungroup
cp remote.sh $out/bin/tmux-remote
cp remote-widget.sh $out/bin/tmux-remote-widget
cp battery-widget.sh $out/bin/tmux-battery-widget
cp status-right.sh $out/bin/tmux-status-right
cp git-root-path.sh $out/bin/tmux-git-root-path
chmod +x $out/bin/*
# Wrap notification scripts to ensure dependencies are on PATH
wrapProgram $out/bin/tmux-notify \
--prefix PATH : ${
pkgs.lib.makeBinPath [
pkgs.jq
pkgs.coreutils
pkgs.tmux
]
} \
--prefix PATH : $out/bin
wrapProgram $out/bin/tmux-notify-panel \
--prefix PATH : ${
pkgs.lib.makeBinPath [
pkgs.jq
pkgs.coreutils
pkgs.ncurses
]
} \
--prefix PATH : $out/bin
wrapProgram $out/bin/tmux-notify-widget \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.jq ]}
wrapProgram $out/bin/tmux-attach \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.tmux ]}
wrapProgram $out/bin/tmux-group \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.tmux ]}
wrapProgram $out/bin/tmux-ungroup \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.tmux ]}
wrapProgram $out/bin/tmux-remote \
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.tmux ]}
wrapProgram $out/bin/tmux-status-right \
--prefix PATH : ${
pkgs.lib.makeBinPath [
pkgs.jq
pkgs.coreutils
]
} \
--prefix PATH : $out/bin
'';
meta = {
description = "Custom tmux status bar widgets, cheatsheet, and notification system";
};
}
#!/usr/bin/env bash
# tmux-group: Create a grouped session from the current session.
# Opens in a new Ghostty window with independent window selection.
SESSION=$(tmux display-message -p "#{session_name}")
# Don't group an already grouped session — use its parent instead
if [[ "$SESSION" =~ _g[0-9]+$ ]]; then
SESSION="${SESSION%_g[0-9]*}"
fi
# Find next available group ID
id=1
while tmux has-session -t "${SESSION}_g${id}" 2>/dev/null; do
id=$((id + 1))
done
GROUP_SESSION="${SESSION}_g${id}"
# Open a new Ghostty window that creates the grouped session.
# destroy-unattached ensures cleanup when the window closes.
open -na Ghostty --args -e tmux new-session -t "$SESSION" -s "$GROUP_SESSION" \; \
set-option destroy-unattached on
{ pkgs }:
pkgs.tmuxPlugins.mkTmuxPlugin {
pluginName = "tmux-nerd-font-window-name";
version = "2.3.0-unstable-2026-02-17";
rtpFilePath = "tmux-nerd-font-window-name.tmux";
src = pkgs.fetchFromGitHub {
owner = "joshmedeski";
repo = "tmux-nerd-font-window-name";
rev = "7c08b6be2a1d0502d5c5cc7171f8507502ca3e25";
sha256 = "sha256-i3DT+r7WUvutRhob+tHZOe8TBUxpe4JflS9e1dgkg6s=";
};
}
#!/usr/bin/env bash
# tmux-ungroup: Leave and destroy the current grouped session.
# If not in a grouped session, does nothing.
SESSION=$(tmux display-message -p "#{session_name}")
if [[ ! "$SESSION" =~ _g[0-9]+$ ]]; then
tmux display-message "Not in a grouped session"
exit 0
fi
# Kill this grouped session — tmux will close the client (and Ghostty window)
tmux kill-session -t "$SESSION"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment