feat(ui): wire interactive path to unified Bubble Tea TUI

Replace the old SetupCLI/runInteractiveLoop flow with a single
tea.NewProgram(AppModel) + appInstance.SetProgram() call. NewAppModel
now constructs InputComponent and StreamComponent inline so the parent
model is fully wired on construction.
This commit is contained in:
Ed Zynda
2026-02-26 01:24:24 +03:00
parent 302b85ab31
commit c7585b17b8
2 changed files with 52 additions and 5 deletions
+45 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/fantasy"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/app"
@@ -21,6 +22,7 @@ import (
"github.com/mark3labs/mcphost/internal/ui"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
)
var (
@@ -774,8 +776,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
}
_ = appInstance // TODO(TAS-19): wire appInstance into the interactive tea.Program
return runInteractiveMode(ctx, mcpAgent, cli, serverNames, toolNames, modelName, messages, sessionManager, hookExecutor, approveToolRun)
return runInteractiveModeBubbleTea(ctx, appInstance, cli, modelName)
}
// AgenticLoopConfig configures the behavior of the unified agentic loop.
@@ -1373,7 +1374,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
}
// runInteractiveMode handles the interactive mode execution
// runInteractiveMode handles the interactive mode execution (legacy path, kept for reference)
func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []fantasy.Message, sessionManager *session.Manager, hookExecutor *hooks.Executor, approveToolRun bool) error {
// Configure and run unified agentic loop
config := AgenticLoopConfig{
@@ -1391,3 +1392,44 @@ func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
}
// runInteractiveModeBubbleTea starts the new unified Bubble Tea interactive TUI.
//
// It:
// 1. Gets the terminal dimensions (falls back to 80x24 if unavailable).
// 2. Creates a ui.AppModel (parent model) with the appInstance as the controller,
// wiring up all child components (InputComponent, StreamComponent).
// 3. Creates a single tea.NewProgram and registers it with appInstance via SetProgram
// so that agent events are routed to the TUI.
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// The old SetupCLI / runInteractiveLoop flow is bypassed entirely for interactive mode.
// cli may be nil if SetupCLI was not called (quiet path); its debug output is displayed
// before handing control to the TUI.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, _ *ui.CLI, modelName string) error {
// Determine terminal size; fall back gracefully.
termWidth, termHeight, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || termWidth == 0 {
termWidth = 80
termHeight = 24
}
// Display startup info via the legacy CLI before handing off to TUI.
// (The CLI was already initialised with model info, tool counts, etc. in runNormalMode.)
// Nothing additional needed here — factory.SetupCLI already displayed it above.
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
CompactMode: viper.GetBool("compact"),
ModelName: modelName,
Width: termWidth,
Height: termHeight,
})
program := tea.NewProgram(appModel)
// Register the program with the app layer so agent events are sent to the TUI.
appInstance.SetProgram(program)
_, runErr := program.Run()
return runErr
}
+7 -2
View File
@@ -155,6 +155,10 @@ type approvalComponentIface interface {
//
// 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. ApprovalComponent is created dynamically per-step
// in Update when a ToolApprovalNeededEvent arrives.
func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
width := opts.Width
if width == 0 {
@@ -176,8 +180,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel {
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).
// 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)
m.stream = NewStreamComponent(opts.CompactMode, width, opts.ModelName)
return m
}