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 --> AFor 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
- npm: npmjs.com/package/@oakoliver/huh
- GitHub: github.com/oakoliver/huh
- Original: github.com/charmbracelet/huh
– Antonio