BackBack to Portfolio

Building terminal apps from Scratch

By Sahaj Bhatt

Not every terminal app needs a TUI framework. Frameworks like Bubbletea or OpenTUI are powerful, but often overkill for rendering a simple list or tracking basic state.

In this guide, we're going raw. We're gonna understand the basics, hijack the terminal, capture user input, see rendering methods, and build a dual-panel file browser from scratch.

3 types of Terminal Apps

  1. CLI (One-Shot): It computes data, dumps it to the screen with some pretty colors, and instantly exits. It's not interactive.
    Example: ls, git status, etc.

  2. Input-Driven TUI: Only re-renders/updates when the user presses a key.

    Example: sahaj-b/go-attendArrow Up Right go-attend gif

  3. Concurrent TUI: Has an independent render/event loop while simultaneously listening for input on a separate thread. Used when you don't want to wait for input to update the UI (eg: loading spinners, live monitors, resize events).

    Example: sahaj-b/sound-of-sortArrow Up Right, fzf, htop, etc. sound-of-sort gif

ANSI Escape Codes

These are the raw commands you send to the terminal to control cursor movement, colors, and screen clearing. They start with the ESC character (\x1b or \033)

Examples:

Handling Colors

There are a many ways to handle ANSI colors in code (structs, wrapper functions, method chaining, etc) But the simplest, most frictionless way is just declaring them as string variables:

yellow := "\033[33m"
bggray := "\033[100m"
reset := "\033[0m"
fmt.Println(yellow + bggray + "hello" + reset)
Note

Be a good citizen and respect the NO_COLORArrow Up Right environment variable. If os.Getenv("NO_COLOR") != "" is true, just set all your color variables to an empty string "". This instantly strips all color formatting

Cooked vs Raw Mode

By default, your terminal is in "cooked" (canonical) mode.
In this mode, when you type anything, it does NOT write to stdin(standard input) until you press Enter (it buffers input).

In TUIs, we want to react to every keystroke immediately, without waiting for Enter. So we need to switch the terminal to "raw" mode. It disables input buffering, echoing input, and line editing features. You get direct access to every key press as it happens.

Important

In raw mode, \n only moves the cursor down, not to the start of the next line. Use \r\n for a proper newline.

Rendering Methods

When you update the screen, you have two core philosophies. If you want the user to still see their previous terminal history (like go-attend, fzf --height 40%, Claude Code), you use Inline Rendering. If you want a full immersive app(like sound-of-sort, htop, fzf) that takes over the terminal, you use Fullscreen Rendering.

Method A: Inline Rendering (Keeping Terminal History)

Instead of clearing the whole screen, you only clear the lines your app is actively using. To do this, we can count how many lines we printed in the last frame(count \r\n), and on the next frame, move the cursor up by that many lines(using \033[%dA), clear down (\033[J), and redraw.

Note

We can also use the Save & Restore method (\033[s and \033[u) to jump back to a specific cursor position, but it has a fatal flaw.

When the terminal scrolls (either manually by user, or because your output exceeds the terminal height), the remembered coordinates doesn't move up with the text.
So when you restore, you end up drawing out of bounds, screwing up the UI.

Note: Inline rendering is extremely sensitive to line-wrapping and terminal scrolling. We'll see that in the Responsiveness section below.

Method B: Fullscreen/Altscreen Rendering

This is basically clearing the entire screen everytime before you draw. And, if you dont want to mess with the user's terminal history (scrollback), you can use the Alternate Screen Buffer (recommended).
Activate it using \033[?1049h. This completely separates your app from the user's history. When you exit, you send \033[?1049l to return them exactly where they were. This is what most TUIs like vim, htop, opencode do.
You still clear the screen, but it happens in this isolated buffer.

Note

There's also Differential Rendering (Double Buffer), which is what frameworks like Bubbletea and OpenTUI use. Instead of redrawing the entire screen, it compares the Current Frame and the Next Frame.
It then generates exactly the right cursor movements (\033[Y;XH) and ANSI codes to update only the specific characters that changed. It can be both inline or fullscreen. It's like React's Virtual DOM diffing, but for terminal cells.

  • Pros: Absolute maximum performance and no flickering. It sends the minimum number of bytes to stdout.
  • Cons: Complex to implement from scratch, overkill for simple apps

Responsiveness in Terminal UIs

Terminal UIs are responsive too, just in a more cursed way than the web.

Width Overflow (Line-Wrapping)

When a line exceeds the terminal width, the terminal automatically wraps it to the next line. This is a problem because your app thinks it's printed one line, but visually it takes up two lines.

If you are using Inline Rendering, this will screw up the rendering. You don't know if the lines got wrapped, so when you move the cursor up to redraw, you might be moving up too few lines, resulting in a shredded, duplicate UI.

line-wrapping-problem img

The Fix: Clamp your strings' visible length to the terminal width before drawing (recommended), or implement wrap detection logic

Tip

len(str) won't give you the visible length if you have ANSI codes or multi-byte unicode chars in your string. Use github.com/acarl005/stripansiArrow Up Right with RuneCount like this:

func visibleLen(s string) int {
  return utf8.RuneCountInString(stripansi.Strip(s))
}

If you are using multi-width unicode characters (eg: CJK), use github.com/mattn/go-runewidthArrow Up Right instead of RuneCount

Note

Why aren't we using the ANSI code(\033[?7l) to disable line wrapping instead?
Because it behaves differently: once the text reaches the end of a line, any extra characters will replace the last character, which makes the end of that line look funky.

Height Overflow

If your app tries to print more lines than the terminal has height, the terminal will physically scroll down to make room.

Now, when you move the cursor up to redraw, it will stop at the top of the terminal, not the top of your app. So you end up with a shredded UI and a messed up scrollback history.

The Fix: Clamp your output's total height to the terminal height. If you have UI chrome like a status bar, that needs to count too.

Terminal Resize

If the user resizes the terminal, your app needs to adapt immediately. In Unix-like systems, the terminal sends a SIGWINCH signal on resize. Listen for SIGWINCH, then re-read terminal size, recompute panel widths/heights, and redraw immediately.

If you care about Windows too, use other methods like polling terminal size, using a library, or using platform-specific syscalls.

Building the app

Enough theory, we'll now make a simple dual-panel, inline-rendered file browser that shows the contents of the current directory on the left, and the contents of the selected directory on the right.

We'll use Go's cross-platform term library (golang.org/x/term) for terminal manipulation (setting to raw mode and getting terminal size).
We can absolutely do it manually with stty commands on Unix-like systems, for e.g. see go-attend's initScreen functionArrow Up Right for manual raw mode setup. But using the library is just simpler and portable.

Step 0: Preparing the Terminal

We'll switch to raw mode and hide the cursor

import (
    "os"
    "golang.org/x/term"
)
 
func initUI() (func(), error) {
    // save current state of the terminal, and switch to raw mode
    oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
    if err != nil {
        return nil, err
    }
 
    fmt.Print("\033[?25l") // hide cursor
 
    // return a cleanup function to run on exit
    return func() {
        term.Restore(int(os.Stdin.Fd()), oldState) // restore the old terminal state
        fmt.Print("\033[?25h") // show cursor
    }, nil
}

Step 1: The App Struct

Holds the app's state, and has methods (receiver functions) for rendering, updating, etc.

type App struct {
    currentDir        string
    entries           []os.DirEntry
    cursorIdx         int // position of selected entry
    scrollOffset      int // for scrollling entries
    rightPaneEntries  []os.DirEntry
    width             int // app's current width
    height            int // app's current height
    lastRenderedLines int // track lines to move cursor UP
    showRightPane     bool
}

Step 2: Rendering

We'll use strings.Builder to efficiently construct frames, as it avoids repeated string copying inside loops, unlike +=, which creates new strings each time. For small, infrequent concatenations, plain + is fine though.

At the very start, we move the cursor up by lastRenderedLines and clear down.

const (
    maxHeight = 20
    maxWidth  = 80
    yellow    = "\033[33m"
    cyan      = "\033[36m"
    reset     = "\033[0m"
)
 
func (a *App) render() {
    var b strings.Builder
 
    // 1. move cursor UP by last frame's height, then clear down
    if a.lastRenderedLines > 0 {
        b.WriteString(fmt.Sprintf("\033[%dA\033[J", a.lastRenderedLines))
    }
 
    linesPrinted := 0
    panelWidth := a.width
    if a.showRightPane {
        panelWidth /= 2
    }
 
    bodyHeight := max(a.height-1, 0) // max entries to show at once
 
    // 2. draw rows
    for i := range bodyHeight {
        idx := a.scrollOffset + i
        leftStr, rightStr := "", ""
        if idx < len(a.entries) {
            leftStr = entryLabel(a.entries[idx], idx == a.cursorIdx) // get formatted entry string
        }
 
        row := clamp(leftStr, a.width)
        if a.showRightPane {
            if i < len(a.rightPaneEntries) {
                rightStr = a.rightPaneEntries[i].Name()
            }
            safeLeft := clamp(leftStr, panelWidth-3)
            safeRight := clamp(rightStr, panelWidth-3)
            // compute padding from visible width
            padding := max(panelWidth-3-visibleLen(safeLeft), 1)
            row = clamp(fmt.Sprintf("%s%s %s│%s %s", safeLeft, strings.Repeat(" ", padding), cyan, reset, safeRight), a.width)
        }
 
        b.WriteString(row + "\r\n")
        linesPrinted++
    }
 
    // 3. status bar
    statusBar := fmt.Sprintf("\033[36mPath: %s\033[0m", a.currentDir)
    b.WriteString(clamp(statusBar, a.width) + "\r\n")
    linesPrinted++
 
    a.lastRenderedLines = linesPrinted
    fmt.Print(b.String())
}
 
func (a *App) updateLayout() { // set layout based on current terminal size
    termwidth, termheight, _ := term.GetSize(int(os.Stdin.Fd()))
    // clamp width to terminal width to avoid line wrapping
    a.width = min(termwidth, maxWidth)
    a.height = min(termheight-1, maxHeight)
    a.height = max(a.height, 1)
    a.showRightPane = termwidth >= 80
}

Step 3: Handling Input & The Event Loop

In raw mode, arrow keys are escape sequences (Up is \033[A). Because we are building an input-driven app, our event loop is dead simple: draw the screen, block until the user presses a key, update state, and repeat.

func (a *App) readKey() string {
    buf := make([]byte, 3) // 3 bytes coz arrow keys are 3 bytes.
    n, _ := os.Stdin.Read(buf)
    return string(buf[:n])
}
 
func (a *App) handleKey(key string) bool {
    switch key {
    case "q", "\x03": // \x03 : Ctrl+C
        return false // exit
    case "\033[A", "k": // \033[A : Up Arrow
        a.moveCursor(-1)
    case "\033[B", "j": // \033[B : Down Arrow
        a.moveCursor(1)
    case "\r", "\n": // Enter
        a.enterDirectory()
    }
    return true // continue
}
 
func main() {
    restore, err := initUI()
    if err != nil {
        panic(err)
    }
    defer restore() // restore terminal state on exit
 
    app := loadApp(".") // this will return an *App with `entries` populated with current dir
    app.updateLayout()
 
    // redraw -> wait for input -> update state -> redraw -> ...
    for {
        app.render()
 
        key := app.readKey()
        if !app.handleKey(key) {
            break
        }
    }
}
Warning

Bulletproofing against Pipes (/dev/tty vs os.Stdin)

When you pipe data into your app (e.g. cat data.txt | yourapp), os.Stdin becomes the pipe, not the user's terminal. If you try to set raw mode on a pipe, it will fail and crash your app.

The Fix: Use /dev/tty instead. It's a magical Unix file that always points to the actual screen and keyboard, bypassing standard input entirely. This is how tools like fzf can read piped data while still letting you type and navigate.

tty, _ := os.OpenFile("/dev/tty", os.O_RDWR, 0)
// now replace all `os.Stdin` with `tty`
term.MakeRaw(int(tty.Fd()))
// ...
term.GetSize(int(tty.Fd()))
// To read from pipe, use `os.Stdin.Read()`, to read keyboard input, use `tty.Read()`

No one is piping data into our file browser, so we can get away with os.Stdin for simplicity.

Here is the complete, runnable code for the inline-rendered file browser without SIGWINCH handling. To run it, copy it into a main.go file, run go mod init tui, go mod tidy (to fetch x/term and stripansi), and run it.
(This is not production code. Error handling, edge cases, and NO_COLOR are ignored for brevity)

package main
 
import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
    "unicode/utf8"
 
    "github.com/acarl005/stripansi"
    "golang.org/x/term"
)
 
const (
    maxHeight = 20
    maxWidth  = 80
    yellow    = "\033[33m"
    cyan      = "\033[36m"
    reset     = "\033[0m"
)
 
type App struct {
    currentDir        string
    entries           []os.DirEntry
    cursorIdx         int
    scrollOffset      int
    rightPaneEntries  []os.DirEntry
    width             int
    height            int
    lastRenderedLines int
    showRightPane     bool
}
 
func initUI() (func(), error) {
    oldState, err := term.MakeRaw(int(os.Stdin.Fd())) // switch to raw mode
    if err != nil {
        return nil, err
    }
    fmt.Print("\033[?25l") // hide cursor
    return func() {
        term.Restore(int(os.Stdin.Fd()), oldState)
        fmt.Print("\033[?25h") // show cursor
    }, nil
}
 
func visibleLen(s string) int {
    return utf8.RuneCountInString(stripansi.Strip(s))
}
 
func readEntries(dir string) []os.DirEntry {
    files, _ := os.ReadDir(dir)
    // extra nil first row, representing the parent directory ('..')
    return append([]os.DirEntry{nil}, files...)
}
 
func loadApp(dir string) *App {
    absPath, _ := filepath.Abs(dir)
    app := &App{
        currentDir: absPath,
        entries:    readEntries(absPath),
    }
    app.updateRightPanel()
    return app
}
 
func (a *App) updateRightPanel() {
    if len(a.entries) == 0 {
        a.rightPaneEntries = nil
        return
    }
 
    selected := a.entries[a.cursorIdx]
    // only directories can be previewed
    if selected != nil && !selected.IsDir() {
        a.rightPaneEntries = nil
        return
    }
 
    dir := filepath.Join(a.currentDir, "..")
    if selected != nil {
        dir = filepath.Join(a.currentDir, selected.Name())
    }
    a.rightPaneEntries, _ = os.ReadDir(dir)
}
 
func (a *App) updateScroll() {
    bodyHeight := max(a.height-1, 1)
    if a.cursorIdx < a.scrollOffset {
        a.scrollOffset = a.cursorIdx
    }
    if a.cursorIdx >= a.scrollOffset+bodyHeight {
        a.scrollOffset = a.cursorIdx - bodyHeight + 1
    }
}
 
func (a *App) enterDirectory() {
    if len(a.entries) == 0 {
        return
    }
 
    selected := a.entries[a.cursorIdx]
    if selected != nil && !selected.IsDir() {
        return
    }
 
    if selected == nil {
        a.currentDir = filepath.Join(a.currentDir, "..")
    } else {
        a.currentDir = filepath.Join(a.currentDir, selected.Name())
    }
    a.entries = readEntries(a.currentDir)
    a.cursorIdx, a.scrollOffset = 0, 0
    a.updateRightPanel()
}
 
func (a *App) updateLayout() {
    termwidth, termheight, _ := term.GetSize(int(os.Stdin.Fd()))
    // clamp width to terminal width to avoid line wrapping
    a.width = min(termwidth, maxWidth)
    a.height = min(termheight-1, maxHeight)
    a.height = max(a.height, 1)
    a.showRightPane = termwidth >= 80
}
 
func clamp(s string, maxLen int) string {
    width := visibleLen(s)
    if width > maxLen {
        if maxLen > 3 {
            // ANSI codes are kept in the string, so trim runes after measuring visible width.
            runes := []rune(s)
            return string(runes[:maxLen-2]) + "…"
        }
        return string([]rune(s)[:maxLen])
    }
    return s
}
 
func entryLabel(entry os.DirEntry, selected bool) string {
    prefix := "  "
    if selected {
        prefix = yellow + "❯ "
    }
    if entry == nil {
        return prefix + cyan + ".." + reset
    }
    return prefix + entry.Name() + reset
}
 
func (a *App) render() {
    var b strings.Builder
 
    if a.lastRenderedLines > 0 {
        // go UP and clear down to erase previous render
        b.WriteString(fmt.Sprintf("\033[%dA\033[J", a.lastRenderedLines))
    }
 
    linesPrinted := 0
    panelWidth := a.width
    if a.showRightPane {
        panelWidth /= 2
    }
    bodyHeight := max(a.height-1, 0)
 
    for i := range bodyHeight {
        idx := a.scrollOffset + i
        leftStr, rightStr := "", ""
        if idx < len(a.entries) {
            leftStr = entryLabel(a.entries[idx], idx == a.cursorIdx)
        }
 
        row := clamp(leftStr, a.width)
        if a.showRightPane {
            if i < len(a.rightPaneEntries) {
                rightStr = a.rightPaneEntries[i].Name()
            }
            safeLeft := clamp(leftStr, panelWidth-3)
            safeRight := clamp(rightStr, panelWidth-3)
            // compute padding from visible width
            padding := max(panelWidth-3-visibleLen(safeLeft), 1)
            row = clamp(fmt.Sprintf("%s%s %s│%s %s", safeLeft, strings.Repeat(" ", padding), cyan, reset, safeRight), a.width)
        }
 
        b.WriteString(row + "\r\n")
        linesPrinted++
    }
 
    statusBar := fmt.Sprintf("\033[36mPath: %s\033[0m", a.currentDir)
    b.WriteString(clamp(statusBar, a.width) + "\r\n")
    linesPrinted++
 
    a.lastRenderedLines = linesPrinted
    fmt.Print(b.String())
}
 
func (a *App) moveCursor(delta int) {
    if len(a.entries) == 0 {
        return
    }
    a.cursorIdx = max(0, min(a.cursorIdx+delta, len(a.entries)-1))
    a.updateScroll()
    a.updateRightPanel()
}
 
func (a *App) readKey() string {
    buf := make([]byte, 3)
    n, _ := os.Stdin.Read(buf)
    return string(buf[:n])
}
 
func (a *App) handleKey(key string) bool {
    switch key {
    case "q", "\x03":
        return false // exit
    case "\033[A", "k":
        a.moveCursor(-1)
    case "\033[B", "j":
        a.moveCursor(1)
    case "\r", "\n":
        a.enterDirectory()
    }
    return true // continue
}
 
func main() {
    restore, err := initUI()
    if err != nil {
        panic(err)
    }
    defer restore()
 
    app := loadApp(".")
    app.updateLayout()
 
    // main loop
    for {
        app.render()
        key := app.readKey() // wait for input
        if !app.handleKey(key) {
            break
        }
    }
}
file-browser-demo img
Note

If we want to handle terminal resizes (SIGWINCH Handling), we have to move away from this input-driven model, as external resize events can come in at any time. We will see how to handle resize in the next section.

Tip

To make your TUI testable, inject an io.Writer interface into your App struct.

type App struct {
    // ... other fields ...
    Out io.Writer
}
 
func (a *App) render() {
    // ... build string ...
    fmt.Fprint(a.Out, b.String()) // write to the injected destination
}

In tests, you can pass a bytes.Buffer to capture the output and assert against it, instead of writing directly to os.Stdout.

Concurrent TUIs

When you don't want to wait for user input, like for a live system monitor, animation, game, or reacting to external events like resize, you need concurrency. You can't just block on os.Stdin.Read() anymore.

There are many architectures to handle this. The two most common ones in Go are:

Pattern 1: Event Funnel

Instead of different parts of your app changing the state all at once (which causes data races), you funnel events into the main loop using channels and process them sequentially.
Concurrent Events -> Sequential Event Loop

keyChan := make(chan string)
tickChan := make(chan time.Time)
 
// input reading thread
go func() {
    for { keyChan <- app.readKey() }
}()
 
// animation ticker thread
go func() {
    for t := range time.Tick(time.Second / 60) { // 60 FPS
        tickChan <- t 
    }
}()
 
// event loop
for {
    app.render()
 
    select {
    case key := <-keyChan: // call handleKey when a key event is received
        app.handleKey(key)
    case tick := <-tickChan: // call Animate when a tick event is received
        app.Animate()
    }
}

Why it's good: No data races, no state sync needed. State mutations are purely sequential and predictable.

Note: As your app grows, managing many channels in a select gets messy. So, use a single channel (type any) to pass ALL events, thats what Bubbletea uses (chan tea.Msg)

Pattern 2: Decoupled Snapshot Pipeline (Visualizers/Games)

If you have a heavy background task (like a sorting algorithm doing 10,000 swaps a second), Pattern 1 will choke. You can't send 10,000 render events per second to the terminal. Instead, you separate the worker and the renderer.
Drawback: you need to sync state between mutators (and readers) using mutexes and/or atomic variables.

stateChan := make(chan VisState, 1)
 
// heavy worker loop
go func() {
    app.SortArray() // will contain mutex logic
}()
 
// sampler (controls frame rate)
go func() {
    for range time.Tick(time.Second / 60) {
        // grab a snapshot (deep copy) of the current array and send to renderer
        stateChan <- app.GetStateSnapshot() // will contain mutex logic (RLock)
    }
}()
 
// input listener
go func() {
    for { 
        key := app.readKey()
        app.handleKey(key) // will contain mutex logic
    }
}()
 
// render loop
for state := range stateChan {
    renderToScreen(state)
}

Why it's good: The renderer never bottlenecks the worker logic. The terminal renders at a smooth 60fps, no matter how fast the background math is running. This is the pattern used in sound-of-sort.

Other Rendering Paradigms

If you go outside go, you'll see other paradigms:

Etc.

We'll use the Event Funnel pattern to handle SIGWINCH signals for terminal resizes. When a resize signal is received, we send an event to the main loop to update the layout and re-render immediately. The main function of the original code will be modified like this:

import (
    // ... other imports ...
    "os/signal"
    "syscall"
)
 
// ... other code ...
 
func main() {
    restore, err := initUI()
    if err != nil {
        panic(err)
    }
    defer restore()
 
    app := loadApp(".")
    app.updateLayout()
 
    // read keys in a separate goroutine
    keyCh := make(chan string)
    go func() {
        for {
            keyCh <- app.readKey()
        }
    }()
 
    // listen for terminal resize events (SIGWINCH handling)
    resizeCh := make(chan os.Signal, 1)
    signal.Notify(resizeCh, syscall.SIGWINCH)
    defer signal.Stop(resizeCh)
 
    // main event loop (event funnel)
    for {
        app.render()
 
        select {
        case <-resizeCh:
            app.updateLayout()
 
        case key := <-keyCh:
            if !app.handleKey(key) {
                return
            }
        }
    }
}
file-browser-resize-demo img
Colorscheme: Catppuccin Catppuccin LogoMade with using Next.js Email: sahajb0606@gmail.com