Skip to content

Commit 0703dd1

Browse files
committed
fix: eliminate escape sequence leak from spinner tea.Program instances
Each spinner created a new tea.NewProgram which sent DECRQM queries for synchronized output mode 2026. When the program exited and restored cooked terminal mode, the terminal's DECRPM response leaked as visible ^[[?2026;2$y characters. Replace Bubble Tea spinner with a simple goroutine animation loop writing directly to stderr via lipgloss.
1 parent ce32cea commit 0703dd1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1490
-38846
lines changed

.golangci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: "2"
2+
3+
linters:
4+
enable:
5+
- modernize
6+
7+
formatters:
8+
enable:
9+
- gofmt

btca.config.jsonc

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,21 @@
2323
"type": "git",
2424
"name": "glamour",
2525
"url": "https://github.com/charmbracelet/glamour",
26+
"branch": "v2-exp"
27+
},
28+
{
29+
"type": "git",
30+
"name": "fantasy",
31+
"url": "https://github.com/charmbracelet/fantasy",
32+
"branch": "main"
33+
},
34+
{
35+
"type": "git",
36+
"name": "catwalk",
37+
"url": "https://github.com/charmbracelet/catwalk",
2638
"branch": "main"
2739
}
2840
],
2941
"model": "claude-haiku-4-5",
3042
"provider": "opencode"
31-
}
43+
}

cmd/root.go

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13-
"github.com/cloudwego/eino/schema"
13+
"charm.land/fantasy"
1414
"github.com/mark3labs/mcphost/internal/agent"
1515
"github.com/mark3labs/mcphost/internal/config"
1616
"github.com/mark3labs/mcphost/internal/hooks"
@@ -608,13 +608,12 @@ func runNormalMode(ctx context.Context) error {
608608
tools := mcpAgent.GetTools()
609609
var toolNames []string
610610
for _, tool := range tools {
611-
if info, err := tool.Info(ctx); err == nil {
612-
toolNames = append(toolNames, info.Name)
613-
}
611+
info := tool.Info()
612+
toolNames = append(toolNames, info.Name)
614613
}
615614

616615
// Main interaction logic
617-
var messages []*schema.Message
616+
var messages []fantasy.Message
618617
var sessionManager *session.Manager
619618
if sessionPath != "" {
620619
_, err := os.Stat(sessionPath)
@@ -637,7 +636,8 @@ func runNormalMode(ctx context.Context) error {
637636

638637
// Convert session messages to schema messages
639638
for _, msg := range loadedSession.Messages {
640-
messages = append(messages, msg.ConvertToSchemaMessage())
639+
fantasyMsg := msg.ConvertToFantasyMessage()
640+
messages = append(messages, fantasyMsg)
641641
}
642642

643643
// If we're also saving, use the loaded session with the session manager
@@ -658,9 +658,10 @@ func runNormalMode(ctx context.Context) error {
658658

659659
// Display all previous messages as they would have appeared
660660
for _, sessionMsg := range loadedSession.Messages {
661-
if sessionMsg.Role == "user" {
661+
switch sessionMsg.Role {
662+
case "user":
662663
cli.DisplayUserMessage(sessionMsg.Content)
663-
} else if sessionMsg.Role == "assistant" {
664+
case "assistant":
664665
// Display tool calls if present
665666
if len(sessionMsg.ToolCalls) > 0 {
666667
for _, tc := range sessionMsg.ToolCalls {
@@ -679,7 +680,7 @@ func runNormalMode(ctx context.Context) error {
679680
if sessionMsg.Content != "" {
680681
cli.DisplayAssistantMessage(sessionMsg.Content)
681682
}
682-
} else if sessionMsg.Role == "tool" {
683+
case "tool":
683684
// Display tool result
684685
if sessionMsg.ToolCallID != "" {
685686
if toolCall, exists := toolCallMap[sessionMsg.ToolCallID]; exists {
@@ -773,7 +774,7 @@ type AgenticLoopConfig struct {
773774
}
774775

775776
// addMessagesToHistory adds messages to the conversation history and saves to session if available
776-
func addMessagesToHistory(messages *[]*schema.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages ...*schema.Message) {
777+
func addMessagesToHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages ...fantasy.Message) {
777778
// Add to local history
778779
*messages = append(*messages, newMessages...)
779780

@@ -790,7 +791,7 @@ func addMessagesToHistory(messages *[]*schema.Message, sessionManager *session.M
790791
}
791792

792793
// replaceMessagesHistory replaces the conversation history and saves to session if available
793-
func replaceMessagesHistory(messages *[]*schema.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []*schema.Message) {
794+
func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session.Manager, cli *ui.CLI, newMessages []fantasy.Message) {
794795
// Replace local history
795796
*messages = newMessages
796797

@@ -807,7 +808,7 @@ func replaceMessagesHistory(messages *[]*schema.Message, sessionManager *session
807808
}
808809

809810
// runAgenticLoop handles all execution modes with a single unified loop
810-
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
811+
func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
811812
// Handle initial prompt for non-interactive modes
812813
if !config.IsInteractive && config.InitialPrompt != "" {
813814
// Execute UserPromptSubmit hooks for non-interactive mode
@@ -837,7 +838,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
837838
}
838839

839840
// Create temporary messages with user input for processing (don't add to history yet)
840-
tempMessages := append(messages, schema.UserMessage(config.InitialPrompt))
841+
tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt))
841842

842843
// Process the initial prompt with tool calls
843844
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
@@ -875,7 +876,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
875876
}
876877

877878
// runAgenticStep processes a single step of the agentic loop (handles tool calls)
878-
func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*schema.Message, []*schema.Message, error) {
879+
func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*fantasy.Response, []fantasy.Message, error) {
879880
var currentSpinner *ui.Spinner
880881

881882
// Start initial spinner (skip if quiet)
@@ -1153,29 +1154,38 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
11531154
if len(messages) > 0 {
11541155
// Find the last user message
11551156
for i := len(messages) - 1; i >= 0; i-- {
1156-
if messages[i].Role == schema.User {
1157-
lastUserMessage = messages[i].Content
1157+
if messages[i].Role == fantasy.MessageRoleUser {
1158+
// Extract text from message parts
1159+
for _, part := range messages[i].Content {
1160+
if tp, ok := part.(fantasy.TextPart); ok {
1161+
lastUserMessage = tp.Text
1162+
break
1163+
}
1164+
}
11581165
break
11591166
}
11601167
}
11611168
}
11621169

1170+
// Get text content from response
1171+
responseText := response.Content.Text()
1172+
11631173
// Update usage tracking for ALL responses (streaming and non-streaming)
11641174
if !config.Quiet && cli != nil {
11651175
cli.UpdateUsageFromResponse(response, lastUserMessage)
11661176
}
11671177

11681178
// Display assistant response with model name
11691179
// Skip if: quiet mode, same content already displayed, or if streaming completed the full response
1170-
streamedFullResponse := responseWasStreamed && streamingContent.String() == response.Content
1171-
if !config.Quiet && cli != nil && response.Content != lastDisplayedContent && response.Content != "" && !streamedFullResponse {
1172-
if err := cli.DisplayAssistantMessageWithModel(response.Content, config.ModelName); err != nil {
1180+
streamedFullResponse := responseWasStreamed && streamingContent.String() == responseText
1181+
if !config.Quiet && cli != nil && responseText != lastDisplayedContent && responseText != "" && !streamedFullResponse {
1182+
if err := cli.DisplayAssistantMessageWithModel(responseText, config.ModelName); err != nil {
11731183
cli.DisplayError(fmt.Errorf("display error: %v", err))
11741184
return nil, nil, err
11751185
}
11761186
} else if config.Quiet {
11771187
// In quiet mode, only output the final response content to stdout
1178-
fmt.Print(response.Content)
1188+
fmt.Print(responseText)
11791189
}
11801190

11811191
// Display usage information immediately after the response (for both streaming and non-streaming)
@@ -1191,15 +1201,14 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
11911201
}
11921202

11931203
// executeStopHook executes the Stop hook if a hook executor is available
1194-
func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, stopReason string, modelName string) {
1204+
func executeStopHook(hookExecutor *hooks.Executor, response *fantasy.Response, stopReason string, modelName string) {
11951205
if hookExecutor != nil {
11961206
// Prepare metadata
11971207
var meta json.RawMessage
11981208
if response != nil {
1199-
metaData := map[string]interface{}{
1209+
metaData := map[string]any{
12001210
"model": modelName,
1201-
"role": string(response.Role),
1202-
"has_tool_calls": len(response.ToolCalls) > 0,
1211+
"has_tool_calls": len(response.Content.ToolCalls()) > 0,
12031212
}
12041213
if metaBytes, err := json.Marshal(metaData); err == nil {
12051214
meta = json.RawMessage(metaBytes)
@@ -1208,7 +1217,7 @@ func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, sto
12081217

12091218
responseContent := ""
12101219
if response != nil {
1211-
responseContent = response.Content
1220+
responseContent = response.Content.Text()
12121221
}
12131222

12141223
input := &hooks.StopInput{
@@ -1225,7 +1234,7 @@ func executeStopHook(hookExecutor *hooks.Executor, response *schema.Message, sto
12251234
}
12261235

12271236
// runInteractiveLoop handles the interactive portion of the agentic loop
1228-
func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []*schema.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
1237+
func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error {
12291238
for {
12301239
// Get user input
12311240
prompt, err := cli.GetPrompt()
@@ -1292,7 +1301,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
12921301
cli.DisplayUserMessage(prompt)
12931302

12941303
// Create temporary messages with user input for processing
1295-
tempMessages := append(messages, schema.UserMessage(prompt))
1304+
tempMessages := append(messages, fantasy.NewUserMessage(prompt))
12961305
// Process the user input with tool calls
12971306
_, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor)
12981307
if err != nil {
@@ -1312,7 +1321,7 @@ func runInteractiveLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
13121321
}
13131322

13141323
// runNonInteractiveMode handles the non-interactive mode execution
1315-
func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []*schema.Message, quiet, noExit bool, mcpConfig *config.Config, sessionManager *session.Manager, hookExecutor *hooks.Executor) error {
1324+
func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, prompt, modelName string, messages []fantasy.Message, quiet, noExit bool, mcpConfig *config.Config, sessionManager *session.Manager, hookExecutor *hooks.Executor) error {
13161325
// Prepare data for slash commands (needed if continuing to interactive mode)
13171326
var serverNames []string
13181327
for name := range mcpConfig.MCPServers {
@@ -1322,9 +1331,8 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C
13221331
tools := mcpAgent.GetTools()
13231332
var toolNames []string
13241333
for _, tool := range tools {
1325-
if info, err := tool.Info(ctx); err == nil {
1326-
toolNames = append(toolNames, info.Name)
1327-
}
1334+
info := tool.Info()
1335+
toolNames = append(toolNames, info.Name)
13281336
}
13291337

13301338
// Configure and run unified agentic loop
@@ -1345,7 +1353,7 @@ func runNonInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.C
13451353
}
13461354

13471355
// runInteractiveMode handles the interactive mode execution
1348-
func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, serverNames, toolNames []string, modelName string, messages []*schema.Message, sessionManager *session.Manager, hookExecutor *hooks.Executor, approveToolRun bool) error {
1356+
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 {
13491357
// Configure and run unified agentic loop
13501358
config := AgenticLoopConfig{
13511359
IsInteractive: true,

cmd/script.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13-
"github.com/cloudwego/eino/schema"
13+
"charm.land/fantasy"
1414
"github.com/mark3labs/mcphost/internal/agent"
1515
"github.com/mark3labs/mcphost/internal/config"
1616
"github.com/mark3labs/mcphost/internal/hooks"
@@ -177,8 +177,8 @@ func parseCustomVariables(_ *cobra.Command) map[string]string {
177177
}
178178

179179
// Parse custom variables with --args: prefix
180-
if strings.HasPrefix(arg, "--args:") {
181-
varName := strings.TrimPrefix(arg, "--args:")
180+
if after, ok := strings.CutPrefix(arg, "--args:"); ok {
181+
varName := after
182182
if varName == "" {
183183
continue // Skip malformed --args: without name
184184
}
@@ -312,7 +312,7 @@ func parseScriptContent(content string, variables map[string]string) (*config.Co
312312
var promptLines []string
313313
var inFrontmatter bool
314314
var foundFrontmatter bool
315-
var frontmatterEnd int = -1
315+
var frontmatterEnd = -1
316316

317317
for i, line := range lines {
318318
trimmed := strings.TrimSpace(line)
@@ -699,13 +699,12 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string,
699699
tools := mcpAgent.GetTools()
700700
var toolNames []string
701701
for _, tool := range tools {
702-
if info, err := tool.Info(ctx); err == nil {
703-
toolNames = append(toolNames, info.Name)
704-
}
702+
info := tool.Info()
703+
toolNames = append(toolNames, info.Name)
705704
}
706705

707706
// Configure and run unified agentic loop
708-
var messages []*schema.Message
707+
var messages []fantasy.Message
709708
config := AgenticLoopConfig{
710709
IsInteractive: prompt == "", // If no prompt, start in interactive mode
711710
InitialPrompt: prompt,

cmd/script_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Working directory is ${env://WORK_DIR:-/tmp}.
8282
t.Fatal("Filesystem server not found in script config")
8383
}
8484

85-
allowedDirs, ok := fsServer.Options["allowed_directories"].([]interface{})
85+
allowedDirs, ok := fsServer.Options["allowed_directories"].([]any)
8686
if !ok {
8787
t.Fatal("allowed_directories should be an array")
8888
}

0 commit comments

Comments
 (0)