mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
feat(ui): add parent AppModel and TUI-internal event types
Introduce the root Bubble Tea model (AppModel) and the three TUI-internal message types needed by the unified architecture spec (TAS-10). - internal/ui/events.go: submitMsg, approvalResultMsg, cancelTimerExpiredMsg - internal/ui/model.go: AppModel with full stateInput/stateWorking/stateApproval state machine, AppController interface for dependency inversion, child component interfaces as stubs (TAS-15/16/17), double-tap ESC cancel with 2s timer, routing of all 13 app-layer events, tea.Println on StepCompleteEvent, stacked View() with separator and queue badge, WindowSizeMsg propagation.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package ui
|
||||
|
||||
// submitMsg is sent by the InputComponent when the user submits a text prompt.
|
||||
// The parent model receives this and calls app.Run(Text) to start agent processing.
|
||||
type submitMsg struct {
|
||||
// Text is the user's input text to send to the agent.
|
||||
Text string
|
||||
}
|
||||
|
||||
// approvalResultMsg is sent by the ApprovalComponent when the user makes a decision
|
||||
// on a tool approval dialog. The parent model receives this and sends the result on
|
||||
// the approvalChan that was stored when ToolApprovalNeededEvent arrived.
|
||||
type approvalResultMsg struct {
|
||||
// Approved is true if the user approved the tool call, false if denied.
|
||||
Approved bool
|
||||
}
|
||||
|
||||
// cancelTimerExpiredMsg is sent by the tea.Tick command that starts when the user
|
||||
// presses ESC once during stateWorking. If this message arrives before the user
|
||||
// presses ESC a second time, the canceling state is reset to false.
|
||||
type cancelTimerExpiredMsg struct{}
|
||||
@@ -0,0 +1,525 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/mark3labs/mcphost/internal/app"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// stateApproval means a tool approval dialog is active. The user must
|
||||
// approve or deny before the agent can continue.
|
||||
stateApproval
|
||||
)
|
||||
|
||||
// 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.
|
||||
// If an agent step is already in progress the prompt is queued and a
|
||||
// QueueUpdatedEvent is sent to the program.
|
||||
Run(prompt string)
|
||||
// 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 and emits a QueueUpdatedEvent.
|
||||
ClearQueue()
|
||||
// ClearMessages clears the conversation history.
|
||||
ClearMessages()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Width is the initial terminal width in columns.
|
||||
Width int
|
||||
|
||||
// Height is the initial terminal height in rows.
|
||||
Height int
|
||||
}
|
||||
|
||||
// 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):
|
||||
//
|
||||
// ┌─ stream region (variable height) ─────────────────┐
|
||||
// │ │
|
||||
// ├─ separator line (with optional queue badge) ───────┤
|
||||
// └─ input region (fixed height from textarea) ────────┘
|
||||
//
|
||||
// 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).
|
||||
// Placeholder until InputComponent is implemented in TAS-15.
|
||||
input inputComponentIface
|
||||
|
||||
// stream is the child streaming display component (spinner + streaming text).
|
||||
// Placeholder until StreamComponent is implemented in TAS-16.
|
||||
stream streamComponentIface
|
||||
|
||||
// approval is the child tool approval dialog component.
|
||||
// Placeholder until ApprovalComponent is implemented in TAS-17.
|
||||
approval approvalComponentIface
|
||||
|
||||
// renderer renders completed assistant messages for tea.Println output.
|
||||
renderer *MessageRenderer
|
||||
|
||||
// compactRdr renders in compact mode.
|
||||
compactRdr *CompactRenderer
|
||||
|
||||
// compactMode selects which renderer to use.
|
||||
compactMode bool
|
||||
|
||||
// modelName is the LLM model name shown in rendered messages.
|
||||
modelName string
|
||||
|
||||
// queueCount is cached from the last QueueUpdatedEvent for badge rendering.
|
||||
queueCount int
|
||||
|
||||
// canceling tracks whether the user has pressed ESC once during stateWorking.
|
||||
// A second ESC within 2 seconds will cancel the current step.
|
||||
canceling bool
|
||||
|
||||
// approvalChan is the response channel for the current tool approval.
|
||||
// Set when a ToolApprovalNeededEvent arrives; cleared after sending the result.
|
||||
approvalChan chan<- bool
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// approvalComponentIface is the interface the parent requires from ApprovalComponent.
|
||||
// It will be satisfied by the real ApprovalComponent created in TAS-17.
|
||||
type approvalComponentIface interface {
|
||||
tea.Model
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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).
|
||||
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
|
||||
}
|
||||
|
||||
m := &AppModel{
|
||||
state: stateInput,
|
||||
appCtrl: appCtrl,
|
||||
renderer: NewMessageRenderer(width, false),
|
||||
compactRdr: NewCompactRenderer(width, false),
|
||||
compactMode: opts.CompactMode,
|
||||
modelName: opts.ModelName,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
|
||||
// Child components are nil until they are attached via setters or until the
|
||||
// concrete implementations are in place (TAS-15, TAS-16, TAS-17).
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// tea.Model interface
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Init implements tea.Model. No startup commands needed; the app layer fires
|
||||
// events via program.Send() once the agent starts.
|
||||
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...)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
switch msg := msg.(type) {
|
||||
|
||||
// ── 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":
|
||||
// Graceful quit: app.Close() is deferred in cmd/root.go.
|
||||
return m, tea.Quit
|
||||
|
||||
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.
|
||||
if m.state == stateApproval && m.approval != nil {
|
||||
updated, cmd := m.approval.Update(msg)
|
||||
m.approval, _ = updated.(approvalComponentIface)
|
||||
cmds = append(cmds, cmd)
|
||||
} else if m.input != nil {
|
||||
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:
|
||||
if m.appCtrl != nil {
|
||||
// app.Run() handles queueing internally if a step is in progress.
|
||||
m.appCtrl.Run(msg.Text)
|
||||
}
|
||||
if m.state != stateWorking {
|
||||
m.state = stateWorking
|
||||
}
|
||||
|
||||
// ── Approval result ──────────────────────────────────────────────────────
|
||||
case approvalResultMsg:
|
||||
if m.approvalChan != nil {
|
||||
m.approvalChan <- msg.Approved
|
||||
m.approvalChan = nil
|
||||
}
|
||||
m.state = stateWorking
|
||||
|
||||
// ── App layer events ─────────────────────────────────────────────────────
|
||||
|
||||
case app.SpinnerEvent:
|
||||
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:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolExecutionEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolResultEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ToolCallContentEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.ResponseCompleteEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.HookBlockedEvent:
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
case app.MessageCreatedEvent:
|
||||
// Informational — no action needed by parent.
|
||||
|
||||
case app.QueueUpdatedEvent:
|
||||
m.queueCount = msg.Length
|
||||
|
||||
case app.ToolApprovalNeededEvent:
|
||||
// Store the response channel and transition to approval state.
|
||||
m.approvalChan = msg.ResponseChan
|
||||
m.state = stateApproval
|
||||
// TODO (TAS-17): construct ApprovalComponent with msg.ToolName/ToolArgs.
|
||||
|
||||
case app.StepCompleteEvent:
|
||||
// Emit the completed response above the BT region via tea.Println,
|
||||
// then reset the stream component and return to input state.
|
||||
cmds = append(cmds, m.printCompletedResponse(msg))
|
||||
if m.stream != nil {
|
||||
m.stream.Reset()
|
||||
}
|
||||
m.state = stateInput
|
||||
m.canceling = false
|
||||
|
||||
case app.StepErrorEvent:
|
||||
// Render the error inline in the stream area, then return to input.
|
||||
if m.stream != nil {
|
||||
_, cmd := m.stream.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
m.state = stateInput
|
||||
m.canceling = false
|
||||
|
||||
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)
|
||||
}
|
||||
if m.state == stateApproval && m.approval != nil {
|
||||
_, cmd := m.approval.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View implements tea.Model. It renders the stacked layout:
|
||||
// stream region + separator + input region.
|
||||
func (m *AppModel) View() tea.View {
|
||||
streamView := m.renderStream()
|
||||
separator := m.renderSeparator()
|
||||
inputView := m.renderInput()
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||
streamView,
|
||||
separator,
|
||||
inputView,
|
||||
)
|
||||
|
||||
return tea.NewView(content)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// renderStream returns the stream region content.
|
||||
func (m *AppModel) renderStream() string {
|
||||
if m.stream == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if m.state == stateApproval && m.approval != nil {
|
||||
// Show both stream context and the approval dialog stacked.
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.stream.View().Content,
|
||||
m.approval.View().Content,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// renderSeparator renders the separator line with an optional queue badge.
|
||||
func (m *AppModel) renderSeparator() string {
|
||||
theme := GetTheme()
|
||||
lineStyle := lipgloss.NewStyle().Foreground(theme.Muted)
|
||||
|
||||
if m.queueCount > 0 {
|
||||
badge := lipgloss.NewStyle().
|
||||
Foreground(theme.Secondary).
|
||||
Render(fmt.Sprintf("%d queued", m.queueCount))
|
||||
|
||||
// Fill the separator with dashes up to the badge.
|
||||
dashWidth := m.width - lipgloss.Width(badge) - 1
|
||||
if dashWidth < 0 {
|
||||
dashWidth = 0
|
||||
}
|
||||
dashes := lineStyle.Render(repeatRune('─', dashWidth))
|
||||
return dashes + " " + badge
|
||||
}
|
||||
|
||||
return lineStyle.Render(repeatRune('─', m.width))
|
||||
}
|
||||
|
||||
// renderInput returns the input region content.
|
||||
func (m *AppModel) renderInput() string {
|
||||
if m.input == nil {
|
||||
return ""
|
||||
}
|
||||
return m.input.View().Content
|
||||
}
|
||||
|
||||
// printCompletedResponse builds a tea.Cmd that emits the final response text
|
||||
// above the BT-managed region using tea.Println. This is used on StepCompleteEvent.
|
||||
func (m *AppModel) printCompletedResponse(evt app.StepCompleteEvent) tea.Cmd {
|
||||
if evt.Response == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := evt.Response.Content.Text()
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rendered string
|
||||
if m.compactMode {
|
||||
msg := m.compactRdr.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
rendered = msg.Content
|
||||
} else {
|
||||
msg := m.renderer.RenderAssistantMessage(content, time.Now(), m.modelName)
|
||||
rendered = msg.Content
|
||||
}
|
||||
|
||||
return tea.Println(rendered)
|
||||
}
|
||||
|
||||
// distributeHeight recalculates child component heights after a window resize.
|
||||
// The input region has a fixed height; the stream region gets the remainder.
|
||||
func (m *AppModel) distributeHeight() {
|
||||
const separatorLines = 1
|
||||
const inputLines = 5 // title (1) + textarea (3) + help (1)
|
||||
|
||||
streamHeight := m.height - separatorLines - inputLines
|
||||
if streamHeight < 0 {
|
||||
streamHeight = 0
|
||||
}
|
||||
|
||||
// Propagate sizes once child components are attached.
|
||||
// (TAS-26 will handle WindowSizeMsg propagation in detail.)
|
||||
_ = streamHeight
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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{}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user