Go library for terminal REPL chrome. Pins a fixed UI (prompt, status bar, hints) at the bottom of the terminal while stdout flows freely above it. Zero dependencies beyond the standard library.
Built for agentic CLI tools where you need a persistent input area but don't want to take over the screen. Uses ANSI scroll regions — the same technique as Claude Code.
go get github.com/hegner123/bezel@latest
Requires Go 1.22+. Supports macOS and Linux (amd64, arm64).
b, err := bezel.New(os.Stdin, os.Stdout, 3) // 3 initial rows of chrome
if err != nil {
log.Fatal(err)
}
defer b.Close()
var ed bezel.LineEditor
var hist bezel.History
km := bezel.DefaultKeyMap()
for ev := range b.Events() {
action, text := ed.HandleEvent(ev, km, &hist)
switch action {
case bezel.ActionQuit:
return
case bezel.ActionSubmit:
hist.Add(text)
fmt.Println(text) // goes to scroll region
}
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{"status"}
lines = append(lines, v.Rows...)
lines = append(lines, "hints")
b.Redraw(lines...) // bezel grows/shrinks as content wraps
}┌──────────────────────────────────────┐
│ Previous terminal output preserved │
│ Child process stdout lands here │ scroll region
│ LLM streaming tokens land here │ (rows 1 to N-3)
│ fmt.Println() lands here │
├──────────────────────────────────────┤
│ ── 80x24 ── thinking... │ bezel row 0 (status)
│ > user input here█ │ bezel row 1 (prompt)
│ Enter submit | Ctrl-C quit │ bezel row 2 (hints)
└──────────────────────────────────────┘
The scroll region is standard terminal scrolling — programs write to stdout normally and it just works. The bezel is redrawn via ANSI escape sequences as a single atomic write to prevent tearing.
Previous terminal history (before your tool launched) is preserved and scrollable.
Bezel manages the scroll region, raw terminal mode, bracketed paste, SIGWINCH, and the merged event channel.
// Create. Height is the initial number of chrome rows at the bottom.
// Redraw adjusts dynamically based on the number of lines passed.
b, err := bezel.New(os.Stdin, os.Stdout, 3)
defer b.Close()
// Read events (keyboard, paste, resize).
for ev := range b.Events() { ... }
// Current terminal dimensions.
size := b.Size() // Size{Rows, Cols}One method draws the bezel. The height adjusts dynamically to fit however many lines you pass. The real terminal cursor is hidden permanently; a pseudo cursor (█) is embedded via Visual:
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{"status line"}
lines = append(lines, v.Rows...) // wrapped editor rows with █
lines = append(lines, "hints")
b.Redraw(lines...) // bezel grows/shrinks to fitThe real cursor stays in the scroll region so fmt.Println and streaming output work without any mode switching — just write to stdout anytime:
// Tool output phase.
b.Redraw("thinking...", "> ", "Ctrl-C cancel")
streamLLMOutput(os.Stdout) // tokens flow to scroll region
// Input phase — bezel grows if content wraps.
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{"ready"}
lines = append(lines, v.Rows...)
lines = append(lines, "Enter submit")
b.Redraw(lines...)
// Writing to stdout works anytime.
fmt.Println("output") // lands in scroll regionBezel captures keystrokes continuously in a background goroutine — they are never lost. But your event loop must drain b.Events() for the user to see their typing in real-time.
If your loop blocks on a synchronous call, keystrokes queue silently:
// User can't see typing until this returns.
response := callLLM(text)To allow typing while streaming, use select to multiplex both channels:
for {
select {
case ev := <-b.Events():
action, _ := ed.HandleEvent(ev, km, &hist)
if action == bezel.ActionQuit { return }
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{"streaming..."}
lines = append(lines, v.Rows...)
lines = append(lines, "Ctrl-C cancel")
b.Redraw(lines...)
case chunk, ok := <-sseChan:
if !ok {
// Stream done — switch to input mode.
break
}
os.Stdout.Write(chunk)
}
}
// Streaming finished.
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{"ready"}
lines = append(lines, v.Rows...)
lines = append(lines, "hints")
b.Redraw(lines...)The rule: if you are reading from b.Events(), the user can type. If you are blocked on something else, keystrokes queue until you resume reading.
On terminal resize, the screen is cleared and an EventResize is delivered. Re-emit any scroll region content in your handler:
if ev.Type == bezel.EventResize {
// Re-print whatever should be visible.
for _, line := range outputHistory {
fmt.Println(line)
}
redraw()
}A single channel delivers all input. Events are parsed from raw terminal bytes — escape sequences become structured types.
type Event struct {
Type EventType // EventKey, EventPaste, EventResize, EventUnknown
Key Key // Which key (KeyRune, KeyEnter, KeyUp, KeyF1, ...)
Ch rune // The character for KeyRune events
Mod Modifier // ModCtrl, ModAlt, ModShift (bitfield)
Text string // Paste content for EventPaste
Raw []byte // Original bytes, always set
}| Type | When |
|---|---|
EventKey |
Any key press. Check Key, Ch, Mod. |
EventPaste |
Bracketed paste. Full text in Text. |
EventResize |
Terminal resized. Call Size() for new dimensions. |
EventUnknown |
Unrecognized escape sequence. Raw bytes in Raw. |
Special keys: KeyEnter, KeyTab, KeyBackspace, KeyEscape, KeyUp, KeyDown, KeyLeft, KeyRight, KeyHome, KeyEnd, KeyDelete, KeyInsert, KeyPageUp, KeyPageDown, KeyF1–KeyF12.
For printable characters, Key == KeyRune and Ch holds the rune. Ctrl+letter shows as Key=KeyRune, Ch='c', Mod=ModCtrl.
Modified special keys work: Key=KeyRight, Mod=ModCtrl for Ctrl+Right, etc.
LineEditor manages an editable line of text with cursor position. Zero value is ready to use.
var ed bezel.LineEditorFor manual control, call methods directly:
ed.Insert('x') // insert at cursor
ed.InsertString("hi") // insert string (paste)
ed.Backspace() // delete before cursor
ed.Delete() // delete at cursor
ed.Left() // cursor left
ed.Right() // cursor right
ed.Home() // cursor to start
ed.End() // cursor to end
ed.WordLeft() // cursor to previous word boundary
ed.WordRight() // cursor past next word
ed.DeleteToStart() // cut to start of line (Ctrl-U)
ed.DeleteToEnd() // cut to end of line (Ctrl-K)
ed.DeleteWordBack() // cut previous word (Ctrl-W)
text := ed.Submit() // return content, reset editor
ed.Set("preset") // replace content, cursor to end
ed.Clear() // empty the editor
ed.String() // current content
ed.StringWithCursor() // content with █ at cursor position
ed.Pos() // cursor position (runes from start)
ed.Len() // content length in runes
ed.Empty() // true if no contentFor the common case, HandleEvent maps events to editor actions via a configurable keymap:
action, text := ed.HandleEvent(ev, km, &hist)Returns the Action taken and, for ActionSubmit/ActionPaste, the relevant text. Pass nil for hist if history is not needed.
KeyMap maps key combinations to actions. DefaultKeyMap() provides standard terminal bindings.
| Key | Action |
|---|---|
| Enter | Submit |
| Ctrl-C | Quit |
| Backspace, Ctrl-H | Backspace |
| Delete | Delete |
| Left, Ctrl-B | Cursor left |
| Right, Ctrl-F | Cursor right |
| Ctrl-Left, Alt-B | Word left |
| Ctrl-Right, Alt-F | Word right |
| Home, Ctrl-A | Home |
| End, Ctrl-E | End |
| Ctrl-U | Cut to start |
| Ctrl-K | Cut to end |
| Ctrl-W, Alt-Backspace | Cut word back |
| Up, Ctrl-P | History previous |
| Down, Ctrl-N | History next |
km := bezel.DefaultKeyMap()
// Change quit to Ctrl-D.
delete(km, bezel.KeyBind{Key: bezel.KeyRune, Ch: 'c', Mod: bezel.ModCtrl})
km[bezel.KeyBind{Key: bezel.KeyRune, Ch: 'd', Mod: bezel.ModCtrl}] = bezel.ActionQuit
// Add Ctrl-L (handle before HandleEvent for custom behavior).
// Or bind it to an existing action:
km[bezel.KeyBind{Key: bezel.KeyRune, Ch: 'l', Mod: bezel.ModCtrl}] = bezel.ActionDeleteToStart
// Remove a binding.
delete(km, bezel.KeyBind{Key: bezel.KeyRune, Ch: 'k', Mod: bezel.ModCtrl})For actions beyond the built-in set, handle the event before calling HandleEvent:
for ev := range b.Events() {
// Custom bindings first.
if ev.Type == bezel.EventKey && ev.Key == bezel.KeyRune && ev.Ch == 'l' && ev.Mod == bezel.ModCtrl {
clearScreen()
continue
}
// Then standard editor handling.
action, text := ed.HandleEvent(ev, km, &hist)
...
}| Action | Meaning |
|---|---|
ActionNone |
Unrecognized key, no change |
ActionQuit |
Quit requested |
ActionSubmit |
Line submitted (text in return value) |
ActionInsert |
Character inserted |
ActionPaste |
Text pasted (text in return value) |
ActionBackspace |
Deleted before cursor |
ActionDelete |
Deleted at cursor |
ActionLeft, ActionRight |
Cursor moved |
ActionWordLeft, ActionWordRight |
Cursor jumped by word |
ActionHome, ActionEnd |
Cursor jumped to boundary |
ActionDeleteToStart, ActionDeleteToEnd |
Line cut |
ActionDeleteWordBack |
Word cut |
ActionHistoryPrev, ActionHistoryNext |
History navigation |
History stores submitted lines and supports Up/Down navigation with draft preservation. Zero value is ready to use.
var hist bezel.History
// Add entries (caller decides what enters history).
hist.Add("command")
// Navigation is handled automatically by HandleEvent.
// Or manually:
text, ok := hist.Prev(currentInput) // saves current input as draft
text, ok = hist.Next() // returns draft when past newest
hist.Reset() // stop navigating
// For persistence or display:
hist.Entries() // []string, oldest to newest
hist.Len() // number of entriesConsecutive duplicates and empty strings are automatically skipped on Add.
When the user presses Up, their current input is saved as a draft. Navigating back down past the newest entry restores it.
Complete pattern for a tool that runs LLM calls and streams output:
func main() {
b, _ := bezel.New(os.Stdin, os.Stdout, 3)
defer b.Close()
var ed bezel.LineEditor
var hist bezel.History
km := bezel.DefaultKeyMap()
redraw := func(status string) {
size := b.Size()
v := ed.Visual(int(size.Cols), []string{"> "})
lines := []string{status}
lines = append(lines, v.Rows...)
lines = append(lines, "Enter send | Ctrl-C quit")
b.Redraw(lines...)
}
redraw("ready")
for ev := range b.Events() {
if ev.Type == bezel.EventResize {
redraw("ready")
continue
}
action, text := ed.HandleEvent(ev, km, &hist)
switch action {
case bezel.ActionQuit:
return
case bezel.ActionSubmit:
hist.Add(text)
fmt.Printf("You: %s\n", text)
// Stream LLM response.
b.Redraw("thinking...", "> ", "Ctrl-C cancel")
response := callLLM(text)
fmt.Printf("AI: %s\n", response)
redraw("ready")
continue
case bezel.ActionNone:
continue
}
redraw("editing")
}
}The high-level Bezel type composes these primitives, which are also exported:
// Raw terminal mode.
state, err := bezel.EnableRaw(os.Stdin)
defer state.Restore()
// Terminal dimensions.
size, err := bezel.TermSize(os.Stdin)
// Bracketed paste mode.
bezel.EnableBracketedPaste(os.Stdout)
defer bezel.DisableBracketedPaste(os.Stdout)
// Parsed input event channel.
ctx, cancel := context.WithCancel(context.Background())
events := bezel.ReadInput(ctx, os.Stdin)
for ev := range events { ... }Use these if you need raw terminal control without the scroll region chrome.
macOS and Linux. Uses ioctl syscalls via syscall.Syscall. No cgo.
- macOS:
TIOCGETA/TIOCSETA/TIOCGWINSZ - Linux:
TCGETS/TCSETS/TIOCGWINSZ
MIT — see LICENSE.