Better input
bufio.Scanner reads a line. It does not let you move the cursor mid-line, recall history, or correct a typo three characters back. For a chat REPL where messages can be long, that's brutal.
We're going to fix it in two steps, because the journey is more useful than the destination.
Step 1: readline
The classic Unix answer to "line editing in a terminal" is readline. In Go, github.com/chzyer/readline is a drop-in replacement for bufio.Scanner:
rl, err := readline.NewEx(&readline.Config{
Prompt: "\033[1;36m❯\033[0m ",
HistoryFile: "~/.bettatech_harness_history",
HistorySearchFold: true,
})
defer rl.Close()
for {
line, err := rl.Readline()
if errors.Is(err, io.EOF) { return }
if errors.Is(err, readline.ErrInterrupt) { continue } // ctrl-c clears the line
// … handle line …
}
That gets you, for free:
- Arrow keys for cursor movement
- Backspace/delete mid-line
- Ctrl-A / Ctrl-E to jump to start/end
- Up/down arrows for history
- Ctrl-R for reverse search through history
- History persisted to
~/.bettatech_harness_historyacross sessions - Ctrl-D to EOF (exit), Ctrl-C to cancel current line
This is the right answer for most CLIs. We use it briefly and then replace it. Why?
Step 2: Bubble Tea
We wanted the input to look like Claude Code or OpenCode — bordered, styled, multi-line ready, with a status indication. readline styling stops at "you can put ANSI codes in the prompt." It can't draw a box.
github.com/charmbracelet/bubbletea is the Go TUI framework that those tools use. It's overkill for "read a line." But it's the right starting point for eventually having a full TUI (chapter 12), and it lets us draw whatever input affordances we want.
The model-view-update pattern:
type chatInputModel struct {
ti textinput.Model
history []string
histIdx int
submitted string
done bool
}
func (m chatInputModel) Init() tea.Cmd { return textinput.Blink }
func (m chatInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
m.submitted = m.ti.Value()
m.done = true
return m, tea.Quit
case tea.KeyCtrlD:
return m, tea.Quit
case tea.KeyUp, tea.KeyDown:
// history navigation (a few lines per direction)
}
}
var cmd tea.Cmd
m.ti, cmd = m.ti.Update(msg)
return m, cmd
}
func (m chatInputModel) View() string {
return boxStyle.Render(m.ti.View()) + "\n" + hintStyle.Render("enter: send · ↑↓: history · ctrl-d: exit")
}
ReadChatInput() wraps that into a one-shot: spin up a tea.NewProgram(...), run it, return when the model says done.
The result is a single bordered input box that gets called once per turn:
╭───────────────────────────────────────────────────────╮
│ ❯ your message │
╰───────────────────────────────────────────────────────╯
enter: send · ↑↓: history · ctrl-d: exit
After you submit, Bubble Tea exits, the box stays on screen (because we don't use alt-screen mode at this stage), and the next iteration of the REPL kicks off another ReadChatInput.
Why two steps for the same goal
You could go straight from bufio.Scanner to Bubble Tea. We didn't, because each step teaches something different.
bufio.Scanner→ readline teaches you that ergonomics matter. The feel improves dramatically and the code change is small. This is the 80/20.- readline → Bubble Tea teaches you the next paradigm. MVU isn't just for prettier input; it's the architecture you'll use when the whole UI becomes a TUI (chapter 12). Doing it once for input is a warmup.
If you're following along and only have time for one step, do readline. Bubble Tea earns its complexity in chapter 12.
A subtle issue: two readers on stdin
Once you have a fancy input library, you can't also use bufio.Scanner somewhere else (for confirm(), say). Two readers on the same stdin buffer steal bytes from each other in unpredictable ways.
The fix in this chapter: use one input mechanism. Confirm uses the same readline.Instance via SetPrompt:
func confirm(prompt string) bool {
input.SetPrompt(prompt + " [y/n] ")
defer input.SetPrompt(mainPrompt)
line, err := input.Readline()
// …
}
In Bubble Tea mode, same idea — confirm runs another tea.NewProgram. By chapter 12 the entire UI is one Bubble Tea program and confirm becomes a state transition inside that program; the "two readers" problem dissolves because there's only ever one.
Pitfalls
Persisting history. When you add HistoryFile, you're writing your typed text to disk. If you ever paste an API key into chat (it happens), you've leaked it to ~/.bettatech_harness_history. We accept this for a learning project. For production: don't persist history, or hash/redact specific patterns first.
TUI in non-TTY environments. tea.NewProgram fails if stdin isn't a terminal. We don't handle this gracefully — running harness < script.txt would crash. A real-world version would detect non-TTY and fall back to scanner-mode automatically.
Up/down ambiguity with multi-line input. Once you have a textarea (vs textinput), arrow keys mean "move within text," not "navigate history." The standard fix is Ctrl-P / Ctrl-N for history (Emacs convention) and reserve arrows for cursor movement. We use single-line textinput to sidestep this.
In the current repo. The one-shot Bubble Tea version of input (with history navigation and persistent history) is in
internal/ui/input.go— seechatInputModel. By chapter 12 the input is part of a larger persistent TUI program, butinput.gostill has the standalone version and theloadHistory/appendHistoryhelpers. History persistence isn't wired into the chapter-12 TUI yet — that's one of the exercises in chapter 13.
Now try
- Compare the feel of the harness with the
bufio.Scannerversion (use git to check out an earlier state if you have history) vs the readline version vs the Bubble Tea version. Notice how much "polish" is just affordance count. - Read
internal/ui/input.goand trace the up-arrow code path. ThebufferTextfield stores what you were typing before you started navigating history, so pressing Down past the most-recent entry restores it. Surprisingly easy to miss. - Replace
textinputwithtextarea(also frombubbles) and figure out the multi-line keybindings. Specifically: how do you bind Shift-Enter to "insert newline" while plain Enter "submits"? (This is a real-world rabbit hole — some terminals can't distinguish the two.)
End of arc 2 — abstractions earn their keep
Six chapters of abstractions: a Provider interface, a slash-command palette, an explicit conversation contract, three compaction strategies, a real input box. Each chapter solved one problem and added one seam. The harness now reads like a system instead of a script — and you can already swap the LLM, the compaction strategy, and the input layer without touching the rest.
Arc 3 (chapters 09–12) is where those seams pay off. We turn the tool switch into a registry, move code into internal/ packages, introduce subagents (each one is just another agent running through the same loop), and replace stdout-printing with a full Bubble Tea program. By the end the harness looks like a small Claude Code.
Next: 09 · Plug-and-play tools.