A simple thing

Published on Tue Jun 8 15:55:28 CEST 2021. Last updated on Tue Jun 29 15:45:57 CEST 2021.

As I've been setting up NetBSD on my ThinkPad T41, I've found myself wanting a small piece of functionality:

I want my xterm window titles to reflect the current state of the shell running in the terminal. Things like the working directory and the running process.

A simple thing, right? So I thought too. But the problem is more complicated than it looks.

The solution I've finally settled on requires modification of the source of the shell [1] as well as a custom program that communicates with the shell, continuously updating the title of the relevant xterm window [2]. To understand why this is the best solution, we need to go through and dismiss the alternatives.

Terminal control codes

There are two ways of changing the title of a terminal window: either via X11 or via terminal control codes.

Terminal control codes have many benefits:

  1. As they are sent directly to the terminal (emulator), there is no risk that accidentally affect the wrong window.
  2. They can be sent by the shell's built-in echo command, reducing the latency of running an external command.

If you search the internet for ways to set the terminal title to the current working directory, you'll find many suggestions along the following lines:

case $SHELL in
*bash)	PS1='\[\033[2;\w\007\]'$PS1 ;;
*ksh)	PS1='^A^M^A^[[2;$PWD^G^A'$PS1 ;;
*)	... ;;
esac

This sends the CSI 2 ; <title> BEL control sequence to the terminal at every prompt, which instructs it to set the window title [3].

This works fine. Except for a few things:

  1. Not all terminal emulators support this control sequence. NetBSD's wscons, for example, is completely broken by it.
  2. GNU screen receives the sequence, but doesn't forward it to the real terminal by default.

None of these points are big problems on their own. You can check $TERM to identify wscons. You can tell screen to forward the control sequence by adding a ESC P prefix a ESP \ suffix to it.

The problems start when you try to combine them. If you tell screen to forward the control sequence, it might forward it to wscons. This will not be caught by the $TERM check if the screen session was started somewhere else than wscons.

For example, I was working on my fork of the jwm window manager [4] in a screen session in xterm. I realized that I needed to restart X in order to try out my changes, so I detached from the screen session and quit X.

Back in wscons, I realized that I still needed to compile my changes. So I re-attached to the screen session and entered make. Everything went well until my shell's prompt showed up again. Suddenly, typed characters weren't being displayed on the screen anymore and there didn't seem to be any way to fix it.

In my shrc, I made sure that $XTERM_SHELL was set before I sent the control sequences, but because the screen session was started in xterm, $XTERM_SHELL was set, even though I was attached to the session in wscons and not xterm.

In other words, I needed some way to tell which terminal I was really in.

Turns out there isn't any obvious way to do that. The closest thing to a solution is to write a certain control sequence to the terminal, which asks it for its secondary device attributes. These will be different for different terminals. I don't know if they're guaranteed to be unique, but they work well enough to distinguish one terminal from another.

Combine that with another problem that I've kept hidden from you until now. I said that you can tell screen to forward control sequences to the parent terminal. Well, it turns out you don't want to do that if you're not inside GNU screen.

My solution to these problems is called safetitle [5]. It is a C program that tries to figure out the identity of the terminal based on its secondary device attributes. If the identified terminal is blacklisted (wscons), it exits. Otherwise, it sends the control sequences that set the terminal title. But don't forget: if the terminal is identified to be GNU screen, it also sends the special forwarding control sequences.

Ultimately, though, I was not entirely satisfied. Not only was safetitle a hack, but because it required forking a child process at every prompt, it was a bit slow.

On the flip side, it could be made better by only using it when $TERM is set to screen, and using the traditional method otherwise. Furthermore, just the fact that it supported GNU screen was pretty great, and I would probably have kept using it if it weren't for the next section of this story.

Identifying the running process

Barring the unexpected complications described above, setting the terminal title to the current working directory is easy enough. It is far more difficult to set the terminal title to the currently running program.

The first problem is terminological. What does one even mean by the currently running program? One probably doesn't mean the shell itself, which is the obvious answer to the question; one probably means something like the program running inside the shell.

The problem with this is that the shell can run many programs at once. Take a look at the following process tree:

 269 ttyE0 S    0:01.51 xterm
 977 ttyp0 Ss   0:00.04 - ksh
 808 ttyp0 T    0:00.02 |-- vi Makefile 
1204 ttyp0 T    0:01.42 |-- vi a-simple-thing.em 
9465 ttyp0 0+   0:00.01 `-- vi ps -d 

xterm is running ksh, which is running three processes simultaneously. Who is to say which is the "correct" currently running process?

One solution is to count the youngest process as the currently running one. The following shell script does exactly that. Given a pid, it prints the pid of the corresponding process's youngest grandchild:

ps -do pid,etime,comm -k etime $opt |
pid=$1 perl -lane '
        if (/^ *$ENV{pid} / .. 1) {
                next if /^ *$ENV{pid} /;
                last if not /[|`-]/;
                $t = 0; $i = 0;
                $t += $_*(60**$i++) for reverse split /:/, $F[1];
                print "$t $_" if $t > 2;
        }
' |
sort -n |
head -1 |
cut -d\  -f2

Conceivably, one could check the output of the script every so often and set the title of the corresponding xterm window accordingly.

The problem with this naive solution is that the age of the process doesn't reflect whether it is currently active in the shell. The only source of this information is the shell itself.

So perhaps the shell can set the window title whenever it starts or continues a process. But looking around at the popular shells, the landscape is rather dry.

The Z shell is the only shell which properly supports something similar. It offers the precmd and preexec functions [6]:

precmd
called before every prompt
preexec
called before the execution of every command

It is possible to simulate preexec in Bash, but it is a hack at best: the special DEBUG signal handler, if set, will be triggered before every command executed by Bash.

To summarize, something like this would work:

preexec() { safetitle "$3" }
case $SHELL in
*bash)	trap 'preexec ${BASH_COMMAND%% *} $BASH_COMMAND $BASH_COMMAND' DEBUG ;;
esac

There are, however, a number of problems with it:

  1. I don't like the Z shell.
  2. I don't like Bash.
  3. The DEBUG handler is called even for non-interactive commands.
  4. fg sets the window title to fg instead of the resumed command.

All of these were real, weighing issues to me. If only there were a better way to do it -- one that would work with the shell of my choice, the Korn shell.

An alternative approach

The Korn shell being open source, I started trying to modify it. I wanted to implement the following functionality directly in the shell itself:

  1. Set the title when the current working directory (cwd) changes.
  2. Set the title when an interactive shell command is executed.
  3. Set the title to the resumed command when fg is executed.

Unfortunately, I ran in to a big problem: I couldn't find any way to have the shell itself send the terminal control sequences without breaking something. I'd get it working until I tried running ed, at which point typed characters would stop being displayed.

The underlying issue is that the terminal is difficult to work with. Different terminals support different control sequences, and the current state of the terminal is hard to reason about.

So I scrapped all of my previous work and started thinking about what a more reliable solution might look like. After a good night's sleep, I came up with this:

  1. Modify ksh to print cwd changes and command evaluations to a named pipe.
  2. Create a program that reads from the named pipe and sets the window title of the corresponding xterm window via the X11 API.

My initial draft looked something like this:

#!/bin/sh
rm /var/$$.session 2>/dev/null
mkfifo /var/$$.session
xterm -e ksh -w /var/$$.session &
pid=$!
xdotool search --sync --pid $!
cat /var/$session | while read ln; do
	case "$ln" in
	cwd*)	cwd=${ln#cwd}
		xdotool --pid $! set_window --name "$cwd" ;;
	cmd*)	cmd=${ln#cmd}
		xdotool --pid $! set_window --name "$cmd ($cwd)" ;;
	*)	xdotool --pid $! set_window --name "$cwd" ;;
	esac
done

The script creates a named pipe and launches ksh in xterm, telling it to read from the named pipe. Then, it waits for the window associated with the pid of the launched xterm process to appear. Once the window appears, the script starts reading from the named pipe, updating the title on every line read.

This initial shell script didn't work, because xdotool is rather unreliable when it comes to setting window titles. But I knew it was possible, because I had written a small C program myself that could set the window title.

A reliable solution

Long story short, the approach described above was successful.

I patched [1] NetBSD's ksh to support a new option called -w, which expects the name of a file. If provided, the shell opens the file for writing and then, whenever either an interactive command is issued, the prompt is written or the cwd of the shell is updated, a single line is written to the file. The line has the following format:

before prompt
an empty line
on startup
"cwd" followed by the working directory
after cwd change
"cwd" followed by the new working directory
after interactive command compilation
"cmd" followed by the typed command
after fg
"cmd" followed by the resumed command

The second component, tterm [2], is what wraps it all together. Upon execution, it creates a named pipe on the file system, identified by tterm's own pid. Then, it immediately forks.

The child process execs itself into the following command line:

xterm -e /usr/local/bin/ksh -w <path to named pipe>

Meanwhile, the parent process starts looking for a window belonging to the pid of the child process. Once such a window is found, it starts reading from the named pipe.

For every line read, it sets the title of the found window according to the following rules:

cwd
<cwd>
cmd
<cmd> (<cwd>)
empty line
<cwd>

For example, it sets the title to cwd at startup, which is probably your home directory, so it sets it to ~. Then, when you run vi todo, it sets the title to vi todo (~). Finally, when you exit vi, it sets the title back to ~.

Summary

In summary, I've traveled the world around in search of a solution to a very simple problem. Ultimately, the solution turned out to be pretty simple as well.

It only has a single problem: it doesn't work with GNU screen. While it wouldn't strictly be impossible to make it work, it would require a lot of effort. In the meantime, I'm happy to fall back on safetitle when $TERM is set to screen:

case $TERM in
screen*) PS1='$(safetitle "$PWD")'$PS1 ;;
esac

Other than that, I am very pleased with tterm and my patched version of ksh.

Window titles are especially dear to me, as I use them to figure out the current working directory in a script called dwim [7], which emulates the Plan 9 plumbing system on UNIX. It is an extremely useful script that I couldn't live without.

In the end, I hope this page can serve as a resource for those who have been looking for the same answer as me. I've spent a decently large amount of time researching this topic, so be sure to send me an e-mail [8] if there's something you're wondering.


References

  1. http://git.ankarstrom.se/ksh/
  2. http://git.ankarstrom.se/x11/tterm/
  3. https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h4-Operating-System-Commands:OSC-Ps;Pt-ST.E65
  4. http://git.ankarstrom.se/jwm/
  5. http://git.ankarstrom.se/safetitle/
  6. https://zsh.sourceforge.io/Doc/Release/Functions.html
  7. http://git.ankarstrom.se/dwim
  8. mailto:john(at)ankarstrom.se