🔄

【コピペすら不要】tmux + Neovimで作るVSCodeライクなターミナル開発環境

に公開

おはようございます、こんにちは、こんばんは。
スペースマーケットでWebエンジニアをしています、s0arです。

ファッキンコールド(クソ寒い)

今日はターミナル開発環境の話をします。

この記事 is 何

以前、Zellij + Helix で軽量ターミナル開発環境を作った記事を書きました。

で、結局 tmux + Neovim に乗り換えました。

今回は Ghostty + tmux + Neovim の組み合わせで、Cmd+P でファイル検索、Cmd+Shift+F で全文検索など、VSCode のキーバインドがそこそこそのまま使えるターミナル開発環境を構築しました。
前回はコピペで動く構成でしたが、今回は セットアップスクリプト1発で全部入ります。コピペすら不要。

git clone https://github.com/s-0-a-r/tmux-neovim-ide.git ~/tmux-neovim-ide && ~/tmux-neovim-ide/setup.sh

もちろん中身を理解したい人向けに、以下で各設定ファイルの解説もしています。

なぜ乗り換えたのか

前回の選定理由(振り返り)

前回の記事では、こんなことを書いていました。

ターミナル開発環境といえば tmux + Neovim が定番ですが、Neovim は設定が大変そうで踏み出せない人も多いんじゃないでしょうか。自分もその一人でした。
プラグインマネージャーどれ使えばいいの?LSP の設定は?Tree-sitter って何?…みたいな。

せやろなあ。わかるよその気持ち。わかるけどな。

使ってみて分かった不満

全体観:
設定こねくり回して無理くり作ってるだけ。自己満過ぎ。

Helix の限界:
Helix はデフォルトでそこそこ動いて簡単なのが魅力だけど、エコシステムもクソもねえ
思うよね、したいって。カスタマイズ。(自認浅倉透激キモおじさん)

Zellij である必要性が一切なかった:
ガチでなんのこだわりもなかった。tmuxでええよね。

乗り換えのきっかけ

決め手は3つです。

  1. lazy.nvim の存在: Neovim のプラグイン管理が劇的に簡単になった。設定ファイルを lua/plugins/ に置くだけで自動で読み込んでくれる。なんなら初回起動時にプラグインマネージャー自体を自動インストールしてくれる。すごい(KONAMI)
  2. Ghostty の Cmd キーパススルー: macOS の Cmd キーを tmux 経由で Neovim まで届けられるようになった。つまり Cmd+P でファイル検索ができる。tmux のキーバインド覚えなくてOK
  3. エコシステムの豊富さ: ファイルツリー、ファジーファインダー、Git 装飾、ターミナル統合…何でもプラグインでできる。結局エコシステムの豊富さが正義だった

前回「Neovim の設定が面倒」って言ったのは誰…俺!俺!俺!俺!Ole!Ole!

採用したツール

ツール 役割
Ghostty ターミナルエミュレータ
tmux セッション管理
Neovim エディタ + ターミナル + ファイルツリー
fzf プロジェクト選択

もうね、とにかくNeovimが田中正義。ジャスティス。今年も正義執行してくれ頼む。

何の話?

アーキテクチャ

Ghostty (ターミナルエミュレータ)
  └── tmux (セッション管理)
       └── Neovim (NVIM_IDE=1 モード)
            ├── Neo-tree (左: ファイルツリー)
            ├── Snacks Dashboard / Editor (中央: ダッシュボード / コード編集)
            ├── Snacks Terminal (下: Claude Code 実装用)
            └── Snacks Terminal (右: Claude Code レビュー用)

ポイントは tmux と Neovim の役割分担です。

  • tmux: セッション管理だけ。ペイン分割は Neovim に任せる
  • Neovim: エディタ + ターミナル + ファイルツリーをすべて内蔵
  • vim-tmux-navigator: Ctrl+h/j/k/l で tmux ペインと Neovim ウィンドウをシームレスに行き来

tmux はあくまでセッションを保持するための黒子です。UI は全部 Neovim。

Ghostty is 何

Ghostty は Zig で書かれた高速なターミナルエミュレータです。どっかの赤い彗星とタメ張るぐらい速い。知らんけど。
こいつのええとこは、Cmd キーをエスケープシーケンスとしてアプリケーションに送信できるとこ。
iTerm2 でもできるらしいけど、Ghostty のほうが速いし設定がシンプルなのでね。
なんやかんや言うとりますけども、元々使ってただけっすわ。変える理由さん、なし!ww

Claude Code 2窓体制

前回と同じく実装用 + レビュー・相談用の2窓体制です。

  • 下部ターミナル: 実装をお願いする用
  • 右側ターミナル: コードレビューや相談用

これがバチクソ便利なのは前回から変わりません。ダイソン。

Cmd キーの魔法

ここが今回の技術的な目玉です(大袈裟)

macOS の Cmd キーは通常、ターミナルアプリケーション(tmux や Neovim)には届きません。
OS レベルで消費されるからです。
これを3層のマッピングで突破します。

Ghostty                    tmux                      Neovim
Cmd+P を検知        →  user-keys で受信      →  <D-p> として認識
エスケープシーケンス      CSI u 形式に変換          fzf-lua files を実行
に変換して送信

結果、こうなります:

キー 動作 VSCode 相当
Cmd+P ファイル検索 Quick Open
Cmd+Shift+F 全文検索 Search
Cmd+Shift+P コマンドパレット Command Palette
Cmd+B サイドバー切替 Toggle Sidebar
Cmd+/ コメント切替 Toggle Comment
Cmd+` 下部ターミナル Toggle Terminal
Cmd+J 右側ターミナル -
Cmd+Shift+J フローティングターミナル -

VSCodeのキーバインドに飼い慣らされたワイ大歓喜

tmux のキーバインド覚えたくないとか言ってたやつ息してる?w
キーバインド合わせちゃえばいいのにw

なんか怒られそう。誠にごめんなさい
前回のワイへの煽りです。許してクレメンス

最終的なレイアウト

layout

  • 左: Neo-tree(ファイルツリー)
  • 中央: エディタ(Neovim)
  • 下: Claude Code 実装用ターミナル(Cmd+` で表示/非表示)
  • 右: Claude Code レビュー用ターミナル(Cmd+J で表示/非表示)

ide コマンドで起動すると、NVIM_IDE=1 環境変数により自動でこのレイアウトが展開されます。

セットアップ

1. ツールのインストール

brew install tmux neovim fzf fd ripgrep

Ghostty は公式サイトからインストールしてください。

フォントは Nerd Font 対応のものが必要です。自分は PlemolJP Console NF を使っています。

brew install --cask font-plemol-jp-console-nf

前回は brew install zellij helix yazi fzf fd ripgrep だったので、むしろ減っています。

2. Ghostty の設定

~/Library/Application Support/com.mitchellh.ghostty/config:

background-opacity = 0.8
font-family="PlemolJP Console NF"
keybind = shift+enter=text:\n
term = xterm-256color
macos-option-as-alt = true

# Cmd keys forwarded to Neovim (via tmux user-keys)
keybind = super+p=text:\x1b[112;9~
keybind = super+shift+f=text:\x1b[70;10~
keybind = super+shift+p=text:\x1b[80;10~
keybind = super+b=text:\x1b[98;9~
keybind = super+slash=text:\x1b[47;9~
keybind = super+grave_accent=text:\x1b[96;9~
keybind = super+j=text:\x1b[106;9~
keybind = super+shift+j=text:\x1b[74;10~

Cmd キーをエスケープシーケンスに変換して送信する設定です。
\x1b[112;9~ みたいな謎の文字列が並んでいますが、気にしなくて大丈夫です。
セットアップスクリプトが全部やってくれます。

3. tmux の設定

~/.tmux.conf:

# ==========================================================
# tmux Configuration
# ==========================================================

# -- Terminal & Color --
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"

# -- General --
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
set -g escape-time 0
set -g history-limit 50000
set -g focus-events on
set -g set-clipboard on

# -- Prefix --
set -g prefix C-b
set -g prefix2 C-a

# -- Window & Pane --
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"

# Resize panes
bind -n M-Left resize-pane -L 2
bind -n M-Right resize-pane -R 2
bind -n M-Up resize-pane -U 2
bind -n M-Down resize-pane -D 2

# -- Status Bar --
set -g status-position top

# -- vim-tmux-navigator --
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
    | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf)(diff)?$'"
bind-key -n 'C-h' if-shell "$is_vim" 'send-keys C-h'  'select-pane -L'
bind-key -n 'C-j' if-shell "$is_vim" 'send-keys C-j'  'select-pane -D'
bind-key -n 'C-k' if-shell "$is_vim" 'send-keys C-k'  'select-pane -U'
bind-key -n 'C-l' if-shell "$is_vim" 'send-keys C-l'  'select-pane -R'
bind-key -T copy-mode-vi 'C-h' select-pane -L
bind-key -T copy-mode-vi 'C-j' select-pane -D
bind-key -T copy-mode-vi 'C-k' select-pane -U
bind-key -T copy-mode-vi 'C-l' select-pane -R

# -- Cmd key passthrough (Ghostty ~ format → CSI u format) --
set -s user-keys[0] "\e[112;9~"
set -s user-keys[1] "\e[70;10~"
set -s user-keys[2] "\e[80;10~"
set -s user-keys[3] "\e[98;9~"
set -s user-keys[4] "\e[47;9~"
set -s user-keys[5] "\e[96;9~"
set -s user-keys[6] "\e[106;9~"
set -s user-keys[7] "\e[74;10~"

bind-key -n User0 send-keys -l "\e[112;9u"
bind-key -n User1 send-keys -l "\e[70;10u"
bind-key -n User2 send-keys -l "\e[80;10u"
bind-key -n User3 send-keys -l "\e[98;9u"
bind-key -n User4 send-keys -l "\e[47;9u"
bind-key -n User5 send-keys -l "\e[96;9u"
bind-key -n User6 send-keys -l "\e[106;9u"
bind-key -n User7 send-keys -l "\e[74;10u"

# -- Copy Mode (vi style) --
setw -g mode-keys vi
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"

# -- Plugins --
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
set -g @plugin 'tmux-plugins/tmux-yank'

# Catppuccin theme
set -g @plugin 'catppuccin/tmux#v2.1.3'
set -g @catppuccin_flavor "mocha"
set -g @catppuccin_window_status_style "rounded"
set -g @catppuccin_window_default_text "#W"
set -g @catppuccin_window_current_text "#W"
set -g @catppuccin_status_left_separator "█"
set -g @catppuccin_status_right_separator "█"

# Catppuccin module options
set -g @catppuccin_date_time_text "%H:%M"

# -- Auto-install TPM --
if "test ! -d ~/.tmux/plugins/tpm" \
   "run 'git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm && ~/.tmux/plugins/tpm/bin/install_plugins'"

# Initialize TPM
run '~/.tmux/plugins/tpm/tpm'

# -- Status bar (MUST be after TPM loads catppuccin) --
set -g status-left ""
set -g status-right-length 100
set -g status-right "#{E:@catppuccin_status_directory}"
set -ag status-right "#{E:@catppuccin_status_session}"
set -ag status-right "#{E:@catppuccin_status_date_time}"

ポイント:

  • escape-time 0: これがないと Neovim のモード切り替えが遅延します。必須
  • vim-tmux-navigator: Ctrl+h/j/k/l で tmux ペインと Neovim ウィンドウをシームレスに移動
  • Cmd key passthrough: Ghostty から受け取ったエスケープシーケンスを CSI u 形式に変換して Neovim に転送
  • Catppuccin Mocha: Neovim と統一したテーマ。かっこE
  • ステータスバーは TPM の後に設定: Catppuccin のステータスバーモジュールは TPM がロードした後でないと使えないので、run 'tpm' の後に status-right を設定しています。ここハマりポイント
  • TPM 自動インストール: 初回起動時にプラグインマネージャーとプラグインが自動で入ります

TPM とプラグインは初回起動時に自動でインストールされます。
もし反映されない場合は Ctrl+b → I(大文字の I)で手動インストールしてください。

4. Neovim の設定

Neovim の設定は ~/.config/nvim/ 配下に配置します。

~/.config/nvim/
├── init.lua                  ← メイン設定
└── lua/
    └── plugins/
        ├── colorscheme.lua   ← テーマ
        ├── fzf.lua           ← ファジーファインダー
        ├── neo-tree.lua      ← ファイルエクスプローラー
        ├── lsp.lua           ← LSP
        ├── completion.lua    ← 補完
        ├── treesitter.lua    ← シンタックスハイライト
        ├── git.lua           ← Git装飾
        ├── toggleterm.lua    ← ターミナル統合 + ダッシュボード
        ├── bufferline.lua    ← タブライン
        ├── lualine.lua       ← ステータスライン
        ├── indent.lua        ← インデントガイド
        ├── navigation.lua    ← tmux連携
        └── which-key.lua     ← キーマップヘルプ

ファイル数多いなって思いましたか? 自分も思いました。
でもセットアップスクリプトが全部やるので大丈夫です。たぶん。きっと。maybe。

init.lua

~/.config/nvim/init.lua:

-- ==========================================================
-- Neovim Configuration
-- ==========================================================

-- Leader key (must be set before lazy.nvim)
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"

-- ==========================================================
-- Core Options
-- ==========================================================
local opt = vim.opt

opt.number = true
opt.relativenumber = true
opt.signcolumn = "yes"
opt.cursorline = true
opt.termguicolors = true
opt.showmode = false
opt.pumheight = 10
opt.scrolloff = 8
opt.sidescrolloff = 8
opt.splitbelow = true
opt.splitright = true

opt.ignorecase = true
opt.smartcase = true
opt.hlsearch = true
opt.incsearch = true

opt.expandtab = true
opt.shiftwidth = 2
opt.tabstop = 2
opt.smartindent = true

opt.swapfile = false
opt.backup = false
opt.undofile = true
opt.undodir = vim.fn.stdpath("data") .. "/undo"

opt.updatetime = 250
opt.timeoutlen = 300
opt.completeopt = "menu,menuone,noselect"
opt.clipboard = "unnamedplus"
opt.mouse = "a"
opt.wrap = false
opt.fillchars = { eob = " " }

-- ==========================================================
-- Minimal Keymaps (no Cmd keys yet)
-- ==========================================================
local map = vim.keymap.set

-- Window navigation (Ctrl+h/j/k/l - works with vim-tmux-navigator)
map("n", "<C-h>", "<C-w>h", { desc = "Move to left window" })
map("n", "<C-j>", "<C-w>j", { desc = "Move to lower window" })
map("n", "<C-k>", "<C-w>k", { desc = "Move to upper window" })
map("n", "<C-l>", "<C-w>l", { desc = "Move to right window" })

-- Tab / Buffer navigation
map("n", "<S-h>", "<cmd>BufferLineCyclePrev<cr>", { desc = "Previous tab" })
map("n", "<S-l>", "<cmd>BufferLineCycleNext<cr>", { desc = "Next tab" })

-- Line operations (Alt+J/K = move)
map("n", "<A-j>", "<cmd>m .+1<cr>==", { desc = "Move line down" })
map("n", "<A-k>", "<cmd>m .-2<cr>==", { desc = "Move line up" })
map("i", "<A-j>", "<esc><cmd>m .+1<cr>==gi", { desc = "Move line down" })
map("i", "<A-k>", "<esc><cmd>m .-2<cr>==gi", { desc = "Move line up" })
map("v", "<A-j>", ":m '>+1<cr>gv=gv", { desc = "Move line down" })
map("v", "<A-k>", ":m '<-2<cr>gv=gv", { desc = "Move line up" })

-- Indenting (keep selection)
map("v", "<", "<gv", { desc = "Indent left" })
map("v", ">", ">gv", { desc = "Indent right" })

-- Comment toggle (Cmd+/)
map("n", "<D-/>", "gcc", { remap = true, desc = "Toggle comment" })
map("v", "<D-/>", "gc", { remap = true, desc = "Toggle comment" })

-- Clear search highlight
map("n", "<Esc>", "<cmd>nohlsearch<cr>", { desc = "Clear search highlight" })

-- Terminal mode
map("t", "<Esc><Esc>", [[<C-\><C-n>]], { desc = "Exit terminal mode" })

-- ==========================================================
-- IDE mode: auto-open file tree + terminals (NVIM_IDE=1)
-- ==========================================================
vim.g.is_ide = vim.env.NVIM_IDE == "1"
vim.env.NVIM_IDE = nil -- prevent child processes (e.g. git commit) from inheriting

if vim.g.is_ide then
  vim.api.nvim_create_autocmd("VimEnter", {
    once = true,
    callback = function()
      vim.defer_fn(function()
        -- レイアウト構築
        vim.cmd("Neotree show filesystem")
        vim.cmd("wincmd l")
        local editor_win = vim.api.nvim_get_current_win()
        Snacks.terminal.toggle(nil, {
          env = { SNACKS_TERM = "bottom" },
          win = { position = "bottom", height = 0.3 },
        })
        Snacks.terminal.toggle(nil, {
          env = { SNACKS_TERM = "right" },
          win = { position = "right", width = 0.3 },
        })
        vim.api.nvim_set_current_win(editor_win)
        vim.cmd("stopinsert")
        Snacks.dashboard.open({ buf = 0, win = 0 })
      end, 200)
    end,
  })
end

-- ==========================================================
-- Bootstrap lazy.nvim
-- ==========================================================
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
  vim.fn.system({
    "git", "clone", "--filter=blob:none",
    "https://github.com/folke/lazy.nvim.git",
    "--branch=stable", lazypath,
  })
end
vim.opt.rtp:prepend(lazypath)

-- ==========================================================
-- Load plugins
-- ==========================================================
require("lazy").setup({
  spec = {
    { import = "plugins" },
  },
  install = { colorscheme = { "catppuccin" } },
  checker = { enabled = true, notify = false },
  change_detection = { notify = false },
  performance = {
    rtp = {
      disabled_plugins = {
        "gzip", "matchit", "matchparen", "netrwPlugin",
        "tarPlugin", "tohtml", "tutor", "zipPlugin",
      },
    },
  },
})

NVIM_IDE=1 モードがこの構成のミソです。
環境変数 NVIM_IDE=1 が設定されている場合、vim.g.is_ide フラグに保存してから環境変数を即座に nil にしています。こうしないと Neovim 内から起動する子プロセス(git commit のエディタとか)にまで NVIM_IDE=1 が伝播して、そっちもIDE モードで起動してカオスになるので。地味だけど大事。
起動時に自動で Neo-tree(ファイルツリー)と2つのターミナル(下部 + 右側)を開き、中央にはダッシュボードを表示します。
後述する ide スクリプトがこの環境変数をセットしてくれます。

lazy.nvim は初回起動時に自動でインストールされるので、何も考えずに nvim を起動するだけでOKです。こいつ…動くぞ…!

ダッシュボードは通常の nvim 起動時に表示されますが、IDE モードではレイアウト構築後に改めて正しいウィンドウサイズで開き直されます。
全バッファを閉じたときや :q で最後のバッファを閉じたときもダッシュボードに戻るので、VSCode の Welcome タブ的な感覚で使えます。

プラグイン設定

以下のファイルを ~/.config/nvim/lua/plugins/ に配置してください。

colorscheme.lua(Catppuccin Mocha テーマ):

return {
  {
    "catppuccin/nvim",
    name = "catppuccin",
    priority = 1000,
    opts = {
      flavour = "mocha",
      integrations = {
        blink_cmp = true,
        diffview = true,
        gitsigns = true,
        indent_blankline = { enabled = true, scope_color = "lavender" },
        mason = true,
        native_lsp = {
          enabled = true,
          underlines = {
            errors = { "undercurl" },
            warnings = { "undercurl" },
          },
        },
        neo_tree = true,
        rainbow_delimiters = true,
        treesitter = true,
        which_key = true,
      },
    },
    config = function(_, opts)
      require("catppuccin").setup(opts)
      vim.cmd.colorscheme("catppuccin")
    end,
  },
}

fzf.lua(ファジーファインダー - Cmd+P, Cmd+Shift+F の本体):

return {
  {
    "ibhagwan/fzf-lua",
    dependencies = { "nvim-tree/nvim-web-devicons" },
    keys = {
      -- VSCode macOS: Cmd+P = Quick open, Cmd+Shift+F = Search, etc.
      { "<D-p>", "<cmd>FzfLua files<cr>", desc = "Find files" },
      { "<D-S-f>", "<cmd>FzfLua live_grep<cr>", desc = "Search in files" },
      { "<D-S-p>", "<cmd>FzfLua commands<cr>", desc = "Command palette" },
      { "<D-S-o>", "<cmd>FzfLua lsp_document_symbols<cr>", desc = "Go to symbol" },
      { "<D-t>", "<cmd>FzfLua lsp_workspace_symbols<cr>", desc = "Workspace symbols" },
      { "<D-S-m>", "<cmd>FzfLua diagnostics_document<cr>", desc = "Problems" },
      -- Leader alternatives
      { "<leader>ff", "<cmd>FzfLua files<cr>", desc = "Find files" },
      { "<leader>fg", "<cmd>FzfLua live_grep<cr>", desc = "Live grep" },
      { "<leader>fb", "<cmd>FzfLua buffers<cr>", desc = "Buffers" },
      { "<leader>fh", "<cmd>FzfLua help_tags<cr>", desc = "Help tags" },
      { "<leader>fr", "<cmd>FzfLua oldfiles<cr>", desc = "Recent files" },
      { "<leader>fd", "<cmd>FzfLua diagnostics_document<cr>", desc = "Diagnostics" },
      { "<leader>fs", "<cmd>FzfLua lsp_document_symbols<cr>", desc = "Document symbols" },
      { "<leader>fw", "<cmd>FzfLua lsp_workspace_symbols<cr>", desc = "Workspace symbols" },
    },
    opts = {
      winopts = {
        height = 0.85,
        width = 0.80,
        preview = { layout = "vertical", vertical = "up:40%" },
      },
      files = { fd_opts = "--type f --hidden --exclude .git" },
    },
  },
}

neo-tree.lua(ファイルエクスプローラー - Cmd+B でサイドバー切替):

return {
  {
    "nvim-neo-tree/neo-tree.nvim",
    branch = "v3.x",
    dependencies = {
      "nvim-lua/plenary.nvim",
      "nvim-tree/nvim-web-devicons",
      "MunifTanjim/nui.nvim",
    },
    cmd = { "Neotree" },
    keys = {
      -- VSCode macOS: Cmd+Shift+E = Toggle explorer, Cmd+B = Toggle sidebar
      { "<D-S-e>", "<cmd>Neotree toggle<cr>", desc = "Toggle Explorer" },
      { "<D-b>", "<cmd>Neotree toggle<cr>", desc = "Toggle Sidebar" },
      { "<leader>e", "<cmd>Neotree toggle<cr>", desc = "Toggle Explorer" },
      { "<leader>o", "<cmd>Neotree focus<cr>", desc = "Focus Explorer" },
    },
    opts = {
      close_if_last_window = true,
      filesystem = {
        follow_current_file = { enabled = true },
        use_libuv_file_watcher = true,
        filtered_items = {
          visible = true,
          hide_dotfiles = false,
          hide_gitignored = false,
        },
      },
      window = {
        width = 30,
        mappings = {
          ["<space>"] = "none",
          -- VSCode-like tree keybindings
          ["<cr>"] = "open",              -- Enter = open file
          ["l"] = "open",                 -- l = open (vim-style)
          ["h"] = "close_node",           -- h = collapse (vim-style)
          ["a"] = { "add", config = { show_path = "relative" } },  -- New file
          ["A"] = "add_directory",        -- New folder
          ["d"] = "delete",              -- Delete
          ["r"] = "rename",             -- Rename (VSCode: F2)
          ["y"] = "copy_to_clipboard",   -- Copy
          ["x"] = "cut_to_clipboard",    -- Cut
          ["p"] = "paste_from_clipboard", -- Paste
          ["c"] = "copy",               -- Copy to path
          ["m"] = "move",               -- Move to path
          ["."] = "toggle_hidden",       -- Toggle dotfiles
          ["/"] = "fuzzy_finder",        -- Search in tree
          ["<C-c>"] = "close_window",    -- Close explorer
        },
      },
      default_component_configs = {
        indent = {
          with_expanders = true,
          expander_collapsed = "",
          expander_expanded = "",
        },
        git_status = {
          symbols = {
            added = "",
            modified = "",
            deleted = "✖",
            renamed = "󰁕",
            untracked = "",
            ignored = "",
            unstaged = "󰄱",
            staged = "",
            conflict = "",
          },
        },
      },
    },
  },
}

toggleterm.lua(ターミナル統合 + ダッシュボード - Claude Code 2窓の本体):

return {
  {
    "folke/snacks.nvim",
    priority = 1000,
    lazy = false,
    opts = {
      terminal = {},
      dashboard = {
        enabled = not vim.g.is_ide,
        preset = {
          header = [[
███╗   ██╗███████╗ ██████╗ ██╗   ██╗██╗███╗   ███╗
████╗  ██║██╔════╝██╔═══██╗██║   ██║██║████╗ ████║
██╔██╗ ██║█████╗  ██║   ██║██║   ██║██║██╔████╔██║
██║╚██╗██║██╔══╝  ██║   ██║╚██╗ ██╔╝██║██║╚██╔╝██║
██║ ╚████║███████╗╚██████╔╝ ╚████╔╝ ██║██║ ╚═╝ ██║
╚═╝  ╚═══╝╚══════╝ ╚═════╝   ╚═══╝  ╚═╝╚═╝     ╚═╝]],
          keys = {
            { icon = " ", key = "f", desc = "Find File", action = ":FzfLua files" },
            { icon = " ", key = "r", desc = "Recent Files", action = ":FzfLua oldfiles" },
            { icon = " ", key = "g", desc = "Find Text", action = ":FzfLua live_grep" },
            { icon = " ", key = "q", desc = "Quit", action = ":qa" },
          },
        },
      },
    },
    keys = {
      -- Cmd+` = 下部ターミナル (Claude Code 実装用)
      {
        "<D-`>",
        function()
          Snacks.terminal.toggle(nil, {
            env = { SNACKS_TERM = "bottom" },
            win = { position = "bottom", height = 0.3 },
          })
        end,
        mode = { "n", "t" },
        desc = "Bottom terminal",
      },
      -- Cmd+J = 右側ターミナル (Claude Code 相談/レビュー用)
      {
        "<D-j>",
        function()
          Snacks.terminal.toggle(nil, {
            env = { SNACKS_TERM = "right" },
            win = { position = "right", width = 0.3 },
          })
        end,
        mode = { "n", "t" },
        desc = "Right terminal",
      },
      -- Cmd+Shift+J = フローティングターミナル (通常ターミナル/git操作用)
      {
        "<D-S-j>",
        function()
          Snacks.terminal.toggle(nil, {
            env = { SNACKS_TERM = "float" },
            win = {
              position = "float",
              border = "rounded",
              width = 0.85,
              height = 0.8,
            },
          })
        end,
        mode = { "n", "t" },
        desc = "Floating terminal",
      },
    },
    config = function(_, opts)
      require("snacks").setup(opts)
      vim.keymap.set("t", "<Esc><Esc>", [[<C-\><C-n>]], { desc = "Exit terminal mode" })


      -- :q/:wq on last file buffer → :bd instead of quit (triggers BufDelete → dashboard)
      local function is_last_file_buf()
        local bufs = vim.tbl_filter(function(b)
          return vim.bo[b].buflisted and vim.bo[b].buftype == ""
        end, vim.api.nvim_list_bufs())
        return #bufs <= 1 and vim.bo.buflisted and vim.bo.buftype == ""
      end

      local function ide_close_buf(bang)
        local bufnr = vim.api.nvim_get_current_buf()
        local bufs = vim.tbl_filter(function(b)
          return b ~= bufnr and vim.api.nvim_buf_is_valid(b) and vim.bo[b].buflisted and vim.bo[b].buftype == ""
        end, vim.api.nvim_list_bufs())
        if #bufs > 0 then
          vim.cmd("buffer " .. bufs[#bufs])
        else
          vim.cmd("enew")
        end
        vim.cmd("bdelete" .. (bang and "!" or "") .. " " .. bufnr)
        if #bufs == 0 then
          Snacks.dashboard.open({ buf = 0, win = 0 })
        end
      end

      vim.api.nvim_create_user_command("SmartQ", function(o)
        if not vim.g.is_ide then
          vim.cmd("quit" .. (o.bang and "!" or ""))
        else
          ide_close_buf(o.bang)
        end
      end, { bang = true })

      vim.api.nvim_create_user_command("SmartWQ", function(o)
        vim.cmd("write")
        if not vim.g.is_ide then
          vim.cmd("quit" .. (o.bang and "!" or ""))
        else
          ide_close_buf(o.bang)
        end
      end, { bang = true })

      vim.cmd([[cnoreabbrev <expr> q getcmdtype() == ':' && getcmdline() ==# 'q' ? 'SmartQ' : 'q']])
      vim.cmd([[cnoreabbrev <expr> wq getcmdtype() == ':' && getcmdline() ==# 'wq' ? 'SmartWQ' : 'wq']])
    end,
  },
}

lsp.lua(LSP - 定義ジャンプ、補完、診断):

return {
  {
    "neovim/nvim-lspconfig",
    event = { "BufReadPre", "BufNewFile" },
    dependencies = {
      { "mason-org/mason.nvim", opts = {} },
      { "mason-org/mason-lspconfig.nvim" },
    },
    config = function()
      -- Server configurations using Neovim 0.11 native API
      vim.lsp.config("lua_ls", {
        settings = {
          Lua = {
            diagnostics = { globals = { "vim" } },
            workspace = { checkThirdParty = false },
            telemetry = { enable = false },
          },
        },
      })

      vim.lsp.config("ts_ls", {})
      vim.lsp.config("ruby_lsp", {})

      -- Mason-lspconfig auto-enables installed servers
      require("mason-lspconfig").setup({
        ensure_installed = {
          "lua_ls",
          "ts_ls",
          "ruby_lsp",
        },
        automatic_enable = true,
      })

      -- Diagnostic configuration
      vim.diagnostic.config({
        virtual_text = { spacing = 4, prefix = "●" },
        signs = true,
        underline = true,
        update_in_insert = false,
        severity_sort = true,
      })

      -- Keymaps on LSP attach (VSCode-like)
      vim.api.nvim_create_autocmd("LspAttach", {
        callback = function(args)
          local buf = args.buf
          local map = function(mode, lhs, rhs, desc)
            vim.keymap.set(mode, lhs, rhs, { buffer = buf, desc = desc })
          end
          -- VSCode: F12 = Go to definition
          map("n", "<F12>", vim.lsp.buf.definition, "Go to definition")
          map("n", "gd", vim.lsp.buf.definition, "Go to definition")
          -- VSCode: Shift+F12 = Go to references
          map("n", "<S-F12>", vim.lsp.buf.references, "Go to references")
          map("n", "gr", vim.lsp.buf.references, "Go to references")
          -- VSCode: F2 = Rename symbol
          map("n", "<F2>", vim.lsp.buf.rename, "Rename symbol")
          map("n", "<leader>rn", vim.lsp.buf.rename, "Rename symbol")
          -- VSCode macOS: Cmd+. = Quick fix / code action
          map({ "n", "v" }, "<D-.>", vim.lsp.buf.code_action, "Code action")
          map({ "n", "v" }, "<leader>ca", vim.lsp.buf.code_action, "Code action")
          -- Hover info
          map("n", "K", vim.lsp.buf.hover, "Hover documentation")
          -- Signature help
          map("i", "<D-S-Space>", vim.lsp.buf.signature_help, "Signature help")
          -- VSCode macOS: Shift+Alt+F = Format document
          map("n", "<S-A-f>", function() vim.lsp.buf.format({ async = true }) end, "Format document")
          -- Other
          map("n", "gD", vim.lsp.buf.declaration, "Go to declaration")
          map("n", "gi", vim.lsp.buf.implementation, "Go to implementation")
          map("n", "<leader>D", vim.lsp.buf.type_definition, "Type definition")
          -- VSCode: F8 / Shift+F8 = Next/prev diagnostic
          map("n", "<F8>", vim.diagnostic.goto_next, "Next diagnostic")
          map("n", "<S-F8>", vim.diagnostic.goto_prev, "Previous diagnostic")
          map("n", "]d", vim.diagnostic.goto_next, "Next diagnostic")
          map("n", "[d", vim.diagnostic.goto_prev, "Previous diagnostic")
        end,
      })
    end,
  },
}

ensure_installed の言語サーバーは自分の環境に合わせて変更してください。
Mason が初回起動時に自動でインストールしてくれます。便利杉内。

残りのプラグイン設定(リポジトリに全部入ってます)

completion.lua:

return {
  {
    "saghen/blink.cmp",
    version = "*",
    event = "InsertEnter",
    opts = {
      keymap = { preset = "default" },
      appearance = {
        use_nvim_cmp_as_default = false,
        nerd_font_variant = "mono",
      },
      sources = {
        default = { "lsp", "path", "snippets", "buffer" },
      },
      completion = {
        accept = { auto_brackets = { enabled = true } },
        documentation = { auto_show = true, auto_show_delay_ms = 200 },
        menu = {
          draw = {
            columns = {
              { "kind_icon" },
              { "label", "label_description", gap = 1 },
            },
          },
        },
      },
      signature = { enabled = true },
    },
  },
}

treesitter.lua:

return {
  {
    "nvim-treesitter/nvim-treesitter",
    build = ":TSUpdate",
    event = { "BufReadPre", "BufNewFile" },
    config = function()
      require("nvim-treesitter.config").setup({
        ensure_installed = {
          "bash", "c", "css", "diff", "go", "html", "javascript", "json",
          "lua", "luadoc", "markdown", "markdown_inline", "python", "ruby",
          "rust", "tsx", "typescript", "vim", "vimdoc", "yaml", "toml",
          "dockerfile", "regex",
        },
        auto_install = true,
        sync_install = false,
        highlight = { enable = true },
        indent = { enable = true },
        incremental_selection = {
          enable = true,
          keymaps = {
            init_selection = "<C-space>",
            node_incremental = "<C-space>",
            scope_incremental = false,
            node_decremental = "<bs>",
          },
        },
        textobjects = {
          select = {
            enable = true,
            lookahead = true,
            keymaps = {
              ["af"] = "@function.outer",
              ["if"] = "@function.inner",
              ["ac"] = "@class.outer",
              ["ic"] = "@class.inner",
              ["aa"] = "@parameter.outer",
              ["ia"] = "@parameter.inner",
            },
          },
          move = {
            enable = true,
            goto_next_start = { ["]f"] = "@function.outer", ["]c"] = "@class.outer" },
            goto_previous_start = { ["[f"] = "@function.outer", ["[c"] = "@class.outer" },
          },
        },
      })
    end,
  },
  {
    "nvim-treesitter/nvim-treesitter-textobjects",
    dependencies = { "nvim-treesitter/nvim-treesitter" },
  },
}

git.lua:

return {
  {
    "lewis6991/gitsigns.nvim",
    event = { "BufReadPre", "BufNewFile" },
    opts = {
      signs = {
        add = { text = "▎" },
        change = { text = "▎" },
        delete = { text = "" },
        topdelete = { text = "" },
        changedelete = { text = "▎" },
      },
      on_attach = function(bufnr)
        local gs = require("gitsigns")
        local map = function(mode, l, r, desc)
          vim.keymap.set(mode, l, r, { buffer = bufnr, desc = desc })
        end
        map("n", "]h", gs.next_hunk, "Next hunk")
        map("n", "[h", gs.prev_hunk, "Previous hunk")
        map("n", "<leader>hs", gs.stage_hunk, "Stage hunk")
        map("n", "<leader>hr", gs.reset_hunk, "Reset hunk")
        map("n", "<leader>hp", gs.preview_hunk, "Preview hunk")
        map("n", "<leader>hb", function() gs.blame_line({ full = true }) end, "Blame line")
      end,
    },
  },
  {
    "sindrets/diffview.nvim",
    cmd = { "DiffviewOpen", "DiffviewFileHistory" },
    keys = {
      { "<leader>gd", "<cmd>DiffviewOpen<cr>", desc = "Diff view" },
      { "<leader>gh", "<cmd>DiffviewFileHistory %<cr>", desc = "File history" },
    },
  },
}

bufferline.lua:

return {
  {
    "akinsho/bufferline.nvim",
    version = "*",
    dependencies = {
      "nvim-tree/nvim-web-devicons",
      "catppuccin/nvim",
    },
    event = "VeryLazy",
    keys = {
      { "<leader>bp", "<cmd>BufferLineTogglePin<cr>", desc = "Pin tab" },
      { "<leader>bo", "<cmd>BufferLineCloseOthers<cr>", desc = "Close other tabs" },
    },
    opts = function()
      return {
        options = {
          mode = "buffers",
          diagnostics = "nvim_lsp",
          diagnostics_indicator = function(_, _, diag)
            local icons = { error = " ", warning = " ", hint = " " }
            local ret = (diag.error and icons.error .. diag.error .. " " or "")
              .. (diag.warning and icons.warning .. diag.warning .. " " or "")
            return vim.trim(ret)
          end,
          offsets = {
            {
              filetype = "neo-tree",
              text = "Explorer",
              highlight = "Directory",
              text_align = "left",
            },
          },
          close_command = function(bufnr)
            local bufs = vim.tbl_filter(function(b)
              return b ~= bufnr and vim.api.nvim_buf_is_valid(b) and vim.bo[b].buflisted and vim.bo[b].buftype == ""
            end, vim.api.nvim_list_bufs())
            if #bufs > 0 then
              vim.cmd("buffer " .. bufs[#bufs])
            else
              vim.cmd("enew")
            end
            vim.cmd("bdelete! " .. bufnr)
            if vim.g.is_ide and #bufs == 0 then
              Snacks.dashboard.open({ buf = 0, win = 0 })
            end
          end,
          separator_style = "thin",
          show_buffer_close_icons = true,
          show_close_icon = false,
        },
        highlights = require("catppuccin.special.bufferline").get_theme()(),
      }
    end,
  },
}

lualine.lua:

return {
  {
    "nvim-lualine/lualine.nvim",
    dependencies = { "nvim-tree/nvim-web-devicons" },
    event = "VeryLazy",
    opts = {
      options = {
        theme = "catppuccin",
        component_separators = { left = "", right = "" },
        section_separators = { left = "", right = "" },
        globalstatus = true,
      },
      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" },
      },
    },
  },
}

indent.lua:

return {
  {
    "lukas-reineke/indent-blankline.nvim",
    main = "ibl",
    event = { "BufReadPre", "BufNewFile" },
    opts = {
      indent = { char = "│" },
      scope = {
        enabled = true,
        show_start = true,
        show_end = false,
      },
      exclude = {
        filetypes = { "help", "neo-tree", "lazy", "mason", "toggleterm", "dashboard" },
      },
    },
  },
  {
    "HiPhish/rainbow-delimiters.nvim",
    event = { "BufReadPre", "BufNewFile" },
    config = function()
      require("rainbow-delimiters.setup").setup({
        highlight = {
          "RainbowDelimiterRed",
          "RainbowDelimiterYellow",
          "RainbowDelimiterBlue",
          "RainbowDelimiterOrange",
          "RainbowDelimiterGreen",
          "RainbowDelimiterViolet",
          "RainbowDelimiterCyan",
        },
      })
    end,
  },
}

navigation.lua:

return {
  {
    "christoomey/vim-tmux-navigator",
    cmd = {
      "TmuxNavigateLeft",
      "TmuxNavigateDown",
      "TmuxNavigateUp",
      "TmuxNavigateRight",
    },
    keys = {
      { "<C-h>", "<cmd>TmuxNavigateLeft<cr>", desc = "Navigate left" },
      { "<C-j>", "<cmd>TmuxNavigateDown<cr>", desc = "Navigate down" },
      { "<C-k>", "<cmd>TmuxNavigateUp<cr>", desc = "Navigate up" },
      { "<C-l>", "<cmd>TmuxNavigateRight<cr>", desc = "Navigate right" },
    },
  },
}

which-key.lua:

return {
  {
    "folke/which-key.nvim",
    event = "VeryLazy",
    opts = {
      preset = "modern",
    },
    keys = {
      { "<leader>?", function() require("which-key").show({ global = false }) end, desc = "Buffer keymaps" },
    },
  },
}

5. ide スクリプト

前回と同じく、ide コマンドでプロジェクト選択 → セッション起動します。

mkdir -p ~/.local/bin

~/.local/bin/ide:

#!/bin/bash

selected=$(find "$HOME/projects" -maxdepth 3 -name ".git" 2>/dev/null | xargs -n1 dirname | sort -u | fzf --prompt="Project > ")

if [[ -z "$selected" ]]; then
    echo "No project selected"
    exit 0
fi

# Get repository name and directory name
repo_name=$(cd "$selected" && basename "$(git rev-parse --show-toplevel 2>/dev/null || echo "$selected")")
dir_name=$(basename "$selected")

# Create session name: repo_suffix or just repo
if [[ "$dir_name" == "$repo_name" ]]; then
    session_name="$repo_name"
elif [[ "$dir_name" == "$repo_name"-* ]]; then
    suffix="${dir_name#$repo_name-}"
    session_name="${repo_name}_${suffix}"
else
    session_name="$dir_name"
fi

# Replace invalid characters (. and :)
session_name="${session_name//./_}"
session_name="${session_name//:/_}"


if tmux has-session -t="$session_name" 2>/dev/null; then
    # 既存セッションにアタッチ
    if [[ -n "$TMUX" ]]; then
        tmux switch-client -t "$session_name"
    else
        tmux attach-session -t "$session_name"
    fi
else
    # 新規セッション: NVIM_IDE=1 でnvimを起動
    tmux new-session -d -s "$session_name" -c "$selected" -x "$(tput cols)" -y "$(tput lines)"
    tmux send-keys -t "$session_name" "NVIM_IDE=1 nvim" Enter
    if [[ -n "$TMUX" ]]; then
        tmux switch-client -t "$session_name"
    else
        tmux attach-session -t "$session_name"
    fi
fi
chmod +x ~/.local/bin/ide

前回との変更点:

  • Zellij → tmux に変更
  • セッション名の形式: リポジトリ名ベース(worktree の場合は リポジトリ名_サフィックス)
  • NVIM_IDE=1 で Neovim を IDE モードで起動
  • tmux 内からの switch-client に対応(複数セッションの切り替え)
  • -x -y オプションで正しいターミナルサイズを引き継ぎ

6. パスを通す

~/.zshrc に以下を追加(まだの場合):

export PATH="$HOME/.local/bin:$PATH"
source ~/.zshrc

以上でセットアップ完了です。さすがに簡単すぎて涙出るねとは言いづらいファイル数ですが、
セットアップスクリプトなら1行で終わるので実質簡単涙です。たぶん。

使い方

起動

ide

fzf でプロジェクトを選ぶと tmux セッションが起動し、Neovim が IDE モードで立ち上がります。

VSCode ライクなキーバインド

キー 動作
Cmd+P ファイル検索
Cmd+Shift+F 全文検索
Cmd+Shift+P コマンドパレット
Cmd+B サイドバー(Neo-tree)切替
Cmd+/ コメント切替
Cmd+` 下部ターミナル切替
Cmd+J 右側ターミナル切替
Cmd+Shift+J フローティングターミナル
F12 / gd 定義ジャンプ
Shift+F12 / gr 参照一覧
F2 リネーム
K ホバー情報

Cmd キーが使えるので、VSCode から来た人でも違和感なく操作できるはず。
which-key プラグインのおかげで Space を押すと次に押せるキーの候補が表示されるので、覚えなくても大丈夫です。

tmux の最低限の操作

基本的に tmux のキーバインドを覚える必要はないですが、これだけ知っておくと便利です。

キー 動作
Ctrl+b → c 新しいウィンドウ(タブ)を作成
Ctrl+b → n / p 次/前のウィンドウに移動
Ctrl+b → s セッション一覧を表示・切り替え
Ctrl+b → d デタッチ(セッションを維持したまま離脱)

デタッチした後は ide コマンドで同じプロジェクトを選べば復帰できます。

Git Worktree との連携

前回と同じく、worktree を使うと並行開発が捗ります。

ディレクトリ構成:

~/projects/
├── my-app/                             ← 本体(main)
└── .worktrees/                         ← worktree 専用(フラット)
    ├── my-app_feature-x/
    └── my-app_feature-y/

worktree 作成:

cd ~/projects/my-app
git worktree add -b feature/xxx ../.worktrees/my-app_feature-xxx

worktree のディレクトリ名(例: my-app-feature-xxx)からリポジトリ名部分を判別し、my-app_feature-xxx のようなセッション名を自動生成します。
ide コマンドで各 worktree を選ぶと別々のセッションが立ち上がるので、ブランチごとに独立した環境で開発でき、Claude Code のセッションも混ざらなくて爆アドです。

まとめ

  • 前回「Neovim の設定が面倒」と言ったが、lazy.nvim のおかげで意外と簡単だった(手のひらクルー)
  • Ghostty + tmux + Neovim の3層構成で Cmd キーパススルーが実現できた
  • tmux のキーバインド覚えなくても Cmd+P とか VSCode のキーバインドで操作できる
  • 設定ファイルは多いけどセットアップスクリプト1発でたぶん動く

設定ファイルの量は前回より増えましたが、セットアップスクリプト1発で全部入るので前回よりむしろ楽です。
よかったね。

VSCodeはオワコンおじさん「VSCodeはオワコン」

参考リンク

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion