tui-developer
Generate Go Bubble Tea TUI applications with chat-like interfaces. Use when the user asks to create terminal UIs, chat interfaces, interactive CLI, REPLs, or…
npx skills add https://github.com/microsoft/ghqr --skill tui-developerTUI Developer Skill
Generate minimal, idiomatic Go Bubble Tea TUI applications with chat-like interfaces using alt screen + internal viewport.
When to Use
Use this skill when the user asks to:
- Create a terminal UI with Bubble Tea
- Build a chat-like CLI interface
- Implement a REPL or interactive terminal
- Create a log viewer with input
Architecture
The TUI uses alt screen with an internal viewport for scrollable conversation history. Completed output is accumulated in a persisted string that is written to stdout after the alt screen exits, giving the user a transcript in their terminal scrollback.
┌───────────────────────────────┐
│ viewport (scrollable) │ <- persisted history + live activity
│ ❯ user message │
│ ● assistant response │
│ ● Thinking... (live) │
├───────────────────────────────┤
│ ───────────────────────── │ <- separator (with top margin)
│ ❯ input bar │
│ ───────────────────────── │
│ status bar ↕ model │ <- scroll arrow + model/mode
└───────────────────────────────┘
Key Design Decisions
tea.WithAltScreen()— full-screen TUI; avoids inline rendering artifactsbubbles/viewport— scrollable content area; height = terminal height minus fixed chrome rowspersisted string— accumulates all finalized output; written to stdout on exit so the user keeps a transcriptbuildViewportContent()— returnspersisted+ live activity (spinner/streaming) when a response is in progressappendToViewport(content)— appends topersistedand callsupdateViewport()(SetContent + GotoBottom)- No
tea.WithMouseCellMotion()— mouse capture is omitted so the user can select text with the mouse - No
tea.Println— all output goes through the viewport
Required Dependencies
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
)
Implementation Requirements
Model Structure
type model struct {
// Dimensions
width int
height int
// Core components
input textinput.Model
spinner spinner.Model
viewport viewport.Model
// Content
persisted string // finalized transcript (also written to stdout on exit)
// State
processing bool
quitting bool
program *tea.Program
}
Initialization
func newModel() *model {
return &model{
input: newTextInput(),
spinner: newSpinner(),
viewport: viewport.New(80, 20),
width: 80,
}
}
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, m.spinner.Tick)
}
Viewport Height
Reserve 6 rows for chrome: two separators (each with a top margin counts as 2 rows) + input row + status row:
func (m *model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.input.Width = msg.Width - 4
m.viewport.Width = msg.Width
vpHeight := msg.Height - 6
if vpHeight < 5 {
vpHeight = 5
}
m.viewport.Height = vpHeight
m.viewport.SetContent(m.buildViewportContent())
return m, nil
}
Viewport Content Helpers
// appendToViewport adds finalized content and scrolls to bottom.
func (m *model) appendToViewport(content string) {
m.persisted += content
m.updateViewport()
}
// buildViewportContent returns persisted history plus any live activity.
func (m *model) buildViewportContent() string {
if m.processing {
if live := m.renderActivity(); live != "" {
return m.persisted + live
}
}
return m.persisted
}
// updateViewport refreshes content and scrolls to bottom.
func (m *model) updateViewport() {
m.viewport.SetContent(m.buildViewportContent())
m.viewport.GotoBottom()
}
Keyboard Scrolling
case tea.KeyPgUp:
m.viewport.HalfViewUp()
return m, nil
case tea.KeyPgDown:
m.viewport.HalfViewDown()
return m, nil
case tea.KeyUp:
m.viewport.LineUp(1)
return m, nil
case tea.KeyDown:
m.viewport.LineDown(1)
return m, nil
Scroll Arrow in Status Bar
Display ↑/↓/↕ in the status bar to indicate scroll position:
func (m *model) scrollArrow() string {
if m.viewport.YOffset == 0 && m.viewport.AtBottom() {
return "" // all content fits
}
canUp := m.viewport.YOffset > 0
canDown := !m.viewport.AtBottom()
switch {
case canUp && canDown:
return "↕"
case canUp:
return "↑"
default:
return "↓"
}
}
View Layout
View() renders the full screen: viewport + chrome. Return "" when quitting so the alt screen clears cleanly.
func (m *model) View() string {
if m.quitting {
return ""
}
separator := strings.Repeat("─", m.width-2)
parts := []string{
m.viewport.View(),
separator,
m.input.View(),
separator,
m.renderStatusBar(),
}
return strings.Join(parts, "\n")
}
Program Entry and Exit Transcript
After p.Run() returns, print persisted to stdout so the user gets a transcript in their shell:
func Run() error {
m := newModel()
p := tea.NewProgram(m, tea.WithAltScreen())
m.program = p
finalModel, err := p.Run()
if fm, ok := finalModel.(*model); ok && fm.persisted != "" {
fmt.Print(fm.persisted)
}
return err
}
Ctrl+C Behaviour
First Ctrl+C clears the input (with a 2-second warning); second Ctrl+C within that window exits:
func (m *model) handleCtrlC() (tea.Model, tea.Cmd) {
now := time.Now()
if m.ctrlCPressed && now.Sub(m.ctrlCTime) < 2*time.Second {
m.quitting = true
return m, tea.Quit
}
m.input.SetValue("")
m.ctrlCPressed = true
m.ctrlCTime = now
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return ctrlCClearMsg{}
})
}
Quality Checklist
Before returning generated code, verify:
- Uses
tea.WithAltScreen()— full-screen, no inline artifacts - Uses
bubbles/viewport— scrollable content area - Does NOT use
tea.Println— all output goes through the viewport - Does NOT use
tea.WithMouseCellMotion()— preserves native text selection -
persistedstring accumulates all finalized content -
appendToViewport= append topersisted+updateViewport() -
buildViewportContent=persisted+ live activity when processing - Viewport height = terminal height minus 6 fixed chrome rows
-
View()returns""when quitting - After
p.Run(), printsfm.persistedto stdout for transcript - PgUp/PgDn and Up/Down scroll the viewport
- Scroll arrow (↑/↓/↕) shown in status bar
- First Ctrl+C clears input; second Ctrl+C quits
- Model uses pointer receiver (
*model) for all methods - Update handlers are broken into focused methods