tui

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 21, 2026 License: MIT Imports: 26 Imported by: 0

README

pkg/tui

This package implements Falcon's terminal user interface using the Charm ecosystem — Bubble Tea for the application framework, Lip Gloss for styling, and Glamour for markdown rendering.

Package Overview

pkg/tui/
├── app.go          # Entry point: Run() creates the Bubble Tea program
├── model.go        # Model struct — all UI state
├── init.go         # InitialModel(): LLM client, agent, tools, confirmation manager
├── update.go       # Bubble Tea Update() — handles all tea.Msg types
├── view.go         # Bubble Tea View() — renders the full TUI layout
├── keys.go         # Keyboard bindings and input history navigation
├── modelpicker.go  # In-session model switcher UI (/model command)
├── envpicker.go    # In-session environment switcher UI (/env command)
├── slash.go        # Slash command processor
├── styles.go       # Lip Gloss color palette and style definitions
└── highlight.go    # JSON syntax highlighting utility

Architecture

Falcon's TUI follows the Elm Architecture (Model-View-Update):

┌──────────────────────────────────┐
│             Run()                │
│   Creates Bubble Tea program     │
└──────────┬───────────────────────┘
           │
    ┌──────┼──────┐
    ▼      ▼      ▼
  Init  Update  View
  • Init (init.go, model.go) — builds the initial model
  • Update (update.go, keys.go) — handles events and produces new model state
  • View (view.go, styles.go) — renders the model to a string each frame

Model

The Model struct holds all UI state:

type Model struct {
    // UI components
    viewport  viewport.Model    // Scrollable output area
    textinput textinput.Model   // User input field
    spinner   spinner.Model     // Loading animation (harmonica spring)

    // Message log
    logs         []logEntry     // Message history displayed in viewport
    status       string         // "idle", "thinking", "streaming", "tool:name"
    inputHistory []string       // Previous commands for Shift+↑/↓ navigation
    historyIdx   int            // Current position in input history
    savedInput   string         // Saved input when navigating history

    // Agent
    agent          *core.Agent  // The LLM agent instance
    modelName      string       // Current LLM model name for badge display
    streamingBuffer string      // Accumulates streaming content

    // Tool usage display
    toolUsage     []ToolUsageDisplay // Per-tool call stats
    totalCalls    int                // Total tool calls in session
    lastToolName  string
    toolStartTime time.Time

    // File write confirmation
    confirmationMode    bool
    pendingConfirmation *core.FileConfirmation
    confirmManager      *shared.ConfirmationManager

    // Slash command state
    slashState SlashState

    // Model picker (/model command)
    modelPickerActive bool
    modelPickerItems  []modelEntry  // Configured providers from GlobalConfig
    modelPickerIdx    int

    // Environment picker (/env command)
    envPickerActive bool
    envPickerItems  []string  // Names from .falcon/environments/
    envPickerIdx    int

    // Active environment
    activeEnv string
    envVars   map[string]string

    // Layout
    width  int
    height int
    ready  bool
}
Log Entry Types
Type Description Display
user User input Blue > prefix
thinking Agent reasoning Hidden
streaming Partial LLM response Appended live to current entry
tool Tool invocation Green prefix with args and usage count
observation Tool result Dimmed prefix
response Final answer Glamour-rendered markdown
error Error message Red prefix
splash Startup Falcon ASCII art Indented brand art
separator Visual break Hidden

Initialization

InitialModel() in init.go:

  1. Loads global config from ~/.falcon/config.yaml
  2. Runs the setup wizard (Huh forms) if no provider is configured
  3. Builds the LLM client via the provider registry
  4. Creates the Agent and registers all 28+ tools via the central Registry
  5. Applies tool limits from .falcon/config.yaml
  6. Initializes UI components (viewport, textinput, spinner)
  7. Displays the Falcon ASCII splash screen with version, working directory, and web UI URL

Event Handling

Keyboard — Normal Mode
Key Action
Enter Send message to agent
Shift+↑ Navigate to previous command in history
Shift+↓ Navigate to next command in history
PgUp / ↑ Scroll output up
PgDown / ↓ Scroll output down
Ctrl+L Clear screen
Ctrl+U Clear input line
Ctrl+Y Copy last response to clipboard
Esc Stop running agent / Quit if idle
Ctrl+C Quit
Keyboard — Confirmation Mode

When Falcon proposes a file change, the TUI enters confirmation mode:

Key Action
Y Approve — write the file
N Reject — discard the change
PgUp / PgDown Scroll the unified diff
Esc Reject and continue
Keyboard — Model Picker
Key Action
↑ / ↓ Move selection
Enter Select model and switch LLM client
Esc Cancel and close picker
Keyboard — Environment Picker
Key Action
↑ / ↓ Move selection
Enter Load environment and apply variables
Esc Cancel and close picker
Agent Events

The agent emits AgentEvent values via callback. The TUI handles them in update.go:

Event Type TUI Action
streaming Append chunk to current log entry (real-time display)
tool_call Add tool log entry, update status line
observation Add dimmed observation entry with duration
tool_usage Update per-tool call counters
answer Render final answer as Glamour markdown
error Add red error entry
confirmation_required Enter confirmation mode, show diff viewport

Slash Commands

Processed by slash.go before the input is sent to the agent:

Command Action
/model Open the model picker panel
/env Open the environment picker panel
/ Load and execute a YAML files - requests and flows

Model Picker

Implemented in modelpicker.go. Activated by typing /model.

  • Reads configured providers from ~/.falcon/config.yaml
  • Displays a list of modelEntry items: {ProviderID, DisplayName, Model, Config}
  • On selection, calls BuildClient() on the provider and hot-swaps the agent's LLM client
  • The modelName badge in the footer updates immediately

Environment Picker

Implemented in envpicker.go. Activated by typing /env.

  • Reads .yaml files from .falcon/environments/
  • On selection, loads the environment and updates envVars in the model
  • Variable substitution in tool calls will use the newly active environment

Rendering

Layout
┌──────────────────────────────────┐
│          Viewport                │  ← Scrollable output (logs)
│                                  │
├──────────────────────────────────┤
│  > input field         [spinner] │  ← Text input
├──────────────────────────────────┤
│  model · env · calls   help text │  ← Footer (status badges)
└──────────────────────────────────┘

When a model picker or env picker is active, an overlay panel renders above the input line.

When in confirmation mode, a diff viewport replaces the input area.

Styling

Defined in styles.go using Lip Gloss:

var (
    Dim     = lipgloss.NewStyle().Foreground(lipgloss.Color("#6c6c6c"))
    Text    = lipgloss.NewStyle().Foreground(lipgloss.Color("#e0e0e0"))
    Accent  = lipgloss.NewStyle().Foreground(lipgloss.Color("#7aa2f7"))
    Error   = lipgloss.NewStyle().Foreground(lipgloss.Color("#f7768e"))
    Tool    = lipgloss.NewStyle().Foreground(lipgloss.Color("#9ece6a"))
    Success = lipgloss.NewStyle().Foreground(lipgloss.Color("#73daca"))
)

Log entry prefixes:

UserPrefix   = Accent.Render("> ")
ToolPrefix   = Tool.Render("○ ")
ErrorPrefix  = Error.Render("✗ ")
ResultPrefix = Dim.Render("→ ")

Agent Integration

The agent runs in a goroutine and sends events back to the Bubble Tea program via program.Send():

func runAgentAsync(m Model) tea.Cmd {
    return func() tea.Msg {
        err := m.agent.ProcessMessageWithEvents(ctx, input, func(event core.AgentEvent) {
            globalProgram.Send(agentEventMsg(event))
        })
        return agentDoneMsg{err: err}
    }
}

A thread-safe globalProgram reference allows the agent callback goroutine to safely send messages into the Bubble Tea event loop.


Testing

go test ./pkg/tui/...

Example:

func TestModel_Update_WindowResize(t *testing.T) {
    m := InitialModel(0)
    updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
    result := updated.(Model)
    if result.width != 120 {
        t.Error("width not updated")
    }
}

Performance Notes

  • Viewport content is only re-rendered when logs changes
  • Streaming chunks are appended directly to the last log entry — no full re-render per chunk
  • Log entries are capped at 1000 to prevent memory growth in long sessions
  • Markdown rendering (Glamour) is done once per response entry, not on every frame

Documentation

Overview

Package tui provides the terminal user interface for Falcon. It uses Bubble Tea for the TUI framework with a minimal, Claude Code-inspired design.

File organization: - app.go: Entry point (Run function) - model.go: Model struct and message types - init.go: Model initialization and tool registration - update.go: Event handling and state updates - view.go: Rendering and display logic - keys.go: Keyboard input handling - styles.go: Visual styling (colors, borders, etc.) - highlight.go: JSON syntax highlighting

Index

Constants

View Source
const (
	ContentPadLeft  = 2 // Left padding for viewport content
	ContentPadRight = 2 // Right padding for viewport content
)

Content layout constants

View Source
const (
	ThinkingPrefix    = "  thinking "
	ToolPrefix        = "  tool "
	ObservationPrefix = "  result "
	ErrorPrefix       = "  error "
	Separator         = "───"
	ToolCallPrefix    = "○ " // Circle prefix for tool calls (legacy)
)

Log prefixes

View Source
const FalconASCII = `` /* 781-byte string literal not displayed */

Variables

View Source
var (
	// USER: Adjust these colors to change the theme
	DimColor     = lipgloss.Color("#6c6c6c")
	TextColor    = lipgloss.Color("#e0e0e0")
	AccentColor  = lipgloss.Color("#7aa2f7") // The blue cursor/spinner color
	ErrorColor   = lipgloss.Color("#f7768e")
	ToolColor    = lipgloss.Color("#9ece6a")
	MutedColor   = lipgloss.Color("#545454")
	SuccessColor = lipgloss.Color("#73daca")
	WarningColor = lipgloss.Color("#e0af68") // Yellow/orange for warnings

	// OpenCode-style colors
	UserMessageBg = lipgloss.Color("#2a2a2a") // Gray background for user messages
	InputAreaBg   = lipgloss.Color("#2a2a2a") // Matches user messages
	FooterBg      = lipgloss.Color("#1a1a1a") // Darker footer
	ModelBadgeBg  = lipgloss.Color("#565f89") // Model name badge

	// Compact tool call colors
	ToolNameColor = lipgloss.Color("#cf8a6b") // Warm orange for tool names
	ToolArgsColor = lipgloss.Color("#6c6c6c") // Dim for arguments
	ToolUseColor  = lipgloss.Color("#545454") // Very muted for usage fraction

	// Response card
	ResponseCardBg     = lipgloss.Color("#1e1e2e") // Slightly elevated background
	ResponseCardBorder = lipgloss.Color("#3b3b5c") // Subtle border
)

Minimal color palette

View Source
var (
	UserStyle = lipgloss.NewStyle().
				Foreground(TextColor)

	ThinkingStyle = lipgloss.NewStyle().
					Foreground(DimColor).
					Italic(true)

	ToolStyle = lipgloss.NewStyle().
				Foreground(ToolColor)

	ObservationStyle = lipgloss.NewStyle().
						Foreground(DimColor)

	ResponseStyle = lipgloss.NewStyle().
					Foreground(TextColor)

	ErrorStyle = lipgloss.NewStyle().
				Foreground(ErrorColor)

	RetryStyle = lipgloss.NewStyle().
				Foreground(lipgloss.Color("#e0af68"))

	// Interrupted style - faded/muted for agent interruption
	InterruptedStyle = lipgloss.NewStyle().
						Foreground(MutedColor).
						Italic(true)

	PromptStyle = lipgloss.NewStyle().
				Foreground(AccentColor)

	HelpStyle = lipgloss.NewStyle().
				Foreground(DimColor)

	// Status line styles
	StatusIdleStyle = lipgloss.NewStyle().
					Foreground(DimColor)

	StatusActiveStyle = lipgloss.NewStyle().
						Foreground(AccentColor)

	StatusToolStyle = lipgloss.NewStyle().
					Foreground(ToolColor)

	// Status label style (for "thinking", "streaming", "tool calling")
	StatusLabelStyle = lipgloss.NewStyle().
						Foreground(DimColor)

	// Separator style
	SeparatorStyle = lipgloss.NewStyle().
					Foreground(MutedColor)

	// Shortcut key style
	ShortcutKeyStyle = lipgloss.NewStyle().
						Foreground(AccentColor)

	ShortcutDescStyle = lipgloss.NewStyle().
						Foreground(DimColor)

	// Footer specific styles (OpenCode style)
	FooterAppNameStyle = lipgloss.NewStyle().
						Foreground(AccentColor).
						Bold(true).
						PaddingRight(1)

	FooterModelStyle = lipgloss.NewStyle().
						Foreground(DimColor).
						PaddingRight(1)

	FooterEnvStyle = lipgloss.NewStyle().
					Foreground(lipgloss.Color("#4ec9b0")).
					Bold(true).
					PaddingRight(1)

	FooterInfoStyle = lipgloss.NewStyle().
					Foreground(DimColor)

	// Splash screen styles
	SplashStyle = lipgloss.NewStyle().
				Foreground(AccentColor).
				Bold(true).
				MarginLeft(ContentPadLeft).
				MarginTop(1).
				MarginBottom(1)

	SplashInfoStyle = lipgloss.NewStyle().
					Foreground(DimColor).
					MarginLeft(ContentPadLeft)

	SplashVersionStyle = lipgloss.NewStyle().
						Foreground(TextColor).
						Bold(true)
)

Log entry styles

View Source
var (
	// User message: blue left border + gray background + vertical spacing
	UserMessageStyle = lipgloss.NewStyle().
						Background(UserMessageBg).
						BorderStyle(lipgloss.ThickBorder()).
						BorderForeground(AccentColor).
						BorderLeft(true).
						BorderTop(false).
						BorderRight(true).
						BorderBottom(false).
						Padding(1, 2).
						MarginLeft(ContentPadLeft).
						MarginTop(1).
						MarginBottom(1)

	// Tool block: groups all tool calls in one agent turn into a styled container
	ToolBlockStyle = lipgloss.NewStyle().
					BorderStyle(lipgloss.ThickBorder()).
					BorderForeground(ToolNameColor).
					BorderLeft(true).
					BorderTop(false).
					BorderRight(false).
					BorderBottom(false).
					MarginLeft(ContentPadLeft)

	// Compact tool call styles
	ToolNameCompactStyle = lipgloss.NewStyle().
							Foreground(ToolNameColor)

	ToolArgsCompactStyle = lipgloss.NewStyle().
							Foreground(ToolArgsColor)

	ToolUsageCompactStyle = lipgloss.NewStyle().
							Foreground(ToolUseColor)

	ToolDurationStyle = lipgloss.NewStyle().
						Foreground(MutedColor)

	// Tool calls: dimmed with circle prefix (legacy, kept for compatibility)
	ToolCallStyle = lipgloss.NewStyle().
					Foreground(DimColor)

	// Agent messages: plain text with left margin + top spacing
	AgentMessageStyle = lipgloss.NewStyle().
						Foreground(TextColor).
						MarginLeft(ContentPadLeft).
						MarginTop(1)

	// System messages: single compact line for status updates (model switch, etc.)
	SystemMessageStyle = lipgloss.NewStyle().
						Foreground(DimColor).
						Italic(true).
						MarginLeft(ContentPadLeft)

	// Response card: subtle box for tool output/responses
	ResponseCardStyle = lipgloss.NewStyle().
						Background(ResponseCardBg).
						BorderStyle(lipgloss.RoundedBorder()).
						BorderForeground(ResponseCardBorder).
						Padding(1, 2).
						MarginLeft(2)

	// Input area: matches user message style exactly (same borders, padding, margin)
	InputAreaStyle = lipgloss.NewStyle().
					Background(InputAreaBg).
					BorderStyle(lipgloss.ThickBorder()).
					BorderForeground(AccentColor).
					BorderLeft(true).
					BorderTop(false).
					BorderRight(true).
					BorderBottom(false).
					Padding(1, 2).
					MarginLeft(ContentPadLeft)

	// Footer bar style
	FooterStyle = lipgloss.NewStyle().
				Background(FooterBg).
				Foreground(DimColor).
				PaddingLeft(2)

	// Model badge
	ModelBadgeStyle = lipgloss.NewStyle().
					Background(ModelBadgeBg).
					Foreground(TextColor).
					Padding(0, 1)
)

OpenCode-style message block styles

View Source
var (
	// Normal usage (green)
	ToolUsageNormalStyle = lipgloss.NewStyle().
							Foreground(ToolColor)

	// Warning usage (70-89% - yellow)
	ToolUsageWarningStyle = lipgloss.NewStyle().
							Foreground(WarningColor)

	// Critical usage (90%+ - red)
	ToolUsageCriticalStyle = lipgloss.NewStyle().
							Foreground(ErrorColor)

	// Tool name in usage display
	ToolUsageNameStyle = lipgloss.NewStyle().
						Foreground(DimColor)

	// Total usage style
	TotalUsageStyle = lipgloss.NewStyle().
					Foreground(AccentColor)
)

Tool usage display styles

View Source
var (
	DiffAddColor    = lipgloss.Color("#73daca") // Green - added lines
	DiffRemoveColor = lipgloss.Color("#f7768e") // Red - removed lines
	DiffHunkColor   = lipgloss.Color("#7aa2f7") // Blue - hunk headers @@
	DiffHeaderColor = lipgloss.Color("#e0af68") // Yellow - file headers ---/+++
)

Diff colors for file write confirmation

View Source
var (
	DiffAddStyle = lipgloss.NewStyle().
					Foreground(DiffAddColor)

	DiffRemoveStyle = lipgloss.NewStyle().
					Foreground(DiffRemoveColor)

	DiffHunkStyle = lipgloss.NewStyle().
					Foreground(DiffHunkColor)

	DiffHeaderStyle = lipgloss.NewStyle().
					Foreground(DiffHeaderColor).
					Bold(true)

	DiffContextStyle = lipgloss.NewStyle().
						Foreground(DimColor)
)

Diff styles

View Source
var (
	TagChipStyle = lipgloss.NewStyle().
					Foreground(AccentColor).
					Faint(true).
					MarginLeft(ContentPadLeft)

	SlashPanelStyle = lipgloss.NewStyle().
					MarginLeft(ContentPadLeft)

	SlashItemSelectedStyle = lipgloss.NewStyle().
							Foreground(AccentColor).
							Bold(true)

	SlashItemStyle = lipgloss.NewStyle().
					Foreground(DimColor)

	SlashItemKindStyle = lipgloss.NewStyle().
						Foreground(MutedColor).
						Italic(true)
)

Slash command panel styles

View Source
var (
	ConfirmHeaderStyle = lipgloss.NewStyle().
						Foreground(WarningColor).
						Bold(true)

	ConfirmPathStyle = lipgloss.NewStyle().
						Foreground(AccentColor)

	ConfirmFooterStyle = lipgloss.NewStyle().
						Background(FooterBg).
						Padding(0, 1)

	ConfirmApproveStyle = lipgloss.NewStyle().
						Foreground(SuccessColor).
						Bold(true)

	ConfirmRejectStyle = lipgloss.NewStyle().
						Foreground(ErrorColor).
						Bold(true)
)

Confirmation dialog styles

View Source
var PulseColors = []lipgloss.Color{
	"#2a2f4e",
	"#3b4570",
	"#4c5a92",
	"#5d70b4",
	"#6e86d6",
	"#7aa2f7",
	"#6e86d6",
	"#5d70b4",
	"#4c5a92",
	"#3b4570",
}

Pulse animation colors for status circle (dim blue → bright blue → dim blue)

Functions

func HighlightJSON

func HighlightJSON(input string) string

HighlightJSON takes a JSON string, validates it, and returns a syntax-highlighted string. If the input is not valid JSON, it returns the original string.

func Run

func Run() error

Run starts the TUI application.

Types

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is the Bubble Tea model for the Falcon TUI. It manages the state of the terminal interface including: - viewport for scrollable message history - textinput for user input - spinner for loading states - agent for LLM interaction

func InitialModel

func InitialModel() Model

InitialModel creates and returns the initial TUI model.

func (Model) Init

func (m Model) Init() tea.Cmd

Init initializes the Bubble Tea model. This is called once when the program starts.

func (Model) Update

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update handles all messages and updates the model state. This is the main event loop handler for the Bubble Tea application.

func (Model) View

func (m Model) View() string

View renders the entire TUI to a string. This is called by Bubble Tea on every update.

type SlashCommand

type SlashCommand struct {
	Name        string // "model", flow filename, or request filename
	Description string
	Kind        string // "builtin" | "flow" | "request"
}

SlashCommand represents a command available via "/" prefix

type SlashState

type SlashState struct {
	Active      bool
	Query       string
	Suggestions []SlashCommand
	Selected    int
	FlowContent string // loaded file content (after selection, cleared after enter)
	TaggedFile  string // filename of the tagged file (shown as chip until message is sent)
	// contains filtered or unexported fields
}

SlashState tracks current slash command panel state

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL