Day 23: Hard to kill

Though yesterday I added the first TUI interface to Séance, which was a satisfyingly visceral bit of progress, there was a problem with that implementation: it was very annoying to exit.

If you’ve used the command line for any length of time, you will become acustomed to certain aspects of its behaviour that seem universal. Chief among these is that if you want to close a program you can press Ctrl + C. Despite appearances, this behaviour isn’t actually universal. If your terminal is running in raw mode (which isn’t actually a single mode, but a series of conventional separate flags that a program sets) then this behaviour is disabled.

Like most TUI apps Séance runs in raw mode, meaning that as previously written I was unable to exit it as I’d expect; I had to close the terminal each time I ran it. Working around this isn’t too hard, but requires modifying the core event loop somewhat.

Under the previous system, the loop came in three main phases: the agent processes events and creates actions, the I/O driver translates actions into underlying I/O operations, and then the whole thing waits until one of those I/O operations completes. This avoids busy waiting, since the entire program can be suspended by the OS while waiting on I/O. In order to process the Ctrl + C call, the waiting step needs to be watching for key inputs as well.

First we need to start reading events from the terminal. For this, I’ll use Crossterm, the cross-platform terminal library that I have also configured Ratatui to use. The current I/O is done by waiting on a Future, so to wait on terminal events, I need them also to produce a future. Crossterm supports through its EventStream API.

let mut events = EventStream::new();

Wakeup! Grab a brush and put a little makeup

Now comes the slightly trickier bit: how do we combine the two futures? There are multiple options here. The one closest to hand is the select! utility exposed by Tokio, the async runtime I am using, though it would require enabling the macros feature, which I don’t otherwise use. That macro is also quite powerful and therefore complex, and so feels like overkill for what I need. Instead, I’ll be using a utility exposed by futures_lite.

Though more closely associated with smol, it works perfectly well alongside Tokio. smol is aesthetically very much my jam, being a project focused on minimalism and customization. I’d likely be using it or some of its child crates instead of Tokio were it not for my dependency on reqwest. For the simple use case of combining two futures and polling both the future::or function will serve nicely. Note that because this function returns a single future, the two futures it combines must have the same type. This is natural, because we need some method to be able to distinguish which one we actually got. I’ve introduced a new Wakeup enum that represents this.

use futures_lite::future;

// ...

#[derive(Debug)]
enum Wakeup {
    Event(Option<io::Result<Event>>),
    Message(Option<Message>)
}

// ...

let next_event = events.next();
let next_message = message_rx.recv();
let wakeup = future::or(
    async move { Wakeup::Event(next_event.await) },
    async move { Wakeup::Message(next_message.await) }
).await;

match wakeup {
    Wakeup::Event(Some(Ok(Event::Key(e)))) if is_ctrl_c(e) => {
        agent.receive(Message::ControlC, &mut world);
    },
    Wakeup::Event(Some(Ok(_))) => (),
    Wakeup::Message(Some(message)) => {
        agent.receive(message, &mut world);
    }
    other => todo!("wakeup: {other:?}")
};

As desired, it is now possible to close Séance without closing the full terminal.