Rawr
Do you ever think about what exactly happens when you press the cursor left or arrow left key in a terminal? What happens when you press arrow up? What when you press the letter "e"? Well, raw mode in a terminal will make you think about all of this and much more. In the famous words of any Souls game: "beware, despair ahead".
I am a bit exaggerating here (but only a bit). Once the basic loop is set up, things are pretty much okay as is. I have to admit I cheated a bit and used golang.org/x/term. I could have gone a level lower than that and used golang.org/x/sys. But when actually looking at what happens behind the scenes there are two very likely outcomes: I either copy and paste the Linux specific code, missing out on a lot of cross platform compatibility or I have to refresh on way more assembler than I ever wanted to again. Once the shell is done and I have a lot more spare time I might reconsider this and start pulling out these two dependencies, but not today.
Before jumping into raw mode I added a history. So far it is only a slice of strings and two helper methods to get the next or previous item when pressing arrow up or down. I opted to have the newest element at the beginning of the slice. We can argue about the runtime of an insert now being O(n) instead of O(1) and that is fair. But it is also only a temporary solution. Long term I want to move the history, either to a SQLite database or another datastore that multiple instances of the shell can share to keep the history synchronised, without reading and parsing the same history file over and over again.
Raw mode
Raw mode - and I am certain this definition does not hold up when you throw a text book at it - boils down to you literally doing everything in existence yourself. Capture a keystroke, print it, handle backspace, everything that happens is now on you. Including not messing up the cursor index...
ts, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
fmt.Fprintln(os.Stderr, "Error enabling raw mode:", err)
return
}
l.termState = ts
defer term.Restore(int(os.Stdin.Fd()), l.termState)
The defer part here is important. You are not just setting your shell, but your terminal into raw mode. Which means you always want to restore the state, otherwise a panic, or exit leaves your terminal in a state with no Ctrl+C, no visible output of characters you type and generally a really bad time.
From here on things are as simple as reading key presses, printing them, waiting for a \r\n and then running the command. Important here is restoring the term state and going back to raw mode after the command completed. Otherwise apps like neovim would inherit our terminal state and would not be much fun to use (aka: they would be broken and not work at all).
We first check if we read one or three bytes. One byte might be the enter key, "a" or backspace. Three bytes are important to support arrow keys. They are being sent as an escape sequence. Byte 1 is the escape character, byte 2 is [ (control sequence introducer in the ANSI / VT100 standard), byte 3 is the actual identifier for the key where 68 does not stand for the letter D but for arrow left.
Browsing the history with arrow keys basically means keeping track of the index in history to know if it needs to be incremented (arrow up) or decremented (arrow down) and then returning the command. We set the input buffer to the command and refresh the whole terminal to make sure it prints properly.
Arrow left and right need to keep track of a cursor index so we can actually edit a command or path. This was the last part I added this week and I think I am getting to a point where I want to move the state of the command line to a separate structure, the main structure is getting a bit crowded and overused.
Progress
There are two things missing I want to get in before starting to daily drive this shell. First is tab completion for paths. This one should be fairly straight forward. I am curious if it might be a fun exercise to add tab completion to individual commands, but this might get a bit more than I can get in this week. Second is redirecting outputs, something I use too often and that would be really annoying if I could not do that anymore.
After that there are a few things I am considering. Adding a bit more fun to the prompt, like showing the current git working tree or if I am in a Python project using uv. Maybe a web endpoint to redirect the output of a command to. I find myself redirecting the output to a file and then running python -m http.server on a regular basis, especially when SSH-ing into a remote system from my phone. There are a few more things that might be fun, but I really did not spend enough time to think through them if they are worth the effort yet.
posted on June 14, 2026, 7:31 p.m. in golang, lazerbunny