brhfl.com

Job Control

This is an old post from an old blog; assets may be missing, links may be broken, and my opinions may differ considerably by this point. Notably, I use zsh as my primary shell these days, which has out-of-the-box support for what I set out to accomplish here (setopt auto_continue).

If you haven't already, it's probably a good idea to read my previous post. The plan, of course, was to work on z, my shell script to assist me with multitasking and Unix job control. I am working on z, but while I was spending a lot of time thinking about z, I was spending just as much time implementing something additional. Two additional things, to be exact.

It's worth mentioning again that my shell of choice is fish1, and therefore everything that follows is written for fish. With that in mind, the first little trick I whipped up is a simple keybinding to foreground the last used job. I bind this to ctrl-a because I use home/end rather than emacs-style bindings anyway. Unfortunately, fish seems to load its default keybindings after it loads ~/.config/fish/fish.config, which means that I haven't figured out a good way to get this to attach itself on startup. Regardless,

bind \ca 'if test (count (jobs)) -gt 0 ; fg (jobs -lp) ; else ; echo -n \a ; end'

…does the trick. '\ca' can of course be substituted with any desired keybinding. Also, this sounds a bell if the user tries to execute it with no jobs to switch to - just cut out the 'else ; echo -n \a ;' to do away with this. The second thing I whipped up is, I warn you, a bit messy, a bit buggy, likely quite inefficient, and definitely should be considered in development. If a shell script would ever be thought of as 'beta,' this is that. I call it smartenter…

function smartenter -d "Running this doesn't make much sense. Bind it to enter or something!"
 set __se_cmd (commandline -o)
 set __se_params (count $__se_cmd)
 set __se_inca 0
 set __se_incb 0
 set __se_didmatch 0
 if [ $__se_params -eq 1 ]
  set __se_jobcmds (jobs -c)
  set __se_jobpids (jobs -p)
  for i in $__se_jobcmds
   set __se_inca (math $__se_inca+1)
   if [ $i = $__se_cmd ]
    for j in $__se_jobpids
     set __se_incb (math $__se_incb+1)
     if [ $__se_inca -eq $__se_incb ]
      set __se_didmatch 1
      echo
      fg $j
      commandline -r ""
     end
    end
   end
  end
  if [ $__se_didmatch != 1 ]
   echo
   eval $__se_cmd
   kill -s WINCH %self
   commandline -r ""
  end
 else
  echo
  eval $__se_cmd
  kill -s WINCH %self
  commandline -r ""
 end
end

#download it here instead of typing!

…and it only really works if bound to a key. So, I currently have it bound to enter (bind \n smartenter), but something like optenter (bind \e\n smartenter) would work as well. Now, as I said, it's not perfect, so I actually have opt-enter set to replace my standard enter behavior (bind \e\n execute) for when things go awry.

With that all said, what does smartenter do exactly? Keeping in mind that this is a crude early version, quite primitive, it first checks to make sure you're only passing it a single token. More than one token? Just execute the command like nothing ever happened. If there is only one token, however, it searches the jobs list for a match (an exact match). If it finds a match, it stops searching and foregrounds it. If it finds no match, it runs the given command.

What this means is that if I start a fresh shell and type 'fermat,' and hit enter just as though everything is normal, smartenter looks through the jobs list, sees that I'm not running fermat, and then runs it. Then, suppose I get bored with math, and ctrl-z out to get back to the shell so I can play a little Zork (I'm sensing a trend here). Upon being eaten by a grue, I decide I should enrich my brain some more, so I type 'fermat' in again, and hit enter as normal. Smartenter looks through the jobs, sees that I'm already running an instance of fermat, and foregrounds it. Pretty neat, eh?

But, as I mentioned, it is buggy and limited right now. For one thing, since I wanted a proof of concept, I just used a simple 'test,' which only matches exact results. Thus, since my $BROWSER needs to be set to '/usr/local/bin/elinks' rather than just 'elinks,' it shows up as such when I background an instance of the fish documentation (called up by 'help'). This means that if I want to switch to my fish documentation, I actually need to type in '/usr/local/bin/elinks' - just typing 'elinks' will spawn a new instance. So, soon I hope to implement fuzzy matching with grep.

Fish's 'eval' function has always behaved a little strangely for me, and this use is no different. Getting a prompt right away can be sticky, hence the WINCH signals. But even more odd is that certain things just execute in a peculiar fashion - I notice it now and have noticed it before when running 'dc,' for whatever reason, there is no local echo! So, since I sort of like to see what I'm entering into my calculator, I just run dc with opt-enter for now. Dc doesn't play nice with ctrl-z anyway, unfortunately.

If you have more than once instance of a given process running, smartenter foregrounds the first one. This is the first one in the jobs list - not the one with the lowest number, but the one that was most recently backgrounded. You cannot, therefore, use it to switch between two instances. If I'm running Zork in one instance of frotz, but more recently was playing Leather Goddesses of Phobos in another instance of frotz, I can't call Zork back up without manually checking the jobs list and foregrounding it myself. This is due to a few things. First, smartenter currently uses 'jobs -c' to just go through a list of commands. Now, 'jobs' on its own gives the full command, parameters and all. Thus, if I run 'jobs,' I'll see 'frotz -d leather' and 'frotz -d zork.' But, 'jobs -c' only gives the command, therefore all I'll see is 'frotz' and 'frotz.' Using '-c' was a quick and dirty way to make the exact matching work, and when I implement grep, I can also start to implement scanning the full command, parameters and all.

Now, doing such solves one issue - I could, for instance, just type 'zork' or 'leather,' and grep should find it. But, if I just type 'frotz,' it will still run the most recently used instance. This brings up a question of how to implement this user interaction. The first option is to leave it as is - just foreground the most recently used instance, and leave it to the user to either choose an alternative instance manually, or figure out a different fuzzy term to match their target. This, honestly, is probably the most sane approach. The second option is to prompt the user whenever there is more than one match. This seems viable, but it leads to the question of another prompt - if we're prompting the user anyway, we should give them the option of launching a new instance. But if we do that, we should also give them this option when there is even just a single instance running - and that could be a lot of prompting. The third option, and one that I may attempt to implement, would be using flags to control the behavior. These flags couldn't follow an existing standard, because that could (inevitably, would) lead to clashes. So something else would need to be chosen, much like a trailing & starts a process in the background. Two separate flags could exist - one to list and prompt, and another to automatically start a new instance, regardless of matches. While I don't think this is a terrible idea, I like the simplicity of the current form (and having a fallback - in my case, opt-enter - serves the second duty of spawning a new instance).

Finally, some things just behave a little strangely right now. I don't know much about error trapping in fish, so right now there is none. Thus, if you try to 'cd' to a nonexistent directory, rather than just getting the usual error, you get a lengthy complaint about an error in the fish function. Additionally, I've noticed a few things simply don't work, like calling 'funced.' I suspect this is because funced is fairly low-level shell stuff, and it has to spawn other low-level shell stuff like the fish indenter. Also, I need to quickly code in something to handle putting commands into the command history - right now they don't go in, and I realize that is a big problem. Bind also seems buggy at times, and isn't reliably setting \e\n for me, that one is rather tricky.

With all that in mind, I think that smartenter is a promising proof-of-concept for a modern paradigm in command-line multitasking. The result is much like clicking a dock icon (or running open) - if the program is not running, run it; if it is, switch to it. This isn't for everybody, I realize that. But in an effort to keep a simple yet sane multitasking CLI environment running, it's certainly a good direction for me.


  1. No longer. Some time around 2014 I became a full-on zsh convert. First step toward settling on bash, ca. 2019.