From c7585b17b8ff33ecf4029b61d31925dc0687bfa4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 26 Feb 2026 01:24:24 +0300 Subject: [PATCH] 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. --- cmd/root.go | 48 +++++++++++++++++++++++++++++++++++++++++--- internal/ui/model.go | 9 +++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 93cea61b..f18adb49 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 89c08a8b..588d3616 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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 }