BACK TO ENGINEERING
Engineering 6 min read

Porting Go's Huh? to TypeScript: Interactive Terminal Forms, Zero Dependencies

Building terminal applications is one thing. Collecting structured input from humans inside those applications is another thing entirely.

In Go, Charmbracelet's Huh? is the forms library for the terminal — 7 field types, multi-step form navigation, 5 built-in themes, dynamic field evaluation, validation, filtering, and an accessible mode. It's what you reach for when you need a CLI wizard, a configuration prompt, or any interactive questionnaire that runs in a terminal.

Now it's available in TypeScript. This is the sixth Charm ecosystem port.


I – What Shipped

@oakoliver/huh — interactive terminal forms and prompts.

npm install @oakoliver/huh
import { NewForm, NewGroup, NewInput, NewSelect, NewConfirm, NewOption, Run, ThemeCharm } from '@oakoliver/huh';

const name = { value: '' };
const color = { value: '' };

const form = NewForm(
  NewGroup(
    NewInput().title('Name').placeholder('John Doe').value(name),
    NewSelect<string>()
      .title('Color')
      .options([NewOption('Red', 'red'), NewOption('Blue', 'blue')])
      .value(color),
  ),
).theme(ThemeCharm());

await Run(form);
console.log(`${name.value} likes ${color.value}`);

4,944 lines of TypeScript across 21 source files. 28 tests, 126 expects. Dependencies on the three previously ported Charm packages (bubbletea, bubbles, lipgloss) — but zero external runtime dependencies beyond those.


II – Seven Field Types

Each field is a full Elm Architecture model with update(msg) and view() methods, composable via the fluent builder API.

Input

Single-line text with placeholder, character limit, validation, and suggestions. Wraps the bubbles TextInput component underneath, inheriting cursor management, echo modes, and autocomplete.

Text

Multi-line text area with configurable line count, character limit, and word wrapping. Wraps the bubbles Textarea component.

Select

Single-choice selection rendered inside a viewport. Options scroll naturally when the list exceeds the configured height. Supports filtering — press / and type to fuzzy-match option labels. Options can be loaded dynamically via optionsFunc(), which displays a spinner while fetching.

MultiSelect

Multiple-choice selection with an optional limit, select-all toggle (Ctrl+A), and the same viewport scrolling and filtering as Select. Uses prefix markers ( for selected, for unselected in Charm theme) rendered alongside each option via joinHorizontal.

Confirm

Binary yes/no prompt with configurable affirmative/negative labels. Maps to a boolean accessor.

Note

Read-only informational panel. Useful for displaying instructions between form steps. Supports title, description, and an optional "Next" label.

FilePicker

File system browser with extension filtering, hidden file toggle, and directory toggle. Wraps the bubbles FilePicker component.


III – The Hardest Parts

Viewport-Based Scrolling

This was the single largest debugging effort across the entire port. Both Select and MultiSelect use an internal viewport to manage scrolling — the field renders ALL options as a single string, sets it as the viewport's content, and the viewport handles which lines are visible.

The subtlety: the field's outer style has a horizontal frame (1px border + 1px padding = 2 characters). When the viewport width matches the field width, lines rendered at full width get wrapped by the outer style, doubling lines and pushing content off-screen.

The fix was straightforward once understood — updateViewportSize() must subtract the base style's horizontal frame size from the viewport width:

updateViewportSize() {
  const styles = this._theme(this._state);
  const vpWidth = this._width - styles.base.getHorizontalFrameSize();
  this._viewport.setWidth(vpWidth);
  // ...
}

But diagnosing it required tracing through three layers of rendering: the field's option renderer, the viewport's content padding, and lipgloss's width-based line wrapping.

Method Binding in Commands

Go struct methods automatically capture their receiver. In TypeScript, storing a method reference like this._spinner.tickMsg loses the this context when called later as a plain function.

This manifested as a TypeError: undefined is not an object deep inside a recursive command resolution loop. The spinner's tickMsg() method tried to access this._id, but this was undefined because the method was pushed into a commands array as an unbound reference.

The fix: () => this._spinner.tickMsg() instead of this._spinner.tickMsg.

A one-character difference in Go (where the equivalent code just works). A full stack trace investigation in TypeScript.

Binding Reference Sharing

In Go, assigning a struct to a new variable copies it. In TypeScript, assigning an object to a new variable copies the reference. When a form's withKeyMap() distributes keybindings to all fields in a group, every field was sharing the same Binding objects — toggling one field's help bindings affected all fields.

Solution: cloneBinding() and cloneKeyMapSection() helpers that deep-copy every Binding object during distribution.

Recursive BatchMsg Unwrapping

Go's doAllUpdates test helper calls cmd(), checks if the result is a BatchMsg, and recursively processes each sub-command. The TypeScript equivalent needed to understand our BatchMsg class structure (_tag === 'BatchMsg', .cmds array) and handle the fact that timer-based commands return Promises (which naturally terminate recursion since they don't match any message handler).


IV – Dynamic Fields via Eval

Huh? supports dynamic field properties — titles, descriptions, placeholders, suggestions, and options can all be functions that re-evaluate when their dependency changes:

const role = { value: '' };
const team = { value: '' };

NewSelect<string>()
  .title('Team')
  .optionsFunc(
    () => role.value === 'engineer'
      ? [NewOption('Backend', 'be'), NewOption('Frontend', 'fe')]
      : [NewOption('Sales', 'sales'), NewOption('Marketing', 'mkt')],
    role,  // dependency — re-evaluates when role changes
  )
  .value(team);

The implementation uses Eval — a polling mechanism that runs in the form's update loop. Each eval-enabled field stores a hash of its dependency's current value. When the hash changes, the field dispatches an update message with the new computed value.

flowchart TD
    A["Form update loop"] --> B["Hash dependency value"]
    B --> C{"Hash changed?"}
    C -- No --> A
    C -- Yes --> D["Dispatch UpdateOptionsMsg"]
    D --> E["Replace option list"]
    E --> F["Re-mark selected items"]
    F --> G["Resize viewport"]
    G --> A

For options specifically, optionsFunc returns a Cmd that resolves to an UpdateOptionsMsg. The Select and MultiSelect fields process this message by replacing their option list, re-running selectOptions() (which marks options matching the current accessor value as selected), and resizing the viewport.


V – Five Themes

Each theme is a function that takes field state (focused/blurred/error) and returns a complete set of lipgloss styles. The five built-in themes:

  • Charm — Charmbracelet's signature look. Indigo (#7571F9) focused borders, rounded corners, check marks (✓) and bullets (•) for selection prefixes
  • Base — Minimal with square brackets, no colors
  • Dracula — The Dracula color palette (purple, cyan, green, pink)
  • Base16 — Base16 terminal colors
  • Catppuccin — The Catppuccin palette (mauve, lavender, overlay)

Custom themes are supported via ThemeFunc — pass any function with the signature (state: FieldState) => Styles.


VI – The Ecosystem So Far

This is the sixth package in the TypeScript port of Charmbracelet's Go ecosystem:

flowchart BT
    lipgloss["@oakoliver/lipgloss"]
    glamour["@oakoliver/glamour"]
    bubbletea["@oakoliver/bubbletea"]
    bubbles["@oakoliver/bubbles"]
    glow["@oakoliver/glow"]
    huh["@oakoliver/huh"]

    lipgloss --> glamour
    lipgloss --> bubbletea
    lipgloss --> bubbles
    bubbletea --> bubbles
    bubbles --> huh
    bubbletea --> huh
    lipgloss --> huh
    glamour --> glow
Package npm Lines Tests
@oakoliver/lipgloss v1.0.0 3,100 140 tests
@oakoliver/glamour v1.0.0 3,700 205 tests
@oakoliver/bubbletea v1.0.0 2,100 74 tests
@oakoliver/bubbles v1.0.1 7,500 449 tests
@oakoliver/glow v1.0.0 4,254 60 tests
@oakoliver/huh v1.0.0 4,944 28 tests
Total ~25,600 ~956 tests

One more to go — Gum, the shell script utility that wraps all of the above into standalone CLI commands.


VII – Links


– Antonio

"Simplicity is the ultimate sophistication."