Raw terminal mode coming to OTP 28

For anyone who’s worked on a terminal interface in Elixir, you may be excited to know that raw mode is coming to OTP 28: Implement lazy-read and noshell raw mode by garazdawi · Pull Request #8962 · erlang/otp · GitHub

If you’re unfamiliar with raw mode, the gist of it is that it allows terminals to read input without waiting for a newline, allowing for much more responsive terminal UIs. (There are drawbacks as well: You are fully responsible for what is printed, you lose all cursor movement (unless you re-implement it), et al.)

As a quick demo, here’s a script you can run if you have Erlang/OTP master installed:

# elixir getch.exs

defmodule Getch do
  def run do
    # new incantation to switch the terminal to raw mode in OTP 28
    :shell.start_interactive({:noshell, :raw})
    loop(nil)
  end

  def loop("q") do
    IO.write("\rDone!")
    System.halt(0)
  end

  def loop(last) do
    if last, do: print(last)
    loop(IO.getn("Next: ", 1))
  end

  def print(last) do
    IO.write(IO.ANSI.format(["\r", "Got: ", :green, inspect(last), "\r\n"]))
  end
end

Getch.run()

If you run this, you’ll notice that the script immediately responds to each character press. If you were to remove the :shell.start_interactive({:noshell, :raw}) and run the script using OTP 27, you’ll find the behavior to be quite different.

I’m really excited about this and hope to eventually incorporate some of this into Mneme. It seems like you can switch between raw and cooked modes interactively with ease, so it should be possible to “progressively enhance” terminal interfaces by switching to raw mode where you would normally accept input and then back to cooked mode afterwards. A quick example of such switching (use “s” to switch between modes):

defmodule Getch do
  def run do
    :shell.start_interactive({:noshell, :raw})
    loop(nil, :raw)
  end

  def loop("q", _) do
    IO.write("\rDone!")
    System.halt(0)
  end

  def loop("s", :raw) do
    :shell.start_interactive({:noshell, :cooked})
    loop(nil, :cooked)
  end

  def loop("s", :cooked) do
    :shell.start_interactive({:noshell, :raw})
    loop(nil, :raw)
  end

  def loop(last, state) do
    if last, do: print(last)
    IO.write([IO.ANSI.clear_line(), "\r"])
    loop(IO.getn("#{state}: ", 1), state)
  end

  def print(last) do
    IO.write(IO.ANSI.format(["\r", "got ", :green, inspect(last), "\r\n"]))
  end
end

Getch.run()

I’m excited to play around with this more and am eager to see/hear about what others are doing! Would love to hear from folks who have also done TUI work in Elixir. Some that come to mind: @fuelen @ausimian @AndyL

29 Likes

Here’s what that last demo looks like:

getch

9 Likes

Whoa! Whistles and bells! I have to play with this :slightly_smiling_face:

7 Likes

Owl can get a lot more live now :slight_smile:

8 Likes

@zachallaun thanks for posting these demos! Ability to on-the-fly switch between raw and cooked mode is super nice.

Here’s a demo tweak to handle special keys:

defmodule Getch do
  def run do
    # new incantation to switch the terminal to raw mode in OTP 28
    :shell.start_interactive({:noshell, :raw})
    loop(nil)
  end

  def loop("q") do
    IO.write("\rDone!")
    System.halt(0)
  end

  def loop("\d"), do: loop(:backspace)
  def loop("\r"), do: loop(:enter)
  def loop("\t"), do: loop(:tab)
  def loop("\e"), do: loop(:escape)
  def loop("\e[A"), do: loop(:arrow_up)
  def loop("\e[B"), do: loop(:arrow_down)
  def loop("\e[C"), do: loop(:arrow_right)
  def loop("\e[D"), do: loop(:arrow_left)
  def loop("\e[F"), do: loop(:end)
  def loop("\e[H"), do: loop(:home)
  def loop("\e[1~"), do: loop(:home)
  def loop("\e[3~"), do: loop(:delete)
  def loop("\e[4~"), do: loop(:end)
  def loop("\e[5~"), do: loop(:pg_up)
  def loop("\e[6~"), do: loop(:pg_dn)

  def loop(last) do
    if last, do: print(last)
    loop(IO.getn("Next: ", 4))
  end

  def print(last) do
    IO.write(IO.ANSI.format(["\r", "Got: ", :green, inspect(last), "\r\n"]))
  end
end

Getch.run()

Anyone have a guesstimate for OTP28 release date?

2 Likes

The last three major releases (at least, that’s as far back as I looked) have been in May, so we still have a good half a year to go. The first RC releases in February though!

3 Likes

Does this mean iex can benefit from it and we can have vim bindings :slight_smile: ?

It’ll be ~4 years before Elixir exclusively supports OTP 28+. It may be possible to conditionally add certain IEx features, but the core team may not want a divergent experience, so I won’t personally hold out hope for that just yet. :slight_smile:

There certainly is a laundry-list of really neat shell features that become possible with raw mode, though! I’d love as-you-type completion options.

3 Likes

Custom IEx feature !
Good thing livebook has a vi mode :grinning:
What I would like for IEx to be perfect is a history fuzzy finder / session history per project / vi mode.
I hacked something with tmux + fzf 4 years ago that I’m still using today but the integration is not perfect
https://asciinema.org/a/532551

1 Like

Wow this looks neat! Does this mean that, in theory, a iex could allow a mode to navigate a large data structure, then go back to using iex as normal?

Possibly, yes.

I did a little bit of playing around and if you call :shell.start_interactive in a shell, you get {:error, :already_started}, so you can’t “swap” into raw mode without some additional control over what IEx is already doing. I believe Elixir 1.19 will be the first one to support OTP 28, so perhaps a discussion can be had about what changes could be made to support richer terminal interactions once 1.19 dev starts.

2 Likes

Oooh, this is really exciting! And it might allow improved IEx interfaces. I hope that GitHub - nhpip/iex_history2: An improved history for the Elixir IEx shell can start to use this even before Elixir requires OTP 28.

2 Likes

It will not be possible to switch into raw mode once you have started an shell managed by :group, this is because the shell does various things to the terminal that are difficult (not impossible) to restore.

iex uses the built-in line editor that comes with Erlang, so if you want to do changes to what happens before you press “Enter” in iex, you need to do those in :edlin. It is already today possible to configure the keymappings of :edlin to whatever you like, so maybe getting vi mode into iex is just a matter of creating a correct keymap to edlin. I’m only a casual vi user, so I don’t know what you would expect from a vi mode.

iex could now with raw mode go its own way and implement its own edlin if the team wants to, though IMO it would be better if all efforts are put into a single line editor so that everyone (Erlang, Elixir, Gleam, Luerl etc) profit.

10 Likes

Thank you so much for chiming in, @garazdawi! And for all the work that you’ve put into this over the last multiple releases.

Could you expand on this a little bit? I was originally thinking it could be really cool to be able to temporarily “take over” terminal/shell control and then yield it back after some interaction, similar to how that’s possible with group leaders. I’d be really curious to know what, if anything, would make that possible.

2 Likes

Thanks @zachallaun for this awesome demo!

When I saw this post I was working on a little terminal-based chat app to learn about nodes and clustering, so I decided to integrate “raw mode” into it! Check it out:

asciicast

I’d love any and all feedback anyone has (whether it’s feedback about how to do clustering/things with OTP or feedback about using raw mode). Thanks again for bringing this to my attention, super cool

1 Like