What Context Can Bring to Terminal Mouse Clicks

Recent posts
What Context Can Bring to Terminal Mouse Clicks
Why Firsts Matter
LLM Inflation
Comparing the Glove80 and Maltron keyboards
The LLM-for-software Yo-yo
The Fifth Kind of Optimisation
Better Shell History Search
Can We Retain the Benefits of Transitive Dependencies Without Undermining Security?
Structured Editing and Incremental Parsing
How I Prepare to Make a Video on Programming

Blog archive

Recently I added a seemingly trivial feature to my X11 terminal that has noticeably improved my programming productivity — I can click on filenames with line numbers (and column numbers when available) and have my editor jump to that file and line:

If, like me, you spend a lot of your life in a terminal, but like to use an editor in a different window, the benefits of this might be obvious. If you don’t, then the example might seem to reflect rather low ambitions on my part: why don’t I use an all-in-one editor1 that does all this when I press the “compile and run” button?

The answer is simple: I am already familiar with all the tools involved in this change. I already know the Unix terminal and I know how to run all sorts of compiler-ish commands in the terminal, many of which my editor will have no specific support for2. It might not be obvious from the video, but I even ran the Rust commands on a remote server with rsync_cmd! As that suggests, the commands I’m running have no idea that I’m making use of the filename output they produce.

The basic version is easily achievable in modern terminals such as Alacritty, but I needed something slightly more sophisticated, which is why I’ve written this post. As you probably noticed in the video, I have multiple editors open, and the “correct” thing happens in the “correct” editor instance.

Coordinating mouse clicks in different windows with the “correct” editor instance requires some sort of shared information — what I’m calling context. In my case the context I need is “what directory was the interactive shell in that terminal window running?”. Alacritty, at least, doesn’t currently support such context, but extending it to do so is easy, and makes what I want possible.

You can view what I’m about to explain in this post as a horrible hack, or you can view it as a pragmatic way of reusing what we know about existing tools. Personally, I don’t think “match filenames and open them in correct editor” is the only way the ideas here can be applied — I hope it might give others ideas to improve their productivity too!

Clicking on text in a terminal

I first saw someone3 hacking their terminal to allow mouse clicks to interact with an editor a decade or so ago — but it required a program (in this case a compiler) generating special output that the terminal recognised, but did not directly display. Because I switch between so many programming languages and commands, it didn’t seem feasible to alter all of them to do such a thing, let alone hack the terminal itself.

Modern terminals – I’ve been using Alacritty for a while now – have builtin support for this. Most commonly, many terminals (with little or no configuration) allow users to click on URLs printed by programs and have them open in the user’s web browser.

Alacritty – and I believe several other modern terminals – slightly generalises this. It allows the user to specify regular expressions; when matched text is clicked upon, a command of the user’s choosing can be executed.

In the demo at the start of this post, you saw me clicking on errors generated by rustc and cargo test, both of which print errors in the format /path/to/file.rs:line:col. We can match this text and make the resulting text run a shell command term_hint (we’ll get to it shortly!) with the following ~/.config/alacritty/alacritty.toml:

[[hints.enabled]]
regex = "[^ ]+\\.[a-z]+:\\d+:\\d+"
command = { program="term_hint", args=["rustcesque"] }
mouse = { enabled = true }

Alacritty calls this a hint, which is as good a name as any, so I’ll use that. term_hint is a bog standard shell program that I’ve written. It takes in two arguments: a style (in this case rustcesque) and hint text in that style. When I click on matching hint text, such as src/server.rs:12:9, the following command is run:

term_hint ruscesque src/server.rs:12:9

What I want term_hint to do is display src/server.rs in my editor and move the cursor to line 12, column 9. I use Neovim and neovim-qt as a GUI for it. Neovim has a “server” mode where one can write to a Unix socket file and control it “remotely”. I can run Neovim/neovim-qt in this style and create a socket file .nvim_server with:

nvim-qt -- --listen ./.nvim_server

[No, I don’t know why the ./ is necessary here, but without that Neovim doesn’t create a Unix socket file.]

I can then connect to that running instance of Neovim with --server .nvim_server and send it commands. I can tell that other Neovim instance to switch to server.rs with:

nvim --server .nvim_server --remote-tab src/server.rs

and go to line 12, column 9 with:

nvim --server .nvim_server --remote-send ":call cursor(12,9)<CR>"

We now have most of the bits we need to create a basic version of term_hint that is hard-coded to the rustcesque style (i.e. we’ll temporarily ignore the value in $1):

#! /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>"

With that simple script, we’re done, at least for our basic version! Just to recap, all I’ve done is: add a hint to Alacritty; start Neovim in server mode; and write a very basic term_hint script.

Terminal context

If I only used one editor instance for all my editing, the simple solution above would be sufficient — but I use lots of editor instances. Hard-coding /path/to/.nvim_server clearly isn’t going to work!

The problem with the basic approach is that the hint text passed to term_hint has no additional context: it doesn’t know which terminal window the text came from, or what was running in that window. In some cases, the hint text might allow me to work out which project it relates to, but what could I do if the hint text is something generic such as src/main.rs:4:2?

In order to deal with this problem, we need some additional context to augment the hint text. There’s no single answer to the question “what is sufficient context?” but for my use case it turns out that we need very little context. Specifically, all I want to know is: what directory was I working in when the hint text was generated? Since I run one Neovim per directory, that gives me all the context I will need.

Alacritty, as far as I know, doesn’t track which directory I’m currently running commands in. Fortunately, it’s easy for my interactive shell to record this, but I then need some way for that shell and a hint command to coordinate. The obvious way of doing this is for the two to have access to a shared bit of information.

Alas, Alacritty doesn’t provide suitable shared information — but it almost does! Alacritty sets several environment variables for the interactive shell, one of which is ALACRITTY_WINDOW_ID, a numeric identifier for the current window4. Unfortunately, it doesn’t set that variable when running hint commands such as term_hint. Fortunately, it’s very easy to make it do so. I thus raised a simple PR which sets this variable when running hint commands (alas, not yet accepted, though I blame myself for providing insufficient motivation and explanation).

We now have all the support from the terminal we need and we can decide how we make use of it. The way I’ve settled on is to use a cookie that records what directory a given terminal window is running commands in, and which the hint command can then make use of.

The first thing to do is to alter my .zshrc so that each time I change directory the cookie for this terminal window is updated:

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"
}

In essence, zsh calls chpwd whenever I execute cd <dir>. The sole change I have made is to add lines 3 and 4: I write the output of pwd (i.e. a directory name) to $HOME/.cache/term_hint/$ALACRITTY_WINDOW_ID. In other words, I have a directory with one file per terminal window, each recording the directory the terminal window is running commands in.

I can now adjust term_hint so that it reads the contents of the cookie, checks if there’s a Neovim instance running there by checking for the existence of a .nvim_server file:

#! /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>"

In other words, all that we needed for context was the ALACRITTY_WINDOW_ID variable and cookie files. There’s nothing particularly special about this route and I could have used other mechanisms to the same effect5.

Filename styles

As the final flourish, we can extend our Alacritty configuration and term_hint so that we can deal with filename formats such as that produced by Python. Let’s first add a second hint to Alcritty’s configuration:

[[hints.enabled]]
regex = "File .*, line .*"
command = { program="/home/ltratt/bin/term_hint", args=["pythonesque"] }
mouse = { enabled = true }

Notice that the regular expression is very different from that needed for Rust programs6: I am now passing pythonesque as the first argument to term_hint when the regular expression matches. I can then parse the different formats in term_hint thus:

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

As that suggests, it would not be difficult for me to cope with very different filename formats if I wanted to – well, assuming I can work out how to parse them with basic shell tools – such as the output of clang or gcc.

Finally, we can put that all together in one script:

#! /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>"

Of course, one could extend this basic idea to e.g. open an editor if one is not yet open in a different directory. Indeed, one can imagine different use cases that don’t involve filenames or editors at all!

Personally, though, I can’t believe how much of my life I’ve wasted alt-tabbing between a terminal and an editor, forgetting or getting wrong the filename and line number. Hint commands, particularly when extended with basic context, have reduced that cost to zero for me!

2025-10-29 13:45 Older
If you’d like updates on new blog posts: follow me on Mastodon or Twitter; or subscribe to the RSS feed; or subscribe to email updates:

Footnotes

1

Or the equivalent functionality in Neovim.

Or the equivalent functionality in Neovim.

2

For example, editors tend not to have built-in knowledge about the new languages I write.

For example, editors tend not to have built-in knowledge about the new languages I write.

3

Armin Rigo, one of the most astonishingly brilliant programmers I have ever encountered. As with several of Armin’s ideas, it’s taken me a long time to catch up.

Armin Rigo, one of the most astonishingly brilliant programmers I have ever encountered. As with several of Armin’s ideas, it’s taken me a long time to catch up.

4

A 64-bit integer. What this represents can vary between platforms (e.g. perhaps it’s a pointer, or a random number).

A 64-bit integer. What this represents can vary between platforms (e.g. perhaps it’s a pointer, or a random number).

5

My first prototype fished the directory name out of the terminal title using xprop!

My first prototype fished the directory name out of the terminal title using xprop!

6

And matches non-Python output too, but that’s not a problem.

And matches non-Python output too, but that’s not a problem.

Comments



(optional)
(used only to verify your comment: it is not displayed)