Files
kit/internal/ui/model.go
T
Ed Zynda af486133a5 chore: remove dead code, unexport internal symbols, clean up stale comments
- Remove never-called functions: ListChildSessions, NewMessageEntryFromRaw,
  ProviderPool.Stats/PoolStats, CLI.DisplayToolCallMessage
- Remove deprecated ValidateModel (migrate callers to LookupModel)
- Remove deprecated colon-separated model format shim
- Unexport package-internal symbols: EstimateTokens, GetRequiredEnvVars,
  GeneratePKCE, ErrNoClipboardTool, ThinkingBudgetTokens, NewMessageRenderer
- Remove stale TAS-15/TAS-16 placeholder comments (both fully implemented)
- Fix misleading 'temporary approach' comment in clipboard_darwin.go
- Replace interface{} with any in extension examples
- Simplify auto-commit.go dead variable (CombinedOutput → Run)
2026-03-19 17:25:53 +03:00

2955 lines
98 KiB
Go

package ui
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/core"
"github.com/mark3labs/kit/internal/models"
"github.com/mark3labs/kit/internal/session"
)
// appState represents the current state of the parent TUI model.
type appState int
const (
// stateInput is the default state: input is focused and the user is waiting
// to type. The agent is not running.
stateInput appState = iota
// stateWorking means the agent is running. The stream component is active.
// The input component remains visible and editable for queueing messages.
stateWorking
// stateTreeSelector means the /tree viewer is active.
stateTreeSelector
// statePrompt means an extension-triggered interactive prompt is active.
// The prompt overlay takes full focus until the user completes or cancels.
statePrompt
// stateOverlay means an extension-triggered modal overlay dialog is active.
// The overlay takes over the full view until the user completes or cancels.
stateOverlay
// stateModelSelector means the /model selector overlay is active.
stateModelSelector
)
// AppController is the interface the parent TUI model uses to interact with the
// app layer. It is satisfied by *app.App once that is created (TAS-4).
// Using an interface here keeps model.go compilable before app.App exists, and
// makes the parent model easily testable with a mock.
type AppController interface {
// Run queues or immediately starts a new agent step with the given prompt.
// Returns the current queue depth: 0 means the prompt started immediately
// (or the app is closed), >0 means it was queued. The caller must update
// UI state (e.g. queueCount) based on the return value — Run does NOT
// send events to the program to avoid deadlocking when called from
// within Update().
Run(prompt string) int
// CancelCurrentStep cancels any in-progress agent step.
CancelCurrentStep()
// QueueLength returns the number of prompts currently waiting in the queue.
QueueLength() int
// ClearQueue discards all queued prompts. The caller must update UI state
// (e.g. queueCount) — ClearQueue does NOT send events to the program to
// avoid deadlocking when called from within Update().
ClearQueue()
// ClearMessages clears the conversation history.
ClearMessages()
// CompactConversation summarises older messages to free context space.
// Runs asynchronously; results are delivered via CompactCompleteEvent or
// CompactErrorEvent sent through the registered tea.Program. Returns an
// error synchronously if compaction cannot be started (e.g. agent is busy).
// customInstructions is optional text appended to the summary prompt.
CompactConversation(customInstructions string) error
// GetTreeSession returns the tree session manager, or nil if tree sessions
// are not enabled. Used by slash commands like /tree, /fork, /session.
GetTreeSession() *session.TreeManager
// SendEvent sends a tea.Msg to the program asynchronously. Safe to call
// from any goroutine. Used by extension command goroutines to deliver
// results back to the TUI without going through tea.Cmd (which can stall
// when the goroutine blocks on interactive prompts).
SendEvent(tea.Msg)
// AddContextMessage adds a user-role message to the conversation history
// without triggering an LLM response. Used by the ! shell command prefix
// to inject command output into context so the LLM can reference it in
// subsequent turns.
AddContextMessage(text string)
// RunWithFiles queues a multimodal prompt (text + images) for execution.
// Behaves like Run but includes file parts (e.g. clipboard images)
// alongside the text. Returns the current queue depth (0 = started
// immediately, >0 = queued).
RunWithFiles(prompt string, files []fantasy.FilePart) int
}
// SkillItem holds display metadata about a loaded skill for the startup
// [Skills] section. Built by the CLI layer from the SDK's []*kit.Skill.
type SkillItem struct {
Name string // Skill name (e.g. "btca-cli").
Path string // Absolute path to the skill file.
Source string // "project" or "user" (global).
}
// ToolRendererData holds extension-provided rendering functions for a specific
// tool. The UI layer uses this to override the default tool header/body
// rendering without depending on the extensions package directly.
type ToolRendererData struct {
// DisplayName, if non-empty, replaces the auto-capitalized tool name
// in the header line.
DisplayName string
// BorderColor, if non-empty, overrides the default success/error border
// color. Hex string (e.g. "#89b4fa").
BorderColor string
// Background, if non-empty, sets a background color for the tool block.
// Hex string (e.g. "#1e1e2e").
Background string
// BodyMarkdown, when true, renders the RenderBody output as markdown
// via glamour. Ignored when RenderBody is nil or returns empty.
BodyMarkdown bool
// RenderHeader, if non-nil, replaces the default parameter formatting
// in the tool header line. Receives the JSON-encoded arguments and max
// width. Return a short summary string, or empty to fall back to default.
RenderHeader func(toolArgs string, width int) string
// RenderBody, if non-nil, replaces the default tool result body. Receives
// the result text, error flag, and available width. Return the full styled
// body content, or empty to fall back to builtin/default renderer.
RenderBody func(toolResult string, isError bool, width int) string
}
// ---------------------------------------------------------------------------
// Editor interceptor types (UI-layer, decoupled from extensions package)
// ---------------------------------------------------------------------------
// EditorKeyActionType defines the outcome of an editor key interception.
// Mirrors extensions.EditorKeyActionType for package decoupling.
type EditorKeyActionType string
const (
// EditorKeyPassthrough lets the built-in editor handle the key normally.
EditorKeyPassthrough EditorKeyActionType = "passthrough"
// EditorKeyConsumed means the extension handled the key.
EditorKeyConsumed EditorKeyActionType = "consumed"
// EditorKeyRemap transforms the key into a different key.
EditorKeyRemap EditorKeyActionType = "remap"
// EditorKeySubmit forces immediate text submission.
EditorKeySubmit EditorKeyActionType = "submit"
)
// EditorKeyAction is the UI-layer equivalent of extensions.EditorKeyAction.
type EditorKeyAction struct {
// Type determines the action taken.
Type EditorKeyActionType
// RemappedKey is the target key name for EditorKeyRemap.
RemappedKey string
// SubmitText is the text to submit for EditorKeySubmit.
SubmitText string
}
// EditorInterceptor is the UI-layer representation of an extension editor
// interceptor. It decouples the UI package from the extensions package.
// The CLI layer converts the extension EditorConfig to this type.
type EditorInterceptor struct {
// HandleKey intercepts key presses before the built-in editor.
HandleKey func(key string, currentText string) EditorKeyAction
// Render wraps the built-in editor's rendered output.
Render func(width int, defaultContent string) string
}
// WidgetData is the UI-layer representation of an extension widget. It
// decouples the UI package from the extensions package. The CLI layer
// converts extension WidgetConfig values to WidgetData for rendering.
type WidgetData struct {
// Text is the content to display.
Text string
// Markdown, when true, renders Text as styled markdown.
Markdown bool
// BorderColor is a hex color (e.g. "#a6e3a1") for the left border.
// Empty uses the theme's default accent color.
BorderColor string
// NoBorder disables the left border entirely.
NoBorder bool
}
// StatusBarEntryData represents a keyed extension entry in the TUI status bar.
// Multiple entries from different extensions coexist, ordered by Priority
// (lower values render further left).
type StatusBarEntryData struct {
Key string // unique identifier (e.g. "myext:git-branch")
Text string // rendered content shown in the status bar
Priority int // lower = further left; built-in entries use 100-110
}
// UIVisibility controls which built-in TUI chrome elements are visible.
// The zero value shows everything (backward compatible).
type UIVisibility struct {
HideStartupMessage bool // Hide the "Model loaded..." startup block
HideStatusBar bool // Hide the "provider · model Tokens: ..." line
HideSeparator bool // Hide the "────────" divider between stream and input
HideInputHint bool // Hide the "enter submit · ctrl+j..." hint below input
}
// AppModelOptions holds configuration passed to NewAppModel.
type AppModelOptions struct {
// CompactMode selects the compact renderer for message formatting.
CompactMode bool
// ModelName is the display name of the model (e.g. "claude-sonnet-4-5").
ModelName string
// ProviderName is the LLM provider (e.g. "anthropic", "openai").
// Used for the startup "Model loaded" message.
ProviderName string
// LoadingMessage is an optional informational message from the agent
// (e.g. GPU fallback info). Displayed at startup when non-empty.
LoadingMessage string
// Cwd is the working directory for @file autocomplete and path resolution.
// If empty, @file features are disabled.
Cwd string
// Width is the initial terminal width in columns.
Width int
// Height is the initial terminal height in rows.
Height int
// ServerNames holds loaded MCP server names for the /servers command.
ServerNames []string
// ToolNames holds available tool names for the /tools command.
ToolNames []string
// UsageTracker provides token usage statistics for /usage and /reset-usage.
// May be nil if usage tracking is unavailable for the current model.
UsageTracker *UsageTracker
// ExtensionCommands are slash commands registered by extensions. They
// appear in autocomplete, /help, and are dispatched when submitted.
ExtensionCommands []ExtensionCommand
// ContextPaths lists absolute paths of loaded context files (e.g.
// AGENTS.md). Displayed in the [Context] startup section.
ContextPaths []string
// SkillItems lists loaded skills for the [Skills] startup section.
SkillItems []SkillItem
// MCPToolCount is the number of tools loaded from external MCP servers.
MCPToolCount int
// ExtensionToolCount is the number of tools registered by extensions.
ExtensionToolCount int
// GetWidgets returns current extension widgets for a given placement
// ("above" or "below"). Called during View() to render persistent
// extension widgets. May be nil if no extensions are loaded.
GetWidgets func(placement string) []WidgetData
// GetHeader returns the current custom header set by an extension, or
// nil if no header is active. Called during View() to render a
// persistent header above the stream region. May be nil.
GetHeader func() *WidgetData
// GetFooter returns the current custom footer set by an extension, or
// nil if no footer is active. Called during View() to render a
// persistent footer below the status bar. May be nil.
GetFooter func() *WidgetData
// GetToolRenderer returns the extension-provided tool renderer for a
// specific tool name, or nil if no custom renderer is registered.
// Called during tool result rendering to check for custom formatting.
// May be nil if no extensions are loaded.
GetToolRenderer func(toolName string) *ToolRendererData
// GetEditorInterceptor returns the current editor interceptor set by
// an extension, or nil if none is active. Called during Update() to
// intercept key events and during View() to wrap input rendering.
// May be nil if no extensions are loaded.
GetEditorInterceptor func() *EditorInterceptor
// GetUIVisibility returns the current UI visibility overrides set by
// an extension, or nil if none have been set (show everything).
// Called during View() and PrintStartupInfo() to conditionally hide
// built-in chrome elements. May be nil if no extensions are loaded.
GetUIVisibility func() *UIVisibility
// GetStatusBarEntries returns extension-provided status bar entries,
// sorted by priority. Called during renderStatusBar() to inject
// extension entries alongside the built-in model/usage display.
// May be nil if no extensions are loaded.
GetStatusBarEntries func() []StatusBarEntryData
// EmitBeforeFork, if non-nil, is called before branching to a
// different session tree entry. Returns (cancelled, reason) where
// cancelled=true means the fork should be aborted. May be nil if
// no extensions are loaded.
EmitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
// EmitBeforeSessionSwitch, if non-nil, is called before switching
// to a new session branch (e.g. /new, /clear). Returns (cancelled,
// reason). May be nil if no extensions are loaded.
EmitBeforeSessionSwitch func(reason string) (bool, string)
// GetGlobalShortcuts, if non-nil, returns extension-registered global
// keyboard shortcuts. Keys are binding strings (e.g., "ctrl+p").
// Handlers are called in a goroutine to avoid blocking the TUI event
// loop. May be nil if no extensions are loaded.
GetGlobalShortcuts func() map[string]func()
// GetExtensionCommands, if non-nil, returns the current extension
// commands. Called on WidgetUpdateEvent to refresh the command list
// after an extension hot-reload. May be nil if no extensions loaded.
GetExtensionCommands func() []ExtensionCommand
// SetModel changes the active model at runtime. The model string uses
// "provider/model" format (e.g. "anthropic/claude-sonnet-4-5-20250929").
// Returns an error if the model string is invalid or the provider cannot
// be created. May be nil if model switching is not supported.
SetModel func(modelString string) error
// EmitModelChange fires the OnModelChange extension event after a
// successful model switch. Parameters are (newModel, previousModel, source).
// May be nil if extensions are not loaded.
EmitModelChange func(newModel, previousModel, source string)
// ThinkingLevel is the initial thinking level (e.g. "off", "medium").
ThinkingLevel string
// IsReasoningModel is true when the current model supports reasoning.
IsReasoningModel bool
// SetThinkingLevel changes the thinking level on the agent/provider.
SetThinkingLevel func(level string) error
}
// AppModel is the root Bubble Tea model for the interactive TUI. It owns the
// state machine, routes events to child components, and manages the overall
// layout. It holds a reference to the app layer (AppController) for triggering
// agent work and queue operations.
//
// Layout (stacked, no alt screen):
//
// ┌─ [custom header] (optional, from extension) ──────┐
// ├─ stream region (variable height) ─────────────────┤
// │ │
// ├─ separator line (with optional queue count) ───────┤
// │ [above widgets] │
// │ queued How do I fix the build? │
// │ queued Also check the tests │
// ├─ input region (fixed height from textarea) ────────┤
// │ [below widgets] │
// │ Tokens: 23.4K (12%) | Cost: $0.00 provider·model │
// ├─ [custom footer] (optional, from extension) ──────┤
// └────────────────────────────────────────────────────┘
//
// The status bar is always present (1 line) to avoid layout shifts that
// occurred when usage info appeared/disappeared conditionally.
//
// Completed responses are emitted above the BT-managed region via tea.Println()
// before the model resets for the next interaction.
type AppModel struct {
// state is the current state machine state.
state appState
// appCtrl is the app layer reference. Used to call Run(), CancelCurrentStep(), etc.
// Accepts *app.App via the AppController interface.
appCtrl AppController
// input is the child input component (slash commands + autocomplete).
input inputComponentIface
// stream is the child streaming display component (spinner + streaming text).
stream streamComponentIface
// renderer renders completed messages for tea.Println output. It is either
// a *MessageRenderer (standard mode) or a *CompactRenderer (compact mode),
// chosen at construction time via the Renderer interface.
renderer Renderer
// compactMode is retained for StreamComponent selection and any remaining
// mode-specific logic (e.g. startup info formatting).
compactMode bool
// modelName is the LLM model name shown in rendered messages.
modelName string
// queuedMessages stores the text of prompts that were queued (not yet
// submitted to the agent). They are rendered with a "queued" badge above
// the input and move to scrollback when the agent picks them up.
queuedMessages []string
// pendingUserPrints holds user messages that have been consumed from the
// queue but not yet printed to scrollback. They are deferred until
// SpinnerEvent{Show: true} so the previous assistant response can be
// flushed first, preserving chronological order.
pendingUserPrints []string
// scrollbackBuf collects rendered content during a single Update() call.
// All print helpers append here instead of returning tea.Println directly.
// The buffer is drained into a single atomic tea.Println at the end of
// each Update call via drainScrollback(). If the stream component has
// unflushed content, it is automatically prepended so that new messages
// always appear below the previous assistant response.
scrollbackBuf []string
// canceling tracks whether the user has pressed ESC once during stateWorking.
// A second ESC within 2 seconds will cancel the current step.
canceling bool
// providerName is the LLM provider for the startup message.
providerName string
// loadingMessage is an optional agent startup message (e.g. GPU fallback).
loadingMessage string
// serverNames, toolNames are used by /servers and /tools commands.
serverNames []string
toolNames []string
// usageTracker provides token usage stats for /usage and /reset-usage.
// May be nil when usage tracking is unavailable.
usageTracker *UsageTracker
// extensionCommands are slash commands from extensions, dispatched via
// handleExtensionCommand when submitted.
extensionCommands []ExtensionCommand
// treeSelector is the tree navigation overlay, active in stateTreeSelector.
treeSelector *TreeSelectorComponent
// contextPaths and skillItems are used by PrintStartupInfo for the
// [Context] and [Skills] sections.
contextPaths []string
skillItems []SkillItem
// mcpToolCount and extensionToolCount track tool counts by source for
// the startup info display.
mcpToolCount int
extensionToolCount int
// getWidgets returns extension widgets for a given placement. May be nil.
getWidgets func(placement string) []WidgetData
// getHeader returns the current custom header. May be nil.
getHeader func() *WidgetData
// getFooter returns the current custom footer. May be nil.
getFooter func() *WidgetData
// getEditorInterceptor returns the current editor interceptor. May be nil.
getEditorInterceptor func() *EditorInterceptor
// getUIVisibility returns extension-provided UI visibility overrides. May be nil.
getUIVisibility func() *UIVisibility
// getStatusBarEntries returns extension-provided status bar entries. May be nil.
getStatusBarEntries func() []StatusBarEntryData
// emitBeforeFork emits a before-fork event to extensions. Returns
// (cancelled, reason). May be nil if no extensions are loaded.
emitBeforeFork func(targetID string, isUserMsg bool, userText string) (bool, string)
// emitBeforeSessionSwitch emits a before-session-switch event to extensions.
// Returns (cancelled, reason). May be nil if no extensions are loaded.
emitBeforeSessionSwitch func(reason string) (bool, string)
// thinkingLevel is the current extended thinking level.
thinkingLevel string
// thinkingVisible controls whether reasoning blocks are shown or collapsed.
thinkingVisible bool
// isReasoningModel is true when the current model supports reasoning.
isReasoningModel bool
// setThinkingLevel is a callback to change the thinking level on the agent.
// It takes the new level string and returns an error if the change fails.
setThinkingLevel func(level string) error
// getGlobalShortcuts returns extension-registered keyboard shortcuts.
// May be nil if no extensions are loaded.
getGlobalShortcuts func() map[string]func()
// getExtensionCommands returns the current extension commands. Used
// to refresh the command list after an extension hot-reload. May be nil.
getExtensionCommands func() []ExtensionCommand
// setModel changes the active model at runtime. Wired from cmd/root.go.
// May be nil if model switching is not supported.
setModel func(modelString string) error
// emitModelChange fires the OnModelChange extension event. May be nil.
emitModelChange func(newModel, previousModel, source string)
// modelSelector is the model selection overlay, active in stateModelSelector.
modelSelector *ModelSelectorComponent
// prompt holds the state of an active interactive prompt overlay. Nil
// when no prompt is active. Managed by updatePromptState().
prompt *promptOverlay
// promptResponseCh is the write-side of the channel used to deliver the
// user's prompt answer back to the blocking extension goroutine. Set
// alongside prompt; nil when no prompt is active.
promptResponseCh chan<- app.PromptResponse
// prePromptState remembers the state before the prompt overlay took
// over, so the model can return to it when the prompt completes.
prePromptState appState
// overlay holds the state of an active modal overlay dialog. Nil when
// no overlay is active. Managed by updateOverlayState().
overlay *overlayDialog
// overlayResponseCh is the write-side of the channel used to deliver
// the user's overlay response back to the blocking extension goroutine.
// Set alongside overlay; nil when no overlay is active.
overlayResponseCh chan<- app.OverlayResponse
// preOverlayState remembers the state before the overlay took over,
// so the model can return to it when the overlay completes.
preOverlayState appState
// cwd is the working directory for @file path resolution.
cwd string
// width and height track the terminal dimensions.
width int
height int
}
// --------------------------------------------------------------------------
// Child component interfaces (stubs until TAS-15/16/17 implement them)
// --------------------------------------------------------------------------
// inputComponentIface is the interface the parent requires from InputComponent.
// It will be satisfied by the real InputComponent created in TAS-15.
type inputComponentIface interface {
tea.Model
}
// streamComponentIface is the interface the parent requires from StreamComponent.
// It will be satisfied by the real StreamComponent created in TAS-16.
type streamComponentIface interface {
tea.Model
// Reset clears accumulated state between agent steps.
Reset()
// SetHeight constrains the render output to at most h lines (0 = unconstrained).
SetHeight(h int)
// GetRenderedContent returns the rendered assistant message from accumulated
// streaming text, or empty string if nothing has been accumulated.
GetRenderedContent() string
// SpinnerView returns the rendered spinner line (animation + optional label).
// Returns "" when the spinner is not active. The parent renders this in the
// status bar so the spinner never changes the view height.
SpinnerView() string
// SetThinkingVisible sets whether reasoning blocks are shown or collapsed.
SetThinkingVisible(visible bool)
// HasReasoning returns true if any reasoning content has been accumulated.
HasReasoning() bool
}
// --------------------------------------------------------------------------
// Constructor
// --------------------------------------------------------------------------
// NewAppModel creates a new AppModel. The appCtrl parameter must not be nil.
// opts provides display configuration; zero values are valid (uses defaults).
//
// To use with the concrete *app.App type, pass it directly — *app.App
// satisfies AppController once the app layer is implemented (TAS-4).
//
// NewAppModel constructs all child components (InputComponent, StreamComponent)
// using the provided options.
func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
width := opts.Width
if width == 0 {
width = 80 // sensible fallback
}
height := opts.Height
if height == 0 {
height = 24 // sensible fallback
}
// Choose the renderer implementation based on compact mode.
var rdr Renderer
if opts.CompactMode {
cr := NewCompactRenderer(width, false)
cr.getToolRenderer = opts.GetToolRenderer
rdr = cr
} else {
mr := newMessageRenderer(width, false)
mr.getToolRenderer = opts.GetToolRenderer
rdr = mr
}
m := &AppModel{
state: stateInput,
appCtrl: appCtrl,
renderer: rdr,
compactMode: opts.CompactMode,
modelName: opts.ModelName,
providerName: opts.ProviderName,
loadingMessage: opts.LoadingMessage,
serverNames: opts.ServerNames,
toolNames: opts.ToolNames,
usageTracker: opts.UsageTracker,
cwd: opts.Cwd,
width: width,
height: height,
}
// Store extension commands for dispatch.
m.extensionCommands = opts.ExtensionCommands
m.getWidgets = opts.GetWidgets
m.getHeader = opts.GetHeader
m.getFooter = opts.GetFooter
m.getEditorInterceptor = opts.GetEditorInterceptor
m.getUIVisibility = opts.GetUIVisibility
m.getStatusBarEntries = opts.GetStatusBarEntries
m.emitBeforeFork = opts.EmitBeforeFork
m.emitBeforeSessionSwitch = opts.EmitBeforeSessionSwitch
m.getGlobalShortcuts = opts.GetGlobalShortcuts
m.getExtensionCommands = opts.GetExtensionCommands
m.setModel = opts.SetModel
m.emitModelChange = opts.EmitModelChange
m.thinkingLevel = opts.ThinkingLevel
m.thinkingVisible = true // default to showing thinking blocks
m.isReasoningModel = opts.IsReasoningModel
m.setThinkingLevel = opts.SetThinkingLevel
// Store context/skills metadata and tool counts for startup display.
m.contextPaths = opts.ContextPaths
m.skillItems = opts.SkillItems
m.mcpToolCount = opts.MCPToolCount
m.extensionToolCount = opts.ExtensionToolCount
// Wire up child components now that we have the concrete implementations.
m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl)
// Wire up cwd for @file autocomplete.
if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" {
ic.SetCwd(opts.Cwd)
}
// Merge extension commands into the InputComponent's autocomplete source.
if ic, ok := m.input.(*InputComponent); ok && len(opts.ExtensionCommands) > 0 {
for _, ec := range opts.ExtensionCommands {
ic.commands = append(ic.commands, SlashCommand{
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
Complete: ec.Complete,
})
}
}
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
m.stream.SetThinkingVisible(m.thinkingVisible)
// Propagate initial height distribution to children.
m.distributeHeight()
return m
}
// --------------------------------------------------------------------------
// tea.Model interface
// --------------------------------------------------------------------------
// Init implements tea.Model. Initialises child components. Startup info is
// printed to stdout before the program starts via PrintStartupInfo().
func (m *AppModel) Init() tea.Cmd {
var cmds []tea.Cmd
if m.input != nil {
cmds = append(cmds, m.input.Init())
}
if m.stream != nil {
cmds = append(cmds, m.stream.Init())
}
return tea.Batch(cmds...)
}
// uiVis returns the current UIVisibility, defaulting to zero value (show all)
// if no extension has set visibility overrides.
func (m *AppModel) uiVis() UIVisibility {
if m.getUIVisibility != nil {
if v := m.getUIVisibility(); v != nil {
return *v
}
}
return UIVisibility{}
}
// PrintStartupInfo prints the startup banner (model name, context, skills,
// tool counts) to stdout. Call this before program.Run() so the messages are
// visible above the Bubble Tea managed region.
//
// All startup information is rendered inside a single system message block.
func (m *AppModel) PrintStartupInfo() {
if m.uiVis().HideStartupMessage {
return
}
render := func(text string) string {
return m.renderer.RenderSystemMessage(text, time.Now()).Content
}
fmt.Println()
// Build the combined startup content.
var lines []string
if m.providerName != "" && m.modelName != "" {
lines = append(lines, fmt.Sprintf("Model loaded: %s (%s)", m.providerName, m.modelName))
}
if m.loadingMessage != "" {
lines = append(lines, m.loadingMessage)
}
// Context — loaded AGENTS.md files.
if len(m.contextPaths) > 0 {
for _, p := range m.contextPaths {
lines = append(lines, fmt.Sprintf("Context: %s", tildeHome(p)))
}
}
// Skills — listed by name.
if len(m.skillItems) > 0 {
names := make([]string, len(m.skillItems))
for i, si := range m.skillItems {
names[i] = si.Name
}
lines = append(lines, fmt.Sprintf("Skills: %s", strings.Join(names, ", ")))
}
// Extension tool count (only shown when > 0).
if m.extensionToolCount > 0 {
lines = append(lines, fmt.Sprintf("Loaded %d extension tools", m.extensionToolCount))
}
// MCP tool count (only shown when > 0).
if m.mcpToolCount > 0 {
lines = append(lines, fmt.Sprintf("Loaded %d tools from MCP servers", m.mcpToolCount))
}
if len(lines) > 0 {
fmt.Println(render(strings.Join(lines, "\n\n")))
}
}
// tildeHome replaces the user's home directory prefix with ~ for display.
func tildeHome(path string) string {
home, err := os.UserHomeDir()
if err != nil {
return path
}
if strings.HasPrefix(path, home) {
return "~" + path[len(home):]
}
return path
}
// Update implements tea.Model. It is the heart of the state machine: it routes
// incoming messages to children and handles state transitions.
func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Prompt overlay takes precedence when active — it is fully modal.
if m.state == statePrompt && m.prompt != nil {
return m.updatePromptState(msg)
}
// Overlay dialog takes precedence when active — it is fully modal.
if m.state == stateOverlay && m.overlay != nil {
return m.updateOverlayState(msg)
}
switch msg := msg.(type) {
// ── Tree selector events ─────────────────────────────────────────────────
case TreeNodeSelectedMsg:
// User selected a node in the tree. Branch to it and return to input.
if ts := m.appCtrl.GetTreeSession(); ts != nil {
// For user messages: branch to parent (so user can resubmit).
// For other entries: branch directly to the selected entry.
targetID := msg.ID
if msg.IsUser {
// Branch to parent of user message, place text in editor.
if node := ts.GetEntry(msg.ID); node != nil {
if me, ok := node.(*session.MessageEntry); ok {
targetID = me.ParentID
}
}
}
// Emit before-fork event in a goroutine so that extension handlers
// can call blocking operations (e.g. ctx.PromptConfirm) without
// deadlocking the BubbleTea event loop.
if m.emitBeforeFork != nil {
emit := m.emitBeforeFork
ctrl := m.appCtrl
forkTargetID := targetID
forkIsUser := msg.IsUser
forkUserText := msg.UserText
go func() {
cancelled, reason := emit(forkTargetID, forkIsUser, forkUserText)
ctrl.SendEvent(beforeForkResultMsg{
cancelled: cancelled,
reason: reason,
targetID: forkTargetID,
isUser: forkIsUser,
userText: forkUserText,
})
}()
m.treeSelector = nil
m.state = stateInput
return m, func() tea.Msg { return nil }
}
cmds = append(cmds, m.performFork(targetID, msg.IsUser, msg.UserText))
}
m.treeSelector = nil
m.state = stateInput
return m, tea.Batch(cmds...)
case TreeCancelledMsg:
m.treeSelector = nil
m.state = stateInput
return m, nil
// ── Model selector events ────────────────────────────────────────────────
case ModelSelectedMsg:
m.modelSelector = nil
m.state = stateInput
if m.setModel != nil {
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(msg.ModelString); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
} else {
// Update display state directly — we cannot use
// NotifyModelChanged (prog.Send) from inside Update()
// without deadlocking BubbleTea.
parts := strings.SplitN(msg.ModelString, "/", 2)
if len(parts) == 2 {
m.providerName = parts[0]
m.modelName = parts[1]
}
m.printSystemMessage(fmt.Sprintf("Switched to %s", msg.ModelString))
if m.emitModelChange != nil {
emit := m.emitModelChange
newModel := msg.ModelString
prev := previousModel
go emit(newModel, prev, "user")
}
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case ModelSelectorCancelledMsg:
m.modelSelector = nil
m.state = stateInput
return m, nil
// ── Window resize ────────────────────────────────────────────────────────
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.distributeHeight()
// Propagate to children.
if m.input != nil {
_, cmd := m.input.Update(msg)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
// ── Keyboard input ───────────────────────────────────────────────────────
case tea.KeyPressMsg:
switch msg.String() {
case "ctrl+c":
// Cancel any active prompt before quitting.
if m.promptResponseCh != nil {
m.promptResponseCh <- app.PromptResponse{Cancelled: true}
m.promptResponseCh = nil
m.prompt = nil
}
// Cancel any active overlay before quitting.
if m.overlayResponseCh != nil {
m.overlayResponseCh <- app.OverlayResponse{Cancelled: true}
m.overlayResponseCh = nil
m.overlay = nil
}
// Graceful quit: app.Close() is deferred in cmd/root.go.
return m, tea.Quit
}
// Check extension-registered global keyboard shortcuts. These fire
// in all app states except modal prompts/overlays (which return early
// above). Matched shortcuts are consumed — the key does not propagate
// to child components.
if m.getGlobalShortcuts != nil {
if shortcuts := m.getGlobalShortcuts(); shortcuts != nil {
if handler, ok := shortcuts[msg.String()]; ok {
// Run in goroutine so blocking extension calls
// (PromptSelect, etc.) don't stall the event loop.
go handler()
return m, tea.Batch(cmds...)
}
}
}
// Thinking keybindings — only when the model supports reasoning.
if m.isReasoningModel {
switch msg.String() {
case "ctrl+t":
// Toggle thinking block visibility.
m.thinkingVisible = !m.thinkingVisible
if m.stream != nil {
m.stream.SetThinkingVisible(m.thinkingVisible)
}
return m, tea.Batch(cmds...)
case "shift+tab":
// Cycle thinking level.
m.cycleThinkingLevel()
return m, tea.Batch(cmds...)
}
}
// Route to tree selector when active.
if m.state == stateTreeSelector && m.treeSelector != nil {
updated, cmd := m.treeSelector.Update(msg)
m.treeSelector = updated.(*TreeSelectorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// Route to model selector when active.
if m.state == stateModelSelector && m.modelSelector != nil {
updated, cmd := m.modelSelector.Update(msg)
m.modelSelector = updated.(*ModelSelectorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
switch msg.String() {
case "esc":
if m.state == stateWorking {
if m.canceling {
// Second ESC within the timer window — cancel the step.
m.canceling = false
if m.appCtrl != nil {
m.appCtrl.CancelCurrentStep()
}
} else {
// First ESC — set canceling, start 2s timer.
m.canceling = true
cmds = append(cmds, cancelTimerCmd())
}
return m, tea.Batch(cmds...)
}
// In other states pass ESC through to children below.
}
// Route key events to the focused child. Check for editor
// interceptor first — it can consume, remap, or force-submit keys.
if m.input != nil {
var intercepted bool
if m.getEditorInterceptor != nil {
if interceptor := m.getEditorInterceptor(); interceptor != nil && interceptor.HandleKey != nil {
var currentText string
if ic, ok := m.input.(*InputComponent); ok {
currentText = ic.textarea.Value()
}
action := interceptor.HandleKey(msg.String(), currentText)
switch action.Type {
case EditorKeyConsumed:
intercepted = true
case EditorKeyRemap:
if remapped, ok := remapKey(action.RemappedKey); ok {
updated, cmd := m.input.Update(remapped)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
intercepted = true
}
// If remap target is unrecognized, fall through to normal handling.
case EditorKeySubmit:
text := action.SubmitText
var images []ImageAttachment
if text == "" {
if ic, ok := m.input.(*InputComponent); ok {
text = strings.TrimSpace(ic.textarea.Value())
images = ic.ClearPendingImages()
ic.textarea.SetValue("")
ic.textarea.CursorEnd()
}
}
if text != "" {
cmds = append(cmds, func() tea.Msg {
return submitMsg{Text: text, Images: images}
})
}
intercepted = true
}
// EditorKeyPassthrough falls through to normal input handling.
}
}
if !intercepted {
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
}
// ── Cancel timer expired ─────────────────────────────────────────────────
case cancelTimerExpiredMsg:
m.canceling = false
// ── Input submitted ──────────────────────────────────────────────────────
case submitMsg:
// Handle slash commands locally — they should never reach app.Run().
if sc := GetCommandByName(msg.Text); sc != nil {
if cmd := m.handleSlashCommand(sc); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// /compact and /model support optional args (e.g. "/compact Focus on API",
// "/model anthropic/claude-haiku-3-5-20241022").
// GetCommandByName won't match the full text, so check the prefix.
if name, args, ok := strings.Cut(msg.Text, " "); ok {
if sc := GetCommandByName(name); sc != nil {
switch sc.Name {
case "/compact":
if cmd := m.handleCompactCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/model":
if cmd := m.handleModelCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
case "/thinking":
if cmd := m.handleThinkingCommand(strings.TrimSpace(args)); cmd != nil {
cmds = append(cmds, cmd)
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
}
}
// Check extension-registered slash commands. These support arguments
// (e.g. "/sub list files"), so we split on the first space.
if cmd := m.handleExtensionCommand(msg.Text); cmd != nil {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
// Regular prompt — forward to the app layer.
// Preprocess @file references: expand them into XML-wrapped file
// content before sending to the agent. The display text (shown in
// scrollback) uses the original user text so the UI stays clean.
processedText := msg.Text
if m.cwd != "" {
processedText = ProcessFileAttachments(msg.Text, m.cwd)
}
// Convert image attachments to fantasy.FilePart for the app layer.
var fileParts []fantasy.FilePart
for _, img := range msg.Images {
fileParts = append(fileParts, fantasy.FilePart{
Data: img.Data,
MediaType: img.MediaType,
})
}
// Build display text for scrollback (include image count if any).
displayText := msg.Text
if len(msg.Images) > 0 {
displayText = fmt.Sprintf("%s\n[%d image(s) attached]", msg.Text, len(msg.Images))
}
if m.appCtrl != nil {
// Run returns the queue depth: >0 means the prompt was queued
// (agent is busy). We update queuedMessages directly here
// instead of relying on an event from prog.Send(), which would
// deadlock when called synchronously from within Update().
var qLen int
if len(fileParts) > 0 {
qLen = m.appCtrl.RunWithFiles(processedText, fileParts)
} else {
qLen = m.appCtrl.Run(processedText)
}
if qLen > 0 {
// Queued: anchor the message text above the input with a
// "queued" badge. It will be printed to scrollback when
// the agent picks it up (via SpinnerEvent).
m.queuedMessages = append(m.queuedMessages, displayText)
m.distributeHeight()
} else {
// Started immediately. Flush any leftover stream content
// from the previous step first, then print the user
// message — combined via the scrollback buffer so
// scrollback stays in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
}
} else {
m.printUserMessage(displayText)
}
if m.state != stateWorking {
m.state = stateWorking
}
// ── Shell command (! / !!) ───────────────────────────────────────────────
case shellCommandMsg:
// Show spinner while the shell command runs.
m.state = stateWorking
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
cmds = append(cmds, cmd)
}
// Execute the shell command asynchronously so the TUI stays responsive.
cmds = append(cmds, m.executeShellCommand(msg))
case shellCommandResultMsg:
// Stop spinner now that the command has finished.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
m.state = stateInput
cmds = append(cmds, m.handleShellCommandResult(msg))
// ── App layer events ─────────────────────────────────────────────────────
case app.SpinnerEvent:
// SpinnerEvent{Show: true} means a new agent step has started (either
// freshly or from the queue after a previous step completed). Flush
// any leftover stream content from the previous step to scrollback
// before starting the new one, followed by any pending user messages
// from the queue. Everything goes through the scrollback buffer to
// guarantee chronological ordering.
if msg.Show {
m.flushStreamAndPendingUserMessages()
m.state = stateWorking
m.distributeHeight()
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
case app.ReasoningChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
case app.StreamChunkEvent:
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
case app.ToolCallStartedEvent:
// Flush any accumulated streaming text to scrollback first (streaming
// always completes before tool calls fire). The tool call itself is
// NOT printed here — a unified block (header + result) will be
// rendered when the ToolResultEvent arrives.
m.flushStreamContent()
case app.ToolExecutionEvent:
// Pass to stream component for execution spinner display.
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
case app.ToolResultEvent:
// Buffer tool result for scrollback.
m.printToolResult(msg)
// Start spinner again while waiting for the next LLM response.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: true})
cmds = append(cmds, cmd)
}
case app.ToolCallContentEvent:
// In streaming mode this text was already delivered via StreamChunkEvents
// and will be flushed before the next tool call. Ignore to avoid
// double-printing.
case app.ResponseCompleteEvent:
// This event fires for both streaming and non-streaming paths.
// In streaming mode, the content was already delivered via StreamChunkEvents
// and is sitting in the stream component (possibly with reasoning). Don't
// print or reset — flushStreamContent() handles it on the next step.
// In non-streaming mode (no stream content accumulated), print the text.
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
if !hasStreamContent && msg.Content != "" {
m.printAssistantMessage(msg.Content)
if m.stream != nil {
m.stream.Reset()
}
}
case app.MessageCreatedEvent:
// Informational — no action needed by parent.
case app.QueueUpdatedEvent:
// drainQueue popped item(s) from the queue. Move consumed
// messages to pendingUserPrints — they will be printed to
// scrollback in the next SpinnerEvent{Show: true} after the
// previous assistant response is flushed.
for len(m.queuedMessages) > msg.Length {
text := m.queuedMessages[0]
m.queuedMessages = m.queuedMessages[1:]
m.pendingUserPrints = append(m.pendingUserPrints, text)
}
m.distributeHeight()
case app.StepCompleteEvent:
// Keep stream content visible in the view — don't flush to scrollback
// yet. Flushing + resetting in the same frame would shrink the view
// height, and bubbletea's inline renderer leaves blank lines at the
// bottom for the orphaned rows. The content will be flushed to
// scrollback when the next step starts (SpinnerEvent{Show: true}).
// Just stop the spinner and return to input state.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
m.state = stateInput
m.canceling = false
case app.StepCancelledEvent:
// User cancelled the step (double-ESC). Keep partial stream content
// visible (same reasoning as StepCompleteEvent). Just stop the spinner.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
m.state = stateInput
m.canceling = false
case app.StepErrorEvent:
// Keep partial stream content visible (same reasoning as
// StepCompleteEvent). Print the error to scrollback — it appears
// above the view, and the partial response stays visible below.
if m.stream != nil {
_, cmd := m.stream.Update(app.SpinnerEvent{Show: false})
cmds = append(cmds, cmd)
}
if msg.Err != nil {
m.printErrorResponse(msg)
}
m.state = stateInput
m.canceling = false
case app.CompactCompleteEvent:
if m.stream != nil {
m.stream.Reset()
}
m.state = stateInput
m.printCompactResult(msg)
case app.CompactErrorEvent:
if m.stream != nil {
m.stream.Reset()
}
m.state = stateInput
m.printSystemMessage(fmt.Sprintf("Compaction failed: %v", msg.Err))
case app.ModelChangedEvent:
// Extension changed the model — update display name in status bar
// and message attribution.
m.providerName = msg.ProviderName
m.modelName = msg.ModelName
case app.WidgetUpdateEvent:
// Extension widget changed — recalculate height distribution so the
// stream region accounts for widget space. View() will read the
// latest widget state on the next render.
m.distributeHeight()
// Refresh extension commands (e.g. after hot-reload). The callback
// returns the current set from the runner which may have changed.
if m.getExtensionCommands != nil {
newCmds := m.getExtensionCommands()
m.extensionCommands = newCmds
if ic, ok := m.input.(*InputComponent); ok {
// Remove old extension commands and add fresh ones.
var builtins []SlashCommand
for _, sc := range ic.commands {
if sc.Category != "Extensions" {
builtins = append(builtins, sc)
}
}
for _, ec := range newCmds {
builtins = append(builtins, SlashCommand{
Name: ec.Name,
Description: ec.Description,
Category: "Extensions",
Complete: ec.Complete,
})
}
ic.commands = builtins
}
}
case app.EditorTextSetEvent:
// Extension wants to pre-fill the input editor with text.
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(msg.Text)
ic.textarea.CursorEnd()
}
case app.PromptRequestEvent:
// Extension wants to show an interactive prompt. Enter prompt state.
// If already in prompt state (concurrent prompt from another
// extension), immediately cancel the new request.
if m.state == statePrompt {
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
return m, tea.Batch(cmds...)
}
m.prePromptState = m.state
m.state = statePrompt
m.promptResponseCh = msg.ResponseCh
switch msg.PromptType {
case "select":
m.prompt = newSelectPrompt(msg.Message, msg.Options, m.width, m.height)
case "confirm":
defaultVal := msg.Default == "true"
m.prompt = newConfirmPrompt(msg.Message, defaultVal, m.width, m.height)
case "input":
m.prompt = newInputPrompt(msg.Message, msg.Placeholder, msg.Default, m.width, m.height)
default:
// Unknown prompt type — cancel immediately.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
m.state = m.prePromptState
m.promptResponseCh = nil
return m, tea.Batch(cmds...)
}
if m.prompt != nil {
cmds = append(cmds, m.prompt.Init())
}
case app.OverlayRequestEvent:
// Extension wants to show a modal overlay dialog. Enter overlay state.
// If already in overlay or prompt state, immediately cancel the request.
if m.state == stateOverlay || m.state == statePrompt {
if msg.ResponseCh != nil {
msg.ResponseCh <- app.OverlayResponse{Cancelled: true}
}
return m, tea.Batch(cmds...)
}
m.preOverlayState = m.state
m.state = stateOverlay
m.overlayResponseCh = msg.ResponseCh
m.overlay = newOverlayDialog(
msg.Title, msg.Content, msg.Markdown,
msg.BorderColor, msg.Background,
msg.Width, msg.MaxHeight, msg.Anchor,
msg.Actions,
m.width, m.height,
)
if m.overlay != nil {
cmds = append(cmds, m.overlay.Init())
}
case extensionCmdResultMsg:
// Async extension slash command completed. Render output/error.
if msg.err != nil {
m.printSystemMessage(fmt.Sprintf("Command %s error: %v", msg.name, msg.err))
} else if msg.output != "" {
m.printSystemMessage(msg.output)
}
case beforeSessionSwitchResultMsg:
// Async before-session-switch hook completed. Proceed with the
// session reset if the hook did not cancel.
if msg.cancelled {
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performNewSession())
}
case beforeForkResultMsg:
// Async before-fork hook completed. Proceed with the fork if the
// hook did not cancel.
if msg.cancelled {
m.printSystemMessage(msg.reason)
} else {
cmds = append(cmds, m.performFork(msg.targetID, msg.isUser, msg.userText))
}
case app.ExtensionPrintEvent:
// Extension output — route through styled renderers when a level is set.
switch msg.Level {
case "info":
m.printSystemMessage(msg.Text)
case "error":
m.printErrorResponse(app.StepErrorEvent{
Err: fmt.Errorf("%s", msg.Text),
})
case "block":
m.printExtensionBlock(msg)
default:
m.appendScrollback(msg.Text)
}
default:
// Pass unrecognised messages to all children.
if m.input != nil {
_, cmd := m.input.Update(msg)
cmds = append(cmds, cmd)
}
if m.stream != nil {
_, cmd := m.stream.Update(msg)
cmds = append(cmds, cmd)
}
}
cmds = append(cmds, m.drainScrollback())
return m, tea.Batch(cmds...)
}
// View implements tea.Model. It renders the stacked layout:
// stream region + separator + [queued messages] + input region + status bar.
// The status bar is always present (1 line) to avoid layout shifts.
// When the tree selector is active, it replaces the stream region.
func (m *AppModel) View() tea.View {
// Tree selector overlay replaces the normal layout.
if m.state == stateTreeSelector && m.treeSelector != nil {
return m.treeSelector.View()
}
// Model selector overlay replaces the normal layout.
if m.state == stateModelSelector && m.modelSelector != nil {
return m.modelSelector.View()
}
// Overlay dialog replaces the normal layout.
if m.state == stateOverlay && m.overlay != nil {
return tea.NewView(m.overlay.Render())
}
vis := m.uiVis()
streamView := m.renderStream()
// Propagate hint visibility to the input component before rendering.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
}
// When a prompt is active, it replaces the input area for consistency
// (appears below the separator, in the same position as the input).
var inputView string
if m.state == statePrompt && m.prompt != nil {
inputView = m.prompt.Render()
} else {
inputView = m.renderInput()
}
// Build the stacked layout. Optional header/footer wrap the core layout.
var parts []string
// Custom header (if set by extension) — above everything.
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
parts = append(parts, headerView)
}
// Only include the stream region when it has content. When idle the
// stream renders "" which JoinVertical would pad to a full-width blank
// line, inflating the view unnecessarily.
if streamView != "" {
parts = append(parts, streamView)
}
if !vis.HideSeparator {
parts = append(parts, m.renderSeparator())
}
// Render "above" widgets between separator and queued messages.
if aboveView := m.renderWidgetSlot("above"); aboveView != "" {
parts = append(parts, aboveView)
}
if queuedView := m.renderQueuedMessages(); queuedView != "" {
parts = append(parts, queuedView)
}
parts = append(parts, inputView)
// Render "below" widgets between input and status bar.
if belowView := m.renderWidgetSlot("below"); belowView != "" {
parts = append(parts, belowView)
}
if !vis.HideStatusBar {
parts = append(parts, m.renderStatusBar())
}
// Custom footer (if set by extension) — below everything.
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
parts = append(parts, footerView)
}
content := lipgloss.JoinVertical(lipgloss.Left, parts...)
return tea.NewView(content)
}
// --------------------------------------------------------------------------
// Rendering helpers
// --------------------------------------------------------------------------
// renderStream returns the stream region content.
func (m *AppModel) renderStream() string {
if m.stream == nil {
return ""
}
// Show canceling warning if set.
if m.canceling {
warning := lipgloss.NewStyle().
Foreground(lipgloss.Color("214")).
Bold(true).
Render(" ⚠ Press ESC again to cancel")
return lipgloss.JoinVertical(lipgloss.Left,
m.stream.View().Content,
warning,
)
}
return m.stream.View().Content
}
// renderStatusBar renders a persistent single-line status bar below the input.
// Left side: spinner (when active). Middle: extension status entries (sorted by
// priority). Right side: provider · model + usage stats.
// This bar is always present so its height is constant, eliminating layout
// shifts from spinner or usage info appearing/disappearing.
func (m *AppModel) renderStatusBar() string {
theme := GetTheme()
// Left side: spinner animation (when active).
var leftSide string
if m.stream != nil {
leftSide = m.stream.SpinnerView()
}
// Middle: thinking level (when reasoning model) + extension status bar entries.
var middleParts []string
if m.isReasoningModel && m.thinkingLevel != "" && m.thinkingLevel != "off" {
thinkingLabel := "Thinking: " + m.thinkingLevel
middleParts = append(middleParts, lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(thinkingLabel))
}
if m.getStatusBarEntries != nil {
entries := m.getStatusBarEntries()
for _, e := range entries {
middleParts = append(middleParts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(e.Text))
}
}
middleSide := strings.Join(middleParts, " ")
if middleSide != "" && leftSide != "" {
middleSide = " " + middleSide
}
// Right side: provider · model + usage stats.
var rightParts []string
var modelLabel string
if m.providerName != "" && m.modelName != "" {
modelLabel = m.providerName + " · " + m.modelName
} else if m.modelName != "" {
modelLabel = m.modelName
}
if modelLabel != "" {
rightParts = append(rightParts, lipgloss.NewStyle().
Foreground(theme.Muted).
Render(modelLabel))
}
if m.usageTracker != nil {
if usage := m.usageTracker.RenderUsageInfo(); usage != "" {
rightParts = append(rightParts, usage)
}
}
rightSide := strings.Join(rightParts, " ")
// Progressive truncation to keep the status bar on one line.
// When content exceeds terminal width, drop sections in order:
// middle (extensions/thinking) → usage stats → model label → right side.
leftW := lipgloss.Width(leftSide)
middleW := lipgloss.Width(middleSide)
rightW := lipgloss.Width(rightSide)
// Need at least 1 space gap between left+middle and right.
if leftW+middleW+rightW+1 > m.width {
// Drop middle section first (extensions/thinking status).
middleSide = ""
middleW = 0
}
if leftW+rightW+1 > m.width && len(rightParts) > 1 {
// Drop usage stats, keep model label.
rightSide = rightParts[0]
rightW = lipgloss.Width(rightSide)
}
if leftW+rightW+1 > m.width {
// Drop right side entirely.
rightSide = ""
rightW = 0
}
gap := max(m.width-leftW-middleW-rightW, 1)
return leftSide + middleSide + strings.Repeat(" ", gap) + rightSide
}
// cycleThinkingLevel advances to the next thinking level and applies it.
func (m *AppModel) cycleThinkingLevel() {
levels := []string{"off", "minimal", "low", "medium", "high"}
current := m.thinkingLevel
if current == "" {
current = "off"
}
// Find current index and advance to next.
idx := 0
for i, l := range levels {
if l == current {
idx = i
break
}
}
next := levels[(idx+1)%len(levels)]
m.thinkingLevel = next
// Apply the change to the agent/provider.
if m.setThinkingLevel != nil {
// Run in goroutine to avoid blocking the event loop (provider
// recreation may take time).
go func() {
_ = m.setThinkingLevel(next)
}()
}
}
// renderSeparator renders the separator line with an optional queue count badge.
func (m *AppModel) renderSeparator() string {
theme := GetTheme()
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
queueLen := len(m.queuedMessages)
if queueLen > 0 {
badge := lipgloss.NewStyle().
Foreground(theme.Secondary).
Render(fmt.Sprintf("%d queued", queueLen))
// Fill the separator with dashes up to the badge.
dashWidth := max(m.width-lipgloss.Width(badge)-1, 0)
dashes := lineStyle.Render(repeatRune('─', dashWidth))
return dashes + " " + badge
}
return lineStyle.Render(repeatRune('─', m.width))
}
// renderInput returns the input region content. If an editor interceptor
// is active and provides a Render function, the default content is passed
// through it for wrapping/modification.
func (m *AppModel) renderInput() string {
if m.input == nil {
return ""
}
content := m.input.View().Content
if m.getEditorInterceptor != nil {
if interceptor := m.getEditorInterceptor(); interceptor != nil && interceptor.Render != nil {
content = interceptor.Render(m.width, content)
}
}
return content
}
// renderWidgetSlot renders all extension widgets for the given placement
// ("above" or "below"). Returns "" if no widgets exist for that slot.
func (m *AppModel) renderWidgetSlot(placement string) string {
if m.getWidgets == nil {
return ""
}
widgets := m.getWidgets(placement)
if len(widgets) == 0 {
return ""
}
theme := GetTheme()
var blocks []string
for _, w := range widgets {
content := w.Text
var opts []renderingOption
opts = append(opts, WithAlign(lipgloss.Left))
if w.NoBorder {
opts = append(opts, WithNoBorder())
} else {
borderClr := theme.Accent
if w.BorderColor != "" {
borderClr = lipgloss.Color(w.BorderColor)
}
opts = append(opts, WithBorderColor(borderClr))
}
// Use tighter padding for widgets (less vertical padding than
// full message blocks) so they feel compact and unobtrusive.
opts = append(opts, WithPaddingTop(0), WithPaddingBottom(0))
blocks = append(blocks, renderContentBlock(content, m.width, opts...))
}
return strings.Join(blocks, "\n")
}
// renderHeaderFooter renders a custom header or footer from an extension. The
// getter function returns the current data (*WidgetData) or nil when inactive.
// Returns "" when the getter is nil or returns nil. Uses the same rendering
// pipeline as widgets for visual consistency.
func (m *AppModel) renderHeaderFooter(getter func() *WidgetData) string {
if getter == nil {
return ""
}
data := getter()
if data == nil {
return ""
}
theme := GetTheme()
var opts []renderingOption
opts = append(opts, WithAlign(lipgloss.Left))
if data.NoBorder {
opts = append(opts, WithNoBorder())
} else {
borderClr := theme.Accent
if data.BorderColor != "" {
borderClr = lipgloss.Color(data.BorderColor)
}
opts = append(opts, WithBorderColor(borderClr))
}
// Compact padding like widgets.
opts = append(opts, WithPaddingTop(0), WithPaddingBottom(0))
return renderContentBlock(data.Text, m.width, opts...)
}
// renderQueuedMessages renders queued prompts as styled content blocks with a
// "QUEUED" badge, anchored between the separator and input. Each message is
// displayed in a bordered block matching the overall message styling.
func (m *AppModel) renderQueuedMessages() string {
if len(m.queuedMessages) == 0 {
return ""
}
theme := GetTheme()
badge := CreateBadge("QUEUED", theme.Accent)
var blocks []string
for _, msg := range m.queuedMessages {
content := msg + "\n" + badge
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Muted),
)
blocks = append(blocks, rendered)
}
return strings.Join(blocks, "\n")
}
// --------------------------------------------------------------------------
// Print helpers — emit content to scrollback via tea.Println
// --------------------------------------------------------------------------
// printUserMessage renders a user message into the scrollback buffer.
func (m *AppModel) printUserMessage(text string) {
m.appendScrollback(m.renderer.RenderUserMessage(text, time.Now()).Content)
}
// printAssistantMessage renders an assistant message into the scrollback buffer.
func (m *AppModel) printAssistantMessage(text string) {
if text != "" {
m.appendScrollback(m.renderer.RenderAssistantMessage(text, time.Now(), m.modelName).Content)
}
}
// printToolResult renders a tool result message into the scrollback buffer.
func (m *AppModel) printToolResult(evt app.ToolResultEvent) {
m.appendScrollback(m.renderer.RenderToolMessage(evt.ToolName, evt.ToolArgs, evt.Result, evt.IsError).Content)
}
// printErrorResponse renders an error message into the scrollback buffer.
func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) {
if evt.Err != nil {
m.appendScrollback(m.renderer.RenderErrorMessage(evt.Err.Error(), time.Now()).Content)
}
}
// --------------------------------------------------------------------------
// Slash command handlers
// --------------------------------------------------------------------------
// handleSlashCommand executes a recognized slash command and returns a tea.Cmd
// that emits the appropriate output to scrollback. Returns tea.Quit for /quit,
// nil for commands with no visible output, or a tea.Println cmd for display.
func (m *AppModel) handleSlashCommand(sc *SlashCommand) tea.Cmd {
switch sc.Name {
case "/quit":
return tea.Quit
case "/help":
m.printHelpMessage()
case "/tools":
m.printToolsMessage()
case "/servers":
m.printServersMessage()
case "/usage":
m.printUsageMessage()
case "/reset-usage":
m.printResetUsage()
case "/model":
return m.handleModelCommand("")
case "/thinking":
return m.handleThinkingCommand("")
case "/compact":
return m.handleCompactCommand("")
case "/clear":
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
m.printSystemMessage("Conversation cleared. Starting fresh.")
case "/clear-queue":
if m.appCtrl != nil {
m.appCtrl.ClearQueue()
}
m.queuedMessages = m.queuedMessages[:0]
m.distributeHeight()
case "/tree":
return m.handleTreeCommand()
case "/fork":
return m.handleForkCommand()
case "/new":
return m.handleNewCommand()
case "/name":
return m.handleNameCommand()
case "/session":
return m.handleSessionInfoCommand()
default:
m.printSystemMessage(fmt.Sprintf("Unknown command: %s", sc.Name))
}
return nil
}
// printSystemMessage renders a system-level message into the scrollback buffer.
func (m *AppModel) printSystemMessage(text string) {
m.appendScrollback(m.renderer.RenderSystemMessage(text, time.Now()).Content)
}
// printExtensionBlock renders a custom styled block from an extension with
// caller-chosen border color and optional subtitle into the scrollback buffer.
func (m *AppModel) printExtensionBlock(evt app.ExtensionPrintEvent) {
theme := GetTheme()
// Resolve border color: use the extension's hex value, fall back to theme accent.
var borderClr = lipgloss.Color("#89b4fa") // default blue
if evt.BorderColor != "" {
borderClr = lipgloss.Color(evt.BorderColor)
}
// Build content: main text + optional subtitle line.
content := evt.Text
if evt.Subtitle != "" {
sub := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" " + evt.Subtitle)
content = strings.TrimSuffix(content, "\n") + "\n" + sub
}
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(borderClr),
WithMarginBottom(1),
)
m.appendScrollback(rendered)
}
// handleExtensionCommand checks if the submitted text matches an extension-
// registered slash command and returns a tea.Cmd that runs it. Returns nil
// if no extension command matches.
//
// Extension commands execute asynchronously (via tea.Cmd goroutine) so they
// can safely call blocking operations like ctx.PromptSelect() without
// deadlocking the TUI's Update loop. The result is delivered back as an
// extensionCmdResultMsg.
//
// Extension commands support arguments: "/sub list files" is split into
// command name "/sub" and args "list files".
func (m *AppModel) handleExtensionCommand(text string) tea.Cmd {
if len(m.extensionCommands) == 0 {
return nil
}
// Only consider inputs that look like slash commands.
if !strings.HasPrefix(text, "/") {
return nil
}
// Split: "/sub list files" → name="/sub", args="list files"
name, args, _ := strings.Cut(text, " ")
ecmd := FindExtensionCommand(name, m.extensionCommands)
if ecmd == nil {
return nil
}
// Run the command in a dedicated goroutine — NOT as a tea.Cmd. Extension
// commands may block on interactive prompts (ctx.PromptSelect etc.) which
// wait for the TUI to respond via a channel. A blocking tea.Cmd can stall
// BubbleTea's internal Cmd scheduler, causing intermittent freezes.
// The goroutine delivers its result via SendEvent (prog.Send) instead.
cmdName := ecmd.Name
cmdExec := ecmd.Execute
cmdArgs := args
ctrl := m.appCtrl
go func() {
output, err := cmdExec(cmdArgs)
ctrl.SendEvent(extensionCmdResultMsg{name: cmdName, output: output, err: err})
}()
// Return a non-nil Cmd so the caller knows the command was handled
// and doesn't fall through to the regular prompt path. The Cmd itself
// is a no-op.
return func() tea.Msg { return nil }
}
// printHelpMessage renders the help text listing all available slash commands.
func (m *AppModel) printHelpMessage() {
help := "## Available Commands\n\n" +
"**Info:**\n" +
"- `/help`: Show this help message\n" +
"- `/tools`: List all available tools\n" +
"- `/servers`: List configured MCP servers\n" +
"- `/usage`: Show token usage and cost statistics\n" +
"- `/session`: Show session info and statistics\n\n" +
"**Navigation:**\n" +
"- `/tree`: Navigate session tree (switch branches)\n" +
"- `/fork`: Branch from an earlier message\n" +
"- `/new`: Start a new branch (preserves history)\n\n" +
"**System:**\n" +
"- `/compact [instructions]`: Summarise older messages to free context space\n" +
"- `/clear`: Clear message history\n" +
"- `/reset-usage`: Reset usage statistics\n" +
"- `/quit`: Exit the application\n\n"
if len(m.extensionCommands) > 0 {
var extHelp strings.Builder
extHelp.WriteString("**Extensions:**\n")
for _, ec := range m.extensionCommands {
fmt.Fprintf(&extHelp, "- `%s`: %s\n", ec.Name, ec.Description)
}
extHelp.WriteString("\n")
help += extHelp.String()
}
if len(m.skillItems) > 0 {
var skillHelp strings.Builder
skillHelp.WriteString("**Skills:**\n")
skillHelp.WriteString("- `/skill:<name> [args]`: Load a skill into context and run with optional args\n")
skillHelp.WriteString(" Available skills: ")
for i, si := range m.skillItems {
if i > 0 {
skillHelp.WriteString(", ")
}
skillHelp.WriteString("`" + si.Name + "`")
}
skillHelp.WriteString("\n\n")
help += skillHelp.String()
}
help += "**Shell Commands:**\n" +
"- `!command`: Run shell command, output included in LLM context\n" +
"- `!!command`: Run shell command, output excluded from LLM context\n\n" +
"**Keys:**\n" +
"- `Ctrl+C`: Exit at any time\n" +
"- `ESC` (x2): Cancel ongoing LLM generation\n\n" +
"You can also just type your message to chat with the AI assistant."
m.printSystemMessage(help)
}
// printToolsMessage renders the list of available tools.
func (m *AppModel) printToolsMessage() {
var content string
content = "## Available Tools\n\n"
if len(m.toolNames) == 0 {
content += "No tools are currently available."
} else {
for i, tool := range m.toolNames {
content += fmt.Sprintf("%d. `%s`\n", i+1, tool)
}
}
m.printSystemMessage(content)
}
// printServersMessage renders the list of configured MCP servers.
func (m *AppModel) printServersMessage() {
var content string
content = "## Configured MCP Servers\n\n"
if len(m.serverNames) == 0 {
content += "No MCP servers are currently configured."
} else {
for i, server := range m.serverNames {
content += fmt.Sprintf("%d. `%s`\n", i+1, server)
}
}
m.printSystemMessage(content)
}
// printUsageMessage renders token usage statistics.
func (m *AppModel) printUsageMessage() {
if m.usageTracker == nil {
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
sessionStats := m.usageTracker.GetSessionStats()
lastStats := m.usageTracker.GetLastRequestStats()
content := "## Usage Statistics\n\n"
if lastStats != nil {
content += fmt.Sprintf("**Last Request:** %d input + %d output tokens = $%.6f\n",
lastStats.InputTokens, lastStats.OutputTokens, lastStats.TotalCost)
}
content += fmt.Sprintf("**Session Total:** %d input + %d output tokens = $%.6f (%d requests)\n",
sessionStats.TotalInputTokens, sessionStats.TotalOutputTokens, sessionStats.TotalCost, sessionStats.RequestCount)
m.printSystemMessage(content)
}
// printResetUsage resets usage statistics and prints a confirmation.
func (m *AppModel) printResetUsage() {
if m.usageTracker == nil {
m.printSystemMessage("Usage tracking is not available for this model.")
return
}
m.usageTracker.Reset()
m.printSystemMessage("Usage statistics have been reset.")
}
// handleCompactCommand starts an async compaction. It returns a tea.Cmd that
// prints a "compacting..." message and transitions to the working state. If
// the app controller rejects the request (busy, closed) it prints an error
// instead. customInstructions is optional text appended to the summary
// prompt (e.g. "Focus on the API design decisions").
func (m *AppModel) handleCompactCommand(customInstructions string) tea.Cmd {
if m.appCtrl == nil {
m.printSystemMessage("Compaction is not available.")
return nil
}
if err := m.appCtrl.CompactConversation(customInstructions); err != nil {
m.printSystemMessage(fmt.Sprintf("Cannot compact: %v", err))
return nil
}
// Transition to working state so the spinner shows while compaction runs.
m.state = stateWorking
m.printSystemMessage("Compacting conversation...")
var spinnerCmd tea.Cmd
if m.stream != nil {
_, spinnerCmd = m.stream.Update(app.SpinnerEvent{Show: true})
}
return spinnerCmd
}
// printCompactResult renders the compaction summary in a styled block with
// a distinct border color and a stats subtitle into the scrollback buffer.
func (m *AppModel) printCompactResult(evt app.CompactCompleteEvent) {
theme := GetTheme()
saved := evt.OriginalTokens - evt.CompactedTokens
subtitle := fmt.Sprintf(
"%d messages summarised, ~%dk tokens freed (%dk -> %dk)",
evt.MessagesRemoved, saved/1000, evt.OriginalTokens/1000, evt.CompactedTokens/1000,
)
content := evt.Summary
if subtitle != "" {
sub := lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" " + subtitle)
content = strings.TrimSuffix(content, "\n") + "\n\n" + sub
}
rendered := renderContentBlock(
content,
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(theme.Secondary),
WithMarginBottom(1),
)
m.appendScrollback(rendered)
}
// flushStreamContent moves rendered content from the stream component into the
// scrollback buffer and resets the stream. Called before tool calls (streaming
// completes before tools fire). The actual tea.Println is deferred to
// drainScrollback() at the end of the Update cycle.
func (m *AppModel) flushStreamContent() {
if m.stream == nil {
return
}
content := m.stream.GetRenderedContent()
if content == "" {
return
}
m.stream.Reset()
m.appendScrollback(content)
}
// flushStreamAndPendingUserMessages moves the previous assistant response and
// any pending queued user messages into the scrollback buffer. Called from
// SpinnerEvent{Show: true} where all previous stream chunks are guaranteed to
// have been processed. The actual tea.Println is deferred to drainScrollback().
func (m *AppModel) flushStreamAndPendingUserMessages() {
// 1. Flush previous stream content (assistant response).
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
m.appendScrollback(content)
}
}
// 2. Render pending user messages from the queue.
for _, text := range m.pendingUserPrints {
rendered := m.renderer.RenderUserMessage(text, time.Now()).Content
m.appendScrollback(rendered)
}
m.pendingUserPrints = nil
}
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.
func (m *AppModel) appendScrollback(content string) {
if content != "" {
m.scrollbackBuf = append(m.scrollbackBuf, content)
}
}
// drainScrollback flushes the scrollback buffer into a single tea.Println. If
// the stream component has unflushed content, it is automatically prepended so
// that new messages always appear below the previous assistant response. When
// stream content is flushed a ClearScreen follows to clean up orphaned terminal
// rows left after the view height shrinks. Returns nil if there is nothing to
// print.
func (m *AppModel) drainScrollback() tea.Cmd {
if len(m.scrollbackBuf) == 0 {
return nil
}
var parts []string
needsClear := false
// Auto-flush any stream content so it appears before new messages.
if m.stream != nil {
if content := m.stream.GetRenderedContent(); content != "" {
m.stream.Reset()
parts = append(parts, content)
needsClear = true
}
}
parts = append(parts, m.scrollbackBuf...)
m.scrollbackBuf = m.scrollbackBuf[:0]
printCmd := tea.Println(strings.Join(parts, "\n"))
if needsClear {
return tea.Sequence(
printCmd,
func() tea.Msg { return tea.ClearScreen() },
)
}
return printCmd
}
// distributeHeight recalculates child component heights after a window resize,
// queue change, widget update, or state transition, and propagates the computed
// stream height to the StreamComponent.
//
// Layout (line counts):
//
// header = measured dynamically (0 if not set)
// stream region = total - header - separator(1) - widgets - queued(N*5) - input(measured) - widgets - statusBar(1) - footer
// separator = 1 line
// above widgets = measured dynamically
// queued msgs = measured dynamically via lipgloss.Height()
// input region = measured dynamically via lipgloss.Height()
// below widgets = measured dynamically
// status bar = 1 line (always present)
// footer = measured dynamically (0 if not set)
func (m *AppModel) distributeHeight() {
vis := m.uiVis()
separatorLines := 1
if vis.HideSeparator {
separatorLines = 0
}
statusBarLines := 1
if vis.HideStatusBar {
statusBarLines = 0
}
// Measure actual queued message height instead of using a fixed estimate,
// since text wrapping at different widths changes the rendered line count.
var queuedLines int
if queuedView := m.renderQueuedMessages(); queuedView != "" {
queuedLines = lipgloss.Height(queuedView)
}
// Propagate hint visibility before measuring input height.
if ic, ok := m.input.(*InputComponent); ok {
ic.hideHint = vis.HideInputHint
}
// Measure the actual rendered input (or prompt overlay) height so we
// don't rely on a fragile constant that drifts when styling changes.
// Use renderInput() which includes the editor interceptor's Render
// wrapper so the measured height matches what View() actually renders.
inputLines := 9 // fallback: title(1)+margin(1)+nl(1)+textarea(3)+nl(1)+margin(1)+help(1)
if m.state == statePrompt && m.prompt != nil {
if rendered := m.prompt.Render(); rendered != "" {
inputLines = lipgloss.Height(rendered)
}
} else {
if rendered := m.renderInput(); rendered != "" {
inputLines = lipgloss.Height(rendered)
}
}
// Measure widget heights.
var widgetLines int
if above := m.renderWidgetSlot("above"); above != "" {
widgetLines += lipgloss.Height(above)
}
if below := m.renderWidgetSlot("below"); below != "" {
widgetLines += lipgloss.Height(below)
}
// Measure header/footer heights.
var headerFooterLines int
if headerView := m.renderHeaderFooter(m.getHeader); headerView != "" {
headerFooterLines += lipgloss.Height(headerView)
}
if footerView := m.renderHeaderFooter(m.getFooter); footerView != "" {
headerFooterLines += lipgloss.Height(footerView)
}
streamHeight := max(m.height-separatorLines-widgetLines-headerFooterLines-queuedLines-inputLines-statusBarLines, 0)
if m.stream != nil {
m.stream.SetHeight(streamHeight)
}
}
// clamp constrains v to the range [lo, hi].
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// repeatRune returns a string consisting of n repetitions of r.
func repeatRune(r rune, n int) string {
if n <= 0 {
return ""
}
runes := make([]rune, n)
for i := range runes {
runes[i] = r
}
return string(runes)
}
// --------------------------------------------------------------------------
// Editor key remapping
// --------------------------------------------------------------------------
// remapKey converts a key name string to a tea.KeyPressMsg for editor key
// remapping. Returns the KeyPressMsg and true if the key name is recognized,
// or a zero value and false if unknown.
func remapKey(name string) (tea.KeyPressMsg, bool) {
switch name {
case "up":
return tea.KeyPressMsg{Code: tea.KeyUp}, true
case "down":
return tea.KeyPressMsg{Code: tea.KeyDown}, true
case "left":
return tea.KeyPressMsg{Code: tea.KeyLeft}, true
case "right":
return tea.KeyPressMsg{Code: tea.KeyRight}, true
case "backspace":
return tea.KeyPressMsg{Code: tea.KeyBackspace}, true
case "delete":
return tea.KeyPressMsg{Code: tea.KeyDelete}, true
case "enter":
return tea.KeyPressMsg{Code: tea.KeyEnter}, true
case "tab":
return tea.KeyPressMsg{Code: tea.KeyTab}, true
case "esc", "escape":
return tea.KeyPressMsg{Code: tea.KeyEscape}, true
case "home":
return tea.KeyPressMsg{Code: tea.KeyHome}, true
case "end":
return tea.KeyPressMsg{Code: tea.KeyEnd}, true
case "pgup", "pageup":
return tea.KeyPressMsg{Code: tea.KeyPgUp}, true
case "pgdown", "pagedown":
return tea.KeyPressMsg{Code: tea.KeyPgDown}, true
case "space":
return tea.KeyPressMsg{Code: ' ', Text: " "}, true
default:
// Single printable character.
runes := []rune(name)
if len(runes) == 1 {
return tea.KeyPressMsg{Code: runes[0], Text: name}, true
}
return tea.KeyPressMsg{}, false
}
}
// --------------------------------------------------------------------------
// Model command handler
// --------------------------------------------------------------------------
// handleModelCommand handles the /model slash command. With no arguments, it
// opens an interactive model selector overlay with fuzzy finding. With an
// argument (e.g. "/model anthropic/claude-haiku-3-5-20241022"), it switches
// to that model directly.
func (m *AppModel) handleModelCommand(args string) tea.Cmd {
if m.setModel == nil {
m.printSystemMessage("Model switching is not available.")
return nil
}
if args == "" {
// Open the interactive model selector.
currentModel := m.providerName + "/" + m.modelName
m.modelSelector = NewModelSelector(currentModel, m.width, m.height)
m.state = stateModelSelector
return nil
}
// Direct model switch with the provided model string.
previousModel := m.providerName + "/" + m.modelName
if err := m.setModel(args); err != nil {
m.printSystemMessage(fmt.Sprintf("Failed to switch model: %v", err))
return nil
}
// Update display state directly (cannot use prog.Send from Update).
parts := strings.SplitN(args, "/", 2)
if len(parts) == 2 {
m.providerName = parts[0]
m.modelName = parts[1]
}
if m.emitModelChange != nil {
emit := m.emitModelChange
prev := previousModel
newModel := args
go emit(newModel, prev, "user")
}
m.printSystemMessage(fmt.Sprintf("Switched to %s", args))
return nil
}
// --------------------------------------------------------------------------
// Thinking command handler
// --------------------------------------------------------------------------
// handleThinkingCommand changes or displays the current thinking/reasoning level.
// With no arguments, it shows the current level. With a level argument (off,
// minimal, low, medium, high) it switches to that level.
func (m *AppModel) handleThinkingCommand(args string) tea.Cmd {
if !m.isReasoningModel {
m.printSystemMessage("Current model does not support thinking/reasoning.")
return nil
}
if args == "" {
// Show current level with descriptions.
var lines []string
levels := models.ThinkingLevels()
for _, l := range levels {
marker := " "
if string(l) == m.thinkingLevel {
marker = "▸ "
}
lines = append(lines, fmt.Sprintf("%s%s — %s", marker, l, models.ThinkingLevelDescription(l)))
}
header := fmt.Sprintf("Current thinking level: %s\n\nAvailable levels:", m.thinkingLevel)
m.printSystemMessage(header + "\n" + strings.Join(lines, "\n"))
return nil
}
// Parse and validate the level.
level := models.ParseThinkingLevel(args)
if string(level) != strings.ToLower(args) {
m.printSystemMessage(fmt.Sprintf("Unknown thinking level: %q. Use: off, minimal, low, medium, high", args))
return nil
}
// Apply the change.
m.thinkingLevel = string(level)
if m.setThinkingLevel != nil {
go func() {
_ = m.setThinkingLevel(string(level))
}()
}
m.printSystemMessage(fmt.Sprintf("Thinking level set to: %s — %s", level, models.ThinkingLevelDescription(level)))
return nil
}
// --------------------------------------------------------------------------
// Tree session command handlers
// --------------------------------------------------------------------------
// handleTreeCommand opens the tree selector overlay.
func (m *AppModel) handleTreeCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
m.printSystemMessage("No entries in session yet.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
m.state = stateTreeSelector
return nil
}
// handleForkCommand creates a branch from the current position. Like /tree
// but opens the selector directly for fork semantics.
func (m *AppModel) handleForkCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active. Start with `--continue` or `--resume` to enable tree sessions.")
return nil
}
if ts.EntryCount() == 0 {
m.printSystemMessage("No entries to fork from.")
return nil
}
m.treeSelector = NewTreeSelector(ts, m.width, m.height)
m.state = stateTreeSelector
return nil
}
// handleNewCommand starts a fresh session by resetting the tree leaf.
func (m *AppModel) handleNewCommand() tea.Cmd {
// Emit before-session-switch event in a goroutine so that extension
// handlers can call blocking operations (e.g. ctx.PromptConfirm) without
// deadlocking the BubbleTea event loop.
if m.emitBeforeSessionSwitch != nil {
emit := m.emitBeforeSessionSwitch
ctrl := m.appCtrl
go func() {
cancelled, reason := emit("new")
ctrl.SendEvent(beforeSessionSwitchResultMsg{
cancelled: cancelled,
reason: reason,
})
}()
return func() tea.Msg { return nil }
}
return m.performNewSession()
}
// performNewSession performs the actual session reset. Called either directly
// (when no before-hook exists) or after the async hook completes.
func (m *AppModel) performNewSession() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
// No tree session — just clear messages.
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
m.printSystemMessage("Conversation cleared. Starting fresh.")
return nil
}
ts.ResetLeaf()
if m.appCtrl != nil {
m.appCtrl.ClearMessages()
}
m.printSystemMessage("New branch started. Previous conversation is preserved in the tree.")
return nil
}
// performFork performs the actual tree branch. Called either directly (when no
// before-hook exists) or after the async before-fork hook completes.
func (m *AppModel) performFork(targetID string, isUser bool, userText string) tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
_ = ts.Branch(targetID)
m.appCtrl.ClearMessages()
// If it was a user message, populate the input with the text.
if isUser && userText != "" {
if ic, ok := m.input.(*InputComponent); ok {
ic.textarea.SetValue(userText)
ic.textarea.CursorEnd()
}
}
m.printSystemMessage(
fmt.Sprintf("Navigated to branch point. %s",
func() string {
if isUser {
return "Edit and resubmit to create a new branch."
}
return "Continue from this point."
}()))
return nil
}
// handleNameCommand sets a display name for the current session.
func (m *AppModel) handleNameCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
// For now, prompt user to provide name via input. We print instructions
// and the next non-command input starting with "name:" will be captured.
// TODO: inline input dialog.
currentName := ts.GetSessionName()
if currentName != "" {
m.printSystemMessage(fmt.Sprintf("Current session name: %q\nTo rename, type: `/name <new name>` (not yet implemented — use the session file directly).", currentName))
return nil
}
m.printSystemMessage("To name this session, use: `/name <new name>` (not yet implemented — use the session file directly).")
return nil
}
// handleSessionInfoCommand shows session statistics.
func (m *AppModel) handleSessionInfoCommand() tea.Cmd {
ts := m.appCtrl.GetTreeSession()
if ts == nil {
m.printSystemMessage("No tree session active.")
return nil
}
header := ts.GetHeader()
info := fmt.Sprintf("## Session Info\n\n"+
"- **ID:** `%s`\n"+
"- **File:** `%s`\n"+
"- **Working Dir:** `%s`\n"+
"- **Created:** %s\n"+
"- **Entries:** %d\n"+
"- **Messages:** %d\n"+
"- **Current Leaf:** `%s`\n",
header.ID,
ts.GetFilePath(),
header.Cwd,
header.Timestamp.Format(time.RFC3339),
ts.EntryCount(),
ts.MessageCount(),
ts.GetLeafID(),
)
if name := ts.GetSessionName(); name != "" {
info += fmt.Sprintf("- **Name:** %s\n", name)
}
m.printSystemMessage(info)
return nil
}
// --------------------------------------------------------------------------
// Cancel timer command
// --------------------------------------------------------------------------
// cancelTimerCmd returns a tea.Cmd that fires cancelTimerExpiredMsg after 2s.
// This is used for the double-tap ESC cancel flow.
func cancelTimerCmd() tea.Cmd {
return tea.Tick(2*time.Second, func(_ time.Time) tea.Msg {
return cancelTimerExpiredMsg{}
})
}
// --------------------------------------------------------------------------
// Interactive prompt support
// --------------------------------------------------------------------------
// extensionCmdResultMsg carries the result of an asynchronously executed
// extension slash command. Extension commands run async (via tea.Cmd) so they
// can safely call blocking operations like ctx.PromptSelect().
type extensionCmdResultMsg struct {
name string
output string
err error
}
// beforeSessionSwitchResultMsg carries the result of an asynchronously
// executed before-session-switch hook. The hook runs in a goroutine so that
// blocking operations like ctx.PromptConfirm() do not deadlock the TUI.
type beforeSessionSwitchResultMsg struct {
cancelled bool
reason string
}
// beforeForkResultMsg carries the result of an asynchronously executed
// before-fork hook along with the fork context needed to complete the
// operation if the hook allows it.
type beforeForkResultMsg struct {
cancelled bool
reason string
// Fork context — preserved so the operation can proceed after the hook.
targetID string
isUser bool
userText string
}
// updatePromptState handles all messages while the prompt overlay is active.
// It routes keys to the prompt overlay, detects completion/cancellation, and
// restores the previous state when done.
func (m *AppModel) updatePromptState(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// Cancel prompt and quit the application.
m.resolvePrompt(app.PromptResponse{Cancelled: true})
return m, tea.Quit
}
result, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if result != nil {
if result.cancelled {
m.resolvePrompt(app.PromptResponse{Cancelled: true})
} else {
m.resolvePrompt(app.PromptResponse{
Value: result.value,
Index: result.index,
Confirmed: result.confirmed,
})
}
}
case app.PromptRequestEvent:
// Already handling a prompt — reject concurrent requests.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
_, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
default:
// Pass blink ticks and other messages to the prompt overlay.
_, cmd := m.prompt.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// resolvePrompt sends the response through the channel, clears prompt state,
// and restores the previous app state.
func (m *AppModel) resolvePrompt(resp app.PromptResponse) {
if m.promptResponseCh != nil {
m.promptResponseCh <- resp
m.promptResponseCh = nil
}
m.prompt = nil
m.state = m.prePromptState
}
// --------------------------------------------------------------------------
// Overlay dialog support
// --------------------------------------------------------------------------
// updateOverlayState handles all messages while the overlay dialog is active.
// It routes keys to the overlay, detects completion/cancellation, and restores
// the previous state when done.
func (m *AppModel) updateOverlayState(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
// Cancel overlay and quit the application.
m.resolveOverlay(app.OverlayResponse{Cancelled: true})
return m, tea.Quit
}
result, cmd := m.overlay.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
if result != nil {
if result.cancelled {
m.resolveOverlay(app.OverlayResponse{Cancelled: true})
} else {
m.resolveOverlay(app.OverlayResponse{
Action: result.action,
Index: result.index,
})
}
}
case app.OverlayRequestEvent:
// Already handling an overlay — reject concurrent requests.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.OverlayResponse{Cancelled: true}
}
case app.PromptRequestEvent:
// Can't show a prompt while an overlay is active — reject.
if msg.ResponseCh != nil {
msg.ResponseCh <- app.PromptResponse{Cancelled: true}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
_, cmd := m.overlay.Update(msg)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
// resolveOverlay sends the response through the channel, clears overlay state,
// and restores the previous app state.
func (m *AppModel) resolveOverlay(resp app.OverlayResponse) {
if m.overlayResponseCh != nil {
m.overlayResponseCh <- resp
m.overlayResponseCh = nil
}
m.overlay = nil
m.state = m.preOverlayState
}
// --------------------------------------------------------------------------
// Shell command execution (! and !!)
// --------------------------------------------------------------------------
// shellCommandTimeout is the maximum duration for a user shell command.
const shellCommandTimeout = 120 * time.Second
// executeShellCommand runs a shell command asynchronously and returns the
// result as a shellCommandResultMsg. This is launched from Update() as a
// tea.Cmd so the TUI stays responsive during execution.
func (m *AppModel) executeShellCommand(msg shellCommandMsg) tea.Cmd {
command := msg.Command
excludeFromContext := msg.ExcludeFromContext
cwd := m.cwd
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), shellCommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", command)
if cwd != "" {
cmd.Dir = cwd
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
// Non-zero exit is reported via exitCode, not as an error.
err = nil
} else if ctx.Err() == context.DeadlineExceeded {
return shellCommandResultMsg{
Command: command,
Output: fmt.Sprintf("command timed out after %v", shellCommandTimeout),
ExitCode: -1,
Err: fmt.Errorf("command timed out after %v", shellCommandTimeout),
ExcludeFromContext: excludeFromContext,
}
}
}
// Combine stdout + stderr.
var combined strings.Builder
if stdout.Len() > 0 {
combined.WriteString(stdout.String())
}
if stderr.Len() > 0 {
if combined.Len() > 0 {
combined.WriteString("\n")
}
combined.WriteString(stderr.String())
}
return shellCommandResultMsg{
Command: command,
Output: combined.String(),
ExitCode: exitCode,
Err: err,
ExcludeFromContext: excludeFromContext,
}
}
}
// handleShellCommandResult processes the result of a shell command execution.
// It prints the output to scrollback and optionally injects it into the
// conversation context (for ! commands) so the LLM can see it.
func (m *AppModel) handleShellCommandResult(msg shellCommandResultMsg) tea.Cmd {
theme := GetTheme()
// Build the display header.
var header string
if msg.ExcludeFromContext {
header = fmt.Sprintf("$ %s (excluded from context)", msg.Command)
} else {
header = fmt.Sprintf("$ %s", msg.Command)
}
// Build the output content.
var content strings.Builder
content.WriteString(header)
// Display-level truncation: show first maxShellDisplayLines lines with a
// "...(N more lines)" hint, matching the tool result renderer behavior.
const maxShellDisplayLines = 20
displayOutput := msg.Output
var displayHiddenCount int
if displayOutput != "" {
lines := strings.Split(displayOutput, "\n")
if len(lines) > maxShellDisplayLines {
displayHiddenCount = len(lines) - maxShellDisplayLines
displayOutput = strings.Join(lines[:maxShellDisplayLines], "\n")
}
}
if msg.Err != nil {
fmt.Fprintf(&content, "\n\nError: %v", msg.Err)
} else if displayOutput != "" {
content.WriteString("\n\n")
content.WriteString(displayOutput)
if displayHiddenCount > 0 {
fmt.Fprintf(&content, "\n\n...(%d more lines)", displayHiddenCount)
}
} else {
content.WriteString("\n\n(no output)")
}
if msg.ExitCode != 0 {
fmt.Fprintf(&content, "\n\nExit code: %d", msg.ExitCode)
}
// Choose border color: dim for excluded, accent for included.
borderClr := theme.Accent
if msg.ExcludeFromContext {
borderClr = theme.Muted
}
rendered := renderContentBlock(
content.String(),
m.width,
WithAlign(lipgloss.Left),
WithBorderColor(borderClr),
WithMarginBottom(1),
)
m.appendScrollback(rendered)
// For ! (included in context): inject the command output into the
// conversation as a user message so the LLM can reference it on the
// next turn. This does NOT trigger an LLM response — it only adds
// to the conversation history.
if !msg.ExcludeFromContext && m.appCtrl != nil {
// Truncate context output with the same limits as display.
contextOutput := msg.Output
if contextOutput != "" {
tr := core.TruncateTail(contextOutput, core.DefaultMaxLines, core.DefaultMaxBytes)
contextOutput = tr.Content
} else {
contextOutput = "(no output)"
}
contextMsg := fmt.Sprintf("<shell_command>\n<command>%s</command>\n<output>\n%s</output>\n<exit_code>%d</exit_code>\n</shell_command>",
msg.Command, contextOutput, msg.ExitCode)
m.appCtrl.AddContextMessage(contextMsg)
}
return nil
}