#ターミナルに表示されたファイルパスを Cmd+Click するだけで Neovim にファイルが開く。そんな開発体験を WezTerm の open-uri イベントハンドラで実現したので、仕組みと設定方法を紹介します。
モチベーション
開発中、ビルドエラーやlint、grep の出力にファイルパスが頻繁に表示されます。
internal/handler/user.go:42:15: undefined: ErrNotFound
src/components/App.tsx:18:5: 'useState' is defined but never used
config/settings.yaml:12: mapping values are not allowed here
このパスを目で追ってエディタで手動で開く、という作業を毎回やるのは地味にストレスです。「クリックしたら開いてほしい」
——これを WezTerm と Neovim の連携で解決しました。あらゆる言語・ファイル形式に対応しています。
全体のアーキテクチャ
処理の流れは 3 ステップ。
1. hyperlink_rules でファイルパスを検出しカスタム URI に変換
2. Cmd+Click のマウスバインディングで OpenLinkAtMouseCursor を発火
3. open-uri イベントハンドラが URI を受け取り、nvim でファイルを開く
+-----------------+ +--------------------+ +------------------+
| hyperlink_rules | --> | Mouse Binding | --> | open-uri handler |
| ファイルパスを | | Cmd+Click で発火 | | nvim でファイル |
| 検出・URI に変換 | | OpenLinkAtMouse | | を開く |
| | | Cursor | | |
+-----------------+ +--------------------+ +------------------+
Step 1:ハイパーリンクルールでファイルパスを検出する
WezTerm の hyperlink_rules は、ターミナル出力に対して正規表現マッチを行い、マッチした文字列をクリック可能なリンクに変換する機能です。
あらゆるファイル形式に対応するため、2 つのルールを使い分けます。
-- デフォルトルール(URL 等)を維持
config.hyperlink_rules = wezterm.default_hyperlink_rules()
-- ルール 1:ディレクトリ付きファイルパスにマッチ(拡張子不問)
-- 例: src/main.py:10:5, ./config.yaml, ~/project/handler.go
table.insert(config.hyperlink_rules, {
regex = [[[~.]?[\w.\-@]*(?:/[\w.\-@]+)+\.\w+(?::\d+(?::\d+)?)?]],
format = 'file://$0',
})
-- ルール 2:ファイル名のみ(ディレクトリなし)の場合は主要な拡張子にマッチ
-- 例: main.go:42, app.tsx:18, settings.yaml
table.insert(config.hyperlink_rules, {
regex = [[\b[\w\-]+\.(?:go|py|pyi|js|jsx|mjs|cjs|ts|tsx|mts|lua|rs|rb|c|cc|cpp|h|hpp|java|kt|swift|sh|bash|zsh|yaml|yml|toml|json|jsonc|xml|html|htm|css|scss|less|sql|proto|graphql|md|txt|rst|csv|log|conf|cfg|ini|env|mod|sum|lock|vue|svelte|astro|tf|nix|zig|dart|ex|exs|erl|hs|pl|pm|mk|cmake|gradle|prisma|sol)(?::\d+(?::\d+)?)?\b]],
format = 'file://$0',
})
2 つのルールに分けている理由
| ルール | 対象 | 拡張子制限 |
|---|---|---|
| ルール 1 |
/ を含むパス(src/main.py) |
なし(すべてマッチ) |
| ルール 2 | ファイル名のみ(main.go) |
主要な拡張子のみ |
ディレクトリ付きパスであればファイルパスであることがほぼ確実なので、拡張子を問わずマッチさせます。一方、ファイル名のみの場合はドメイン名(example.com)等との誤マッチを防ぐため、プログラミングで使われる主要な拡張子に限定しています。
ポイント
-
regexは Rust の正規表現記法(fancy-regex)で書きます -
(?::\d+(?::\d+)?)?で:行番号:列番号のオプショナルマッチに対応 -
$0でマッチ全体を URI に変換(行番号・列番号も含む) -
wezterm.default_hyperlink_rules()で URL のクリック等デフォルト動作を維持
これにより、ターミナル上の internal/handler/user.go:42:15 のようなテキストがクリック可能なリンクに変換され、Cmd を押しながらホバーすると下線が表示されるようになります。
Step 2:Cmd+Click のマウスバインディング
config.mouse_bindings = {
{
event = { Up = { streak = 1, button = "Left" } },
mods = "CMD",
action = wezterm.action.OpenLinkAtMouseCursor,
},
}
OpenLinkAtMouseCursor はマウスカーソル直下のハイパーリンクを開くアクションです。通常はデフォルトブラウザで URL を開きますが、WezTerm では open-uri イベントをフックすることでこの動作をカスタマイズできます。
event に Up(マウスボタンを離した時)を指定しているのは、ドラッグによるテキスト選択との競合を避けるためです。
Step 3:open-uri イベントハンドラ(本体)
ここが今回の設定の核心です。URI からファイルパスと行番号を解析し、nvim でファイルを開きます。
wezterm.on("open-uri", function(window, pane, uri)
local path_info = uri:gsub("file://", "")
-- (1) file:line:col を解析
local path, line = path_info:match('^(.+):(%d+):%d+$')
if not path then
path, line = path_info:match('^(.+):(%d+)$')
end
if not path then
path = path_info
end
-- (2) 相対パス → 絶対パスへの解決
if path:sub(1, 1) ~= "/" then
local cwd_url = pane:get_current_working_dir()
if cwd_url then
local cwd = cwd_url.file_path
or tostring(cwd_url):gsub("file://[^/]*", "")
cwd = cwd:gsub("/$", "")
path = cwd .. "/" .. path
end
end
-- (3) 既存の nvim タブを探す
local mux_window = window:mux_window()
for i, tab in ipairs(mux_window:tabs()) do
for _, tab_pane in ipairs(tab:panes()) do
local process = tab_pane:get_foreground_process_name()
if process and process:find("nvim") then
-- nvim タブに切り替え
window:perform_action(
wezterm.action.ActivateTab(i - 1), pane
)
-- Escape → wincmd l → edit でファイルを開く
local cmd = "vim.cmd('wincmd l') vim.cmd('edit "
if line then
cmd = cmd .. "+" .. line .. " "
end
cmd = cmd .. path .. "')"
tab_pane:send_text("\x1b:lua " .. cmd .. "\r")
return false
end
end
end
-- (4) nvim が見つからなければ新しいタブで開く
local cwd_url = pane:get_current_working_dir()
local cwd = nil
if cwd_url then
cwd = cwd_url.file_path
or tostring(cwd_url):gsub("file://[^/]*", "")
cwd = cwd:gsub("/$", "")
end
local args = { "/opt/homebrew/bin/nvim", "." }
if line then
table.insert(args, "+" .. line)
end
table.insert(args, path)
window:perform_action(
wezterm.action.SpawnCommandInNewTab {
cwd = cwd or "",
args = args,
},
pane
)
return false
end)
処理の詳細解説
(1) ファイルパスと行番号の解析
ハイパーリンクルールが :行番号:列番号 も含めてマッチするため、URI からこれらを分離します。
file://src/handler.go:42:15 → path = "src/handler.go", line = "42"
file://app.tsx:18 → path = "app.tsx", line = "18"
file://config.yaml → path = "config.yaml", line = nil
(2) 相対パスの解決
ビルドツールや linter の出力はプロジェクトルートからの相対パスです。pane:get_current_working_dir() でクリック元ペインの作業ディレクトリを取得し、絶対パスに変換します。
クリック元ペインの cwd: /Users/me/projects/myapp
出力のパス: internal/handler/user.go
解決後: /Users/me/projects/myapp/internal/handler/user.go
(3) 既存の nvim タブへのディスパッチ
mux_window:tabs() で現在のウィンドウ内の全タブを走査し、フォアグラウンドプロセスが nvim であるペインを探します。見つかった場合:
-
ActivateTabでそのタブに切り替え -
send_textで nvim にキーストロークを送信(行番号がある場合は+行番号付き)
送信しているテキストの内訳:
| 文字列 | 意味 |
|---|---|
\x1b |
Escape キー。インサートモードやコマンドラインモードから抜ける |
:lua ... |
Vim の Ex コマンドで Lua を実行 |
vim.cmd('wincmd l') |
カーソルを右のウィンドウに移動(neo-tree 等のサイドバーを避ける) |
vim.cmd('edit +42 ...') |
指定パスのファイルを行番号付きで開く |
\r |
Enter キーでコマンド実行 |
wincmd l を挟んでいるのは、左側に neo-tree などのファイルツリーが開いている場合に、メインのエディタウィンドウでファイルを開くためです。
(4) nvim がなければ新規タブで起動
既存の nvim タブが見つからない場合、SpawnCommandInNewTab で新しいタブを作成し、nvim . +行番号 <filepath> として起動します。.(カレントディレクトリ)を引数に含めることで、neo-tree 等のプラグインがプロジェクトツリーを正しく認識できます。
return false の重要性
ハンドラの末尾で return false を返しています。これは WezTerm に「デフォルトの URI 処理(ブラウザで開く)をスキップせよ」と伝えるためです。これを忘れると、nvim で開くと同時にブラウザでも file:// URL を開こうとしてしまいます。
実際の使用フロー
1. ターミナルでビルドや lint を実行
→ internal/handler/user.go:42:15: undefined: ErrNotFound
→ src/components/App.tsx:18:5: 'useState' is defined but never used
2. Cmd を押しながらファイルパスをホバー
→ パスに下線が表示される(.go, .tsx, .py 等あらゆるファイルが対象)
3. クリック
→ 既に nvim が開いているタブに自動で切り替わる
→ nvim のメインウィンドウにファイルが該当行で開く
nvim が起動していない場合は、新しいタブで自動的に nvim が立ち上がり、プロジェクトツリーとともにファイルが開きます。
ハマりどころと Tips
OSC 7 が必要
pane:get_current_working_dir() でペインの作業ディレクトリを取得するには、シェルが OSC 7 エスケープシーケンスでカレントディレクトリを WezTerm に通知している必要があります。
zsh の場合、以下を .zshrc に追加します(多くの場合デフォルトで設定済み):
# WezTerm にカレントディレクトリを通知
precmd() {
printf '\e]7;file://%s%s\e\\' "$HOST" "$PWD"
}
hyperlink_rules は上書きされる
config.hyperlink_rules を設定すると、WezTerm のデフォルトルール(https:// URL の検出など)が上書きされます。必ず wezterm.default_hyperlink_rules() でデフォルトルールを取得してから table.insert で追加してください。
-- ⭕ デフォルトルールを維持しつつ追加
config.hyperlink_rules = wezterm.default_hyperlink_rules()
table.insert(config.hyperlink_rules, { ... })
-- ❌ デフォルトルールが消えてしまう
config.hyperlink_rules = { { ... } }
対応拡張子のカスタマイズ
ルール 2(ファイル名のみのマッチ)は、誤マッチ防止のため拡張子を限定しています。対象外の拡張子を追加したい場合は、正規表現の拡張子リストに追記してください。
-- 例: .prisma と .graphql を追加
regex = [[\b[\w\-]+\.(?:go|py|...|prisma|graphql)(?::\d+(?::\d+)?)?\b]],
なお、ディレクトリ付きのパス(path/to/file.xyz)はルール 1 で拡張子を問わずマッチするため、追記は不要です。
WezTerm の hyperlink_rules + open-uri イベントハンドラを組み合わせることで、ターミナル出力のファイルパスから直接 Neovim でファイルを開く体験を実現できました。
設定のポイントは 3 つ:
- hyperlink_rules で 2 つのルールを使い分け、あらゆるファイル形式のパスを検出・URI に変換
-
mouse_bindings で Cmd+Click に
OpenLinkAtMouseCursorを割り当て - open-uri ハンドラ で URI から行番号を解析し、既存 nvim タブへのディスパッチまたは新規起動を行う