X11 터미널에서 파일명과 줄/열 번호를 클릭해 올바른 Neovim 인스턴스가 해당 위치로 점프하게 만드는 과정을 소개한다. Alacritty의 힌트(정규식 매칭), 창별 작업 디렉터리 컨텍스트 공유, 간단한 셸 스크립트를 조합해 원격 실행과 다양한 출력 형식까지 유연하게 처리하는 방법을 설명한다.
최근에 X11 터미널에 겉보기엔 사소하지만 프로그래밍 생산성을 눈에 띄게 끌어올린 기능을 추가했다 — 줄 번호(가능하면 열 번호까지)가 있는 파일명을 클릭하면 에디터가 그 파일의 해당 위치로 점프한다:
나처럼 삶의 많은 시간을 터미널에서 보내지만, 에디터는 다른 창에서 쓰는 사람에게 이 이점은 자명할 것이다. 그렇지 않다면, 이 예시는 내 목표가 너무 소박해 보일지도 모른다. “컴파일 및 실행” 버튼을 누르면 이런 것을 다 해주는 올인원 에디터1을 왜 쓰지 않는가?
답은 간단하다. 이 변화에 관여하는 모든 도구에 이미 익숙하기 때문이다. 이미 유닉스 터미널을 알고 있고, 터미널에서 온갖 컴파일러류 명령을 실행하는 법도 안다. 그중 많은 것들은 내가 쓰는 에디터가 별도 지원을 제공하지 않을 것이다2. 영상에서 바로 보이진 않지만, rsync_cmd로 원격 서버에서 러스트 명령을 실행하기도 했다! 짐작했겠지만, 내가 실행하는 명령들은 자신들이 출력하는 파일명 정보를 내가 활용하고 있다는 사실을 전혀 알지 못한다.
기본형은 Alacritty 같은 현대적 터미널에서 쉽게 구현할 수 있다. 하지만 나는 약간 더 정교한 것이 필요했고, 그래서 이 글을 쓰게 됐다. 영상에서 봤듯이, 나는 여러 에디터 인스턴스를 띄워두고, “맞는” 일이 “맞는” 에디터 인스턴스에서 일어나야 한다.
서로 다른 창에서의 마우스 클릭이 “맞는” 에디터 인스턴스와 조율되려면 어떤 형태로든 공유 정보 — 내가 말하는 ‘컨텍스트’ — 가 필요하다. 내 경우 필요한 컨텍스트는 “그 터미널 창에서 인터랙티브 셸이 어떤 디렉터리에서 실행 중이었는가?”다. 최소한 Alacritty는 현재 그러한 컨텍스트를 지원하지 않지만, 추가하는 것은 쉽고, 그 덕에 내가 원하는 것이 가능해진다.
이 글에서 설명할 것을 끔찍한 핵으로 볼 수도, 기존 도구들에 대한 우리의 지식을 실용적으로 재사용하는 방법으로 볼 수도 있다. 개인적으로, “파일명을 매치해 올바른 에디터에서 연다”는 이 아이디어가 적용될 수 있는 유일한 방식이라고는 생각하지 않는다 — 이 글이 다른 사람들에게도 생산성을 높일 아이디어를 주었으면 한다!
10여 년 전 누군가3가 터미널을 해킹해 마우스 클릭으로 에디터와 상호작용하도록 만든 것을 처음 봤다 — 하지만 그것은 터미널이 인식하되 직접 표시하지는 않는 특수 출력을 프로그램(이 경우 컴파일러)이 생성해야 했다. 나는 너무 많은 프로그래밍 언어와 명령을 오가기에, 그런 식으로 전부를 수정하는 것은 비현실적이었고, 터미널 자체를 해킹하는 것도 마찬가지였다.
현대적 터미널 — 나는 한동안 Alacritty를 써 왔다 — 에는 이를 위한 내장 지원이 있다. 가장 흔하게, 많은 터미널은(거의 설정 없이) 프로그램이 출력한 URL을 사용자가 클릭하면 웹 브라우저에서 열 수 있게 해준다.
Alacritty — 그리고 여러 현대적 터미널도 — 는 이것을 조금 일반화한다. 사용자가 정규식을 지정할 수 있고; 매치된 텍스트를 클릭하면 사용자가 선택한 명령을 실행할 수 있다.
이 글 맨 앞의 데모에서, 나는 rustc와 cargo test가 생성한 오류를 클릭했다. 둘 다 /path/to/file.rs:line:col 형식으로 오류를 출력한다. 우리는 이 텍스트를 매치시키고, 결과 텍스트가 사용자의 셸 명령 term_hint(곧 설명!)를 실행하게 만들 수 있다. 다음 ~/.config/alacritty/alacritty.toml처럼 말이다:
[[hints.enabled]] regex = "[^ ]+\.[a-z]+:\d+:\d+" command = { program="term_hint", args=["rustcesque"] } mouse = { enabled = true }
Alacritty는 이것을 힌트(hint)라고 부르며, 꽤 그럴듯한 이름이므로 나도 그렇게 부르겠다. term_hint는 내가 작성한 평범한 셸 프로그램이다. 이 프로그램은 두 개의 인자를 받는다: 스타일(여기서는 rustcesque)과 그 스타일을 따르는 힌트 텍스트. src/server.rs:12:9 같은 힌트 텍스트를 클릭하면 다음 명령이 실행된다:
term_hint ruscesque src/server.rs:12:9
내가 term_hint에 바라는 것은 src/server.rs를 에디터에서 열고 커서를 12행 9열로 옮기는 일이다. 나는 Neovim과 그 GUI인 neovim-qt를 쓴다. Neovim에는 “서버” 모드가 있어 유닉스 소켓 파일에 쓰면 원격으로 제어할 수 있다. 나는 Neovim/neovim-qt를 이 방식으로 실행해 .nvim_server라는 소켓 파일을 만들 수 있다:
nvim-qt -- --listen ./.nvim_server
[왜 여기서 ./가 필요한지는 나도 모르겠다. 하지만 없으면 Neovim이 유닉스 소켓 파일을 만들지 않는다.]
그런 다음 --server .nvim_server로 그 실행 중인 Neovim 인스턴스에 연결해 명령을 보낼 수 있다. 다른 Neovim 인스턴스에게 server.rs로 전환하라고 지시할 수 있다:
nvim --server .nvim_server --remote-tab src/server.rs
그리고 12행 9열로 이동하라고 지시할 수 있다:
nvim --server .nvim_server --remote-send ":call cursor(12,9)<CR>"
이제 rustcesque 스타일에 하드코딩된(즉, 잠시 $1의 값은 무시하는) term_hint의 기본 버전을 만들기 위한 대부분의 조각을 갖췄다:
#! /bin/sh path=$(echo "$2" | cut -d ":" -f 1) line=$(echo "$2" | cut -d ":" -f 2) col=$(echo "$2" | cut -d ":" -f 3) nvim --server /path/to/.nvim_server --remote-tab "${path}" nvim --server /path/to/.nvim_server --remote-send ":call cursor(${line},${col})<CR>"
이 간단한 스크립트만으로, 적어도 기본형은 완성이다! 정리하자면 내가 한 일은: Alacritty에 힌트를 추가했고; Neovim을 서버 모드로 시작했으며; 아주 기본적인 term_hint 스크립트를 작성했다.
모든 편집을 단 하나의 에디터 인스턴스로만 한다면 위의 단순 해법이면 충분했을 것이다 — 하지만 나는 많은 에디터 인스턴스를 쓴다. /path/to/.nvim_server를 하드코딩하는 건 분명 통하지 않는다!
기본 접근의 문제는 term_hint에 전달되는 힌트 텍스트에 추가 컨텍스트가 없다는 점이다. 그 텍스트가 어느 터미널 창에서 왔는지, 그 창에서 무엇이 실행 중이었는지 알 수 없다. 어떤 경우에는 힌트 텍스트로 어떤 프로젝트와 관련 있는지 추릴 수 있겠지만, src/main.rs:4:2 같은 일반적인 힌트라면 어떻게 해야 할까?
이 문제를 다루려면 힌트 텍스트를 보강할 추가 컨텍스트가 필요하다. “무슨 컨텍스트가 충분한가?”에 대한 단일 해답은 없지만, 내 사용 사례에는 극히 적은 컨텍스트만 있으면 되었다. 구체적으로, 내가 알고 싶은 것은: 힌트 텍스트가 생성될 때 내가 어느 디렉터리에서 작업하고 있었는가? 나는 디렉터리별로 Neovim을 하나씩 띄우므로, 그것만 알면 필요한 모든 컨텍스트가 충족된다.
알아본 바로는 Alacritty는 내가 현재 어떤 디렉터리에서 명령을 실행하는지 추적하지 않는다. 다행히 내 인터랙티브 셸이 이를 기록하는 것은 쉽다. 다만 셸과 힌트 명령이 이를 공유할 방법이 필요하다. 가장 명백한 방법은 둘 다 접근할 수 있는 공유 정보를 둔다는 것이다.
아쉽게도 Alacritty는 적절한 공유 정보를 제공하지 않는다 — 하지만 거의 다 왔다! Alacritty는 인터랙티브 셸에 여러 환경 변수를 설정해 주는데, 그중 하나가 현재 창을 식별하는 숫자 ALACRITTY_WINDOW_ID다4. 불행히도 힌트 명령(예: term_hint)을 실행할 때는 이 변수를 설정하지 않는다. 다행히 그렇게 하도록 만드는 것은 매우 쉽다. 그래서 나는 힌트 명령을 실행할 때도 이 변수를 설정하도록 하는 간단한 PR을 올렸다 (아직 머지되진 않았지만, 충분한 동기와 설명을 제공하지 못한 내 탓이라 본다).
이제 터미널에서 필요한 지원을 모두 갖췄고, 이것을 어떻게 활용할지 결정할 수 있다. 내가 선택한 방식은, 특정 터미널 창이 어떤 디렉터리에서 명령을 실행하는지를 기록하는 ‘쿠키’를 사용하고, 힌트 명령이 그것을 활용하도록 하는 것이다.
첫 번째로 할 일은 .zshrc를 수정해, 디렉터리를 바꿀 때마다 해당 터미널 창의 쿠키가 갱신되도록 하는 것이다:
chpwd() { [[ -t 1 ]] || return mkdir -p "$HOME/.cache/term_hint" pwd > "$HOME/.cache/term_hint/$ALACRITTY_WINDOW_ID" print -Pn "\e]2;[%n@%m %~]\a" }
요지는, 내가 cd <dir>를 실행할 때마다 zsh가 chpwd를 호출한다는 것이다. 내가 추가한 유일한 변경은 3, 4번째 줄이다: pwd의 출력(즉, 디렉터리명)을 $HOME/.cache/term_hint/$ALACRITTY_WINDOW_ID에 쓴다. 다시 말해, 터미널 창마다 파일 하나를 갖는 디렉터리가 있고, 각 파일에는 그 터미널 창이 명령을 실행하는 디렉터리가 기록된다.
이제 term_hint를 조정해 쿠키의 내용을 읽고, .nvim_server 파일의 존재를 확인함으로써 그 위치에서 Neovim 인스턴스가 실행 중인지 검사할 수 있다:
#! /bin/sh cookiep="$HOME/.cache/term_hint/$ALACRITTY_WINDOW_ID" if [ ! -f "$cookiep" ]; then exit 0 fi term_dir=$(cat "$cookiep") cd "$term_dir" || exit 1 path=$(echo "$2" | cut -d ":" -f 1) line=$(echo "$2" | cut -d ":" -f 2) col=$(echo "$2" | cut -d ":" -f 3) nvim --server .nvim_server --remote-tab "${path}" nvim --server .nvim_server --remote-send ":call cursor(${line},${col})<CR>"
즉, 컨텍스트에 필요한 것은 ALACRITTY_WINDOW_ID 변수와 쿠키 파일뿐이었다는 뜻이다. 이 경로에 특별한 점은 없고, 같은 효과를 내는 다른 메커니즘을 쓸 수도 있었다5.
마지막으로, Alacritty 설정과 term_hint를 확장해 파이썬이 내는 것 같은 파일명 형식도 처리해 보자. 먼저 Alacritty 설정에 두 번째 힌트를 추가하자:
[[hints.enabled]] regex = "File ., line ." command = { program="/home/ltratt/bin/term_hint", args=["pythonesque"] } mouse = { enabled = true }
정규식이 러스트 프로그램에 필요했던 것과 매우 다르다는 점에 주의하라6. 이제 정규식이 매치되면 term_hint의 첫 번째 인자로 pythonesque를 넘긴다. 그런 다음 term_hint에서 서로 다른 형식을 다음과 같이 파싱할 수 있다:
case "$1" in "pythonesque" ) path=$(echo "$2" | sed -E 's/ File "([^"]+)"./\1/g') line=$(echo "$2" | sed -E 's/.line ([0-9]).*/\1/g') col=1 ;; "rustcesque" ) path=$(echo "$2" | cut -d ":" -f 1) line=$(echo "$2" | cut -d ":" -f 2) col=$(echo "$2" | cut -d ":" -f 3) ;; * ) echo "Unknown style '$1'" > /dev/stderr exit 1 ;; esac
즉, 원한다면 아주 다른 파일명 형식도 어렵지 않게 대응할 수 있다 — 글쎄, 기본 셸 도구로 파싱하는 법만 알아낸다면 말이다 — 예를 들어 clang이나 gcc의 출력처럼.
마지막으로, 이것들을 하나의 스크립트로 합치자:
#! /bin/sh set -x if [ $# != 2 ]; then echo "term_hint <style> <text>" > /dev/stderr exit 1 fi cookiep="$HOME/.cache/term_hint/$ALACRITTY_WINDOW_ID" if [ ! -f "$cookiep" ]; then exit 0 fi term_dir=$(cat "$cookiep") if [ ! -e "$term_dir/.nvim_server" ]; then exit 0 fi case "$1" in "pythonesque" ) path=$(echo "$2" | sed -E 's/ File "([^"]+)"./\1/g') line=$(echo "$2" | sed -E 's/.line ([0-9]).*/\1/g') col=1 ;; "rustcesque" ) path=$(echo "$2" | cut -d ":" -f 1) line=$(echo "$2" | cut -d ":" -f 2) col=$(echo "$2" | cut -d ":" -f 3) ;; * ) echo "Unknown style '$1'" > /dev/stderr exit 1 ;; esac cd "$term_dir" || exit 1 nvim --server .nvim_server --remote-tab "${path}" nvim --server .nvim_server --remote-send ":call cursor(${line},${col})<CR>"
물론, 이 기본 아이디어를 확장해 예를 들어 다른 디렉터리에 에디터가 아직 떠 있지 않다면 열도록 만들 수도 있다. 사실 파일명이나 에디터와 전혀 무관한 다른 사용 사례도 충분히 상상할 수 있다!
개인적으로, 터미널과 에디터 사이를 Alt+Tab으로 왔다갔다하며 파일명과 줄 번호를 잊거나 틀리느라 얼마나 많은 시간을 낭비했는지 믿기지 않는다. 특히 최소한의 컨텍스트를 곁들인 힌트 명령 덕분에 그 비용은 내게 0이 됐다!
2025-10-29 13:45 Older
새 글 소식을 받고 싶다면: Mastodon이나 Twitter를 팔로우하거나; RSS 피드를 구독하거나; 이메일 업데이트를 구독하세요:
[1](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
또는 Neovim의 동등한 기능.
☒
또는 Neovim의 동등한 기능.
[2](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
예를 들어, 에디터는 내가 새로 쓰는 언어에 대해 내장 지식을 가지지 않는 경우가 많다.
☒
예를 들어, 에디터는 내가 새로 쓰는 언어에 대해 내장 지식을 가지지 않는 경우가 많다.
[3](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
Armin Rigo. 내가 만나 본 프로그래머 중 경이로울 만큼 뛰어난 사람. Armin의 여러 아이디어가 늘 그렇듯, 내가 따라잡는 데 오랜 시간이 걸렸다.
☒
Armin Rigo. 내가 만나 본 프로그래머 중 경이로울 만큼 뛰어난 사람. Armin의 여러 아이디어가 늘 그렇듯, 내가 따라잡는 데 오랜 시간이 걸렸다.
[4](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
64비트 정수. 이것이 무엇을 뜻하는지는 플랫폼마다 달라질 수 있다(예: 포인터이거나 임의의 수일 수도 있다).
☒
64비트 정수. 이것이 무엇을 뜻하는지는 플랫폼마다 달라질 수 있다(예: 포인터이거나 임의의 수일 수도 있다).
[5](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
첫 프로토타입은 xprop으로 터미널 제목에서 디렉터리명을 끌어왔다!
☒
첫 프로토타입은 xprop으로 터미널 제목에서 디렉터리명을 끌어왔다!
[6](https://tratt.net/laurie/blog/2025/what_context_can_bring_to_terminal_mouse_clicks.html)
파이썬이 아닌 출력도 매치하지만, 문제되지 않는다.
☒
파이썬이 아닌 출력도 매치하지만, 문제되지 않는다.