diff --git a/cmd/root.go b/cmd/root.go index 58cf5155..e9af179e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,14 +7,12 @@ import ( "log" "os" "strings" - "time" tea "charm.land/bubbletea/v2" "charm.land/fantasy" "github.com/mark3labs/mcphost/internal/agent" "github.com/mark3labs/mcphost/internal/app" "github.com/mark3labs/mcphost/internal/config" - "github.com/mark3labs/mcphost/internal/hooks" "github.com/mark3labs/mcphost/internal/models" "github.com/mark3labs/mcphost/internal/session" "github.com/mark3labs/mcphost/internal/tools" @@ -38,7 +36,6 @@ var ( streamFlag bool // Enable streaming output compactMode bool // Enable compact output mode scriptMCPConfig *config.Config // Used to override config in script mode - approveToolRun bool // Session management saveSessionPath string @@ -57,7 +54,6 @@ var ( mainGPU int32 // Hooks control - noHooks bool // TLS configuration tlsSkipVerify bool @@ -201,19 +197,6 @@ func InitConfig() { viper.SetEnvPrefix("MCPHOST") viper.AutomaticEnv() - // Load hooks configuration unless disabled - if !viper.GetBool("no-hooks") { - hooksConfig, err := hooks.LoadHooksConfig() - if err != nil { - // Hooks are optional, so just log a warning - if debugMode { - fmt.Fprintf(os.Stderr, "Warning: Failed to load hooks configuration: %v\n", err) - } - } else { - viper.Set("hooks", hooksConfig) - } - } - } // LoadConfigWithEnvSubstitution loads a config file with environment variable substitution. @@ -301,12 +284,6 @@ func init() { BoolVar(&streamFlag, "stream", true, "enable streaming output for faster response display") rootCmd.PersistentFlags(). BoolVar(&compactMode, "compact", false, "enable compact output mode without fancy styling") - rootCmd.PersistentFlags(). - BoolVar(&noHooks, "no-hooks", false, "disable all hooks execution") - rootCmd.PersistentFlags(). - BoolVar(&approveToolRun, "approve-tool-run", false, "enable requiring user approval for every tool call") - - // Session management flags rootCmd.PersistentFlags(). StringVar(&saveSessionPath, "save-session", "", "save session to file after each message") rootCmd.PersistentFlags(). @@ -339,7 +316,7 @@ func init() { _ = viper.BindPFlag("max-steps", rootCmd.PersistentFlags().Lookup("max-steps")) _ = viper.BindPFlag("stream", rootCmd.PersistentFlags().Lookup("stream")) _ = viper.BindPFlag("compact", rootCmd.PersistentFlags().Lookup("compact")) - _ = viper.BindPFlag("no-hooks", rootCmd.PersistentFlags().Lookup("no-hooks")) + _ = viper.BindPFlag("provider-url", rootCmd.PersistentFlags().Lookup("provider-url")) _ = viper.BindPFlag("provider-api-key", rootCmd.PersistentFlags().Lookup("provider-api-key")) _ = viper.BindPFlag("max-tokens", rootCmd.PersistentFlags().Lookup("max-tokens")) @@ -350,7 +327,6 @@ func init() { _ = viper.BindPFlag("num-gpu-layers", rootCmd.PersistentFlags().Lookup("num-gpu-layers")) _ = viper.BindPFlag("main-gpu", rootCmd.PersistentFlags().Lookup("main-gpu")) _ = viper.BindPFlag("tls-skip-verify", rootCmd.PersistentFlags().Lookup("tls-skip-verify")) - _ = viper.BindPFlag("approve-tool-run", rootCmd.PersistentFlags().Lookup("approve-tool-run")) // Defaults are already set in flag definitions, no need to duplicate in viper @@ -470,20 +446,6 @@ func runNormalMode(ctx context.Context) error { modelName = "Unknown" } - var hookExecutor *hooks.Executor - if hooksConfig := viper.Get("hooks"); hooksConfig != nil { - if hc, ok := hooksConfig.(*hooks.HookConfig); ok { - // Generate a session ID for this run - sessionID := fmt.Sprintf("mcphost-%d", time.Now().Unix()) - transcriptPath := "" // We could add transcript logging later - hookExecutor = hooks.NewExecutor(hc, sessionID, transcriptPath) - - // Set model and interactive mode - hookExecutor.SetModel(modelString) - hookExecutor.SetInteractive(promptFlag == "") // Interactive if no prompt flag - } - } - // Create CLI for non-interactive mode only. SetupCLI is the factory for the // non-interactive (quiet and non-quiet) path; interactive mode uses the full // Bubble Tea TUI (AppModel) which handles its own rendering. @@ -738,23 +700,9 @@ func runNormalMode(ctx context.Context) error { }) } - // Determine approval function per mode. - // Non-interactive: auto-approve unless --approve-tool-run is set (interactive TUI approval - // is handled by app.buildApprovalFunc when a tea.Program is registered via SetProgram). - approveToolRun := viper.GetBool("approve-tool-run") - var toolApprovalFunc app.ToolApprovalFunc - if promptFlag != "" && !approveToolRun { - // Non-interactive and no explicit approval required → auto-approve. - toolApprovalFunc = app.AutoApproveFunc - } - // In interactive mode (promptFlag == "") the TUI handles approval via program.Send(); - // toolApprovalFunc remains nil here and is ignored when a tea.Program is set. - // Create the app.App instance now that session messages are loaded. appOpts := app.Options{ Agent: mcpAgent, - ToolApprovalFunc: toolApprovalFunc, - HookExecutor: hookExecutor, SessionManager: sessionManager, MCPConfig: mcpConfig, ModelName: modelName, diff --git a/cmd/script.go b/cmd/script.go index 8b8105da..c5265df3 100644 --- a/cmd/script.go +++ b/cmd/script.go @@ -9,12 +9,10 @@ import ( "os" "regexp" "strings" - "time" "charm.land/fantasy" "github.com/mark3labs/mcphost/internal/agent" "github.com/mark3labs/mcphost/internal/config" - "github.com/mark3labs/mcphost/internal/hooks" "github.com/mark3labs/mcphost/internal/models" "github.com/mark3labs/mcphost/internal/session" "github.com/mark3labs/mcphost/internal/tools" @@ -677,21 +675,6 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, cli.DisplayDebugConfig(debugConfig) } - // Initialize hooks - var hookExecutor *hooks.Executor - if hooksConfig := viper.Get("hooks"); hooksConfig != nil { - if hc, ok := hooksConfig.(*hooks.HookConfig); ok { - // Generate a session ID for this run - sessionID := fmt.Sprintf("mcphost-%d", time.Now().Unix()) - transcriptPath := "" // We could add transcript logging later - hookExecutor = hooks.NewExecutor(hc, sessionID, transcriptPath) - - // Set model and interactive mode - hookExecutor.SetModel(finalModel) - hookExecutor.SetInteractive(prompt == "") - } - } - // Prepare data for slash commands var serverNames []string for name := range mcpConfig.MCPServers { @@ -718,7 +701,7 @@ func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, MCPConfig: mcpConfig, } - return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor) + return runAgenticLoop(ctx, mcpAgent, cli, messages, config) } // AgenticLoopConfig configures the behavior of the unified agentic loop. @@ -729,7 +712,6 @@ type AgenticLoopConfig struct { IsInteractive bool // true for interactive mode, false for non-interactive InitialPrompt string // initial prompt for non-interactive mode ContinueAfterRun bool // true to continue to interactive mode after initial run (--no-exit) - ApproveToolRun bool // only used in interactive mode // UI configuration Quiet bool // suppress all output except final response @@ -760,30 +742,9 @@ func replaceMessagesHistory(messages *[]fantasy.Message, sessionManager *session } // runAgenticLoop handles all execution modes with a single unified loop -func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) error { +func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig) error { // Handle initial prompt for non-interactive modes if !config.IsInteractive && config.InitialPrompt != "" { - // Execute UserPromptSubmit hooks for non-interactive mode - if hookExecutor != nil { - input := &hooks.UserPromptSubmitInput{ - CommonInput: hookExecutor.PopulateCommonFields(hooks.UserPromptSubmit), - Prompt: config.InitialPrompt, - } - - hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.UserPromptSubmit, input) - if err != nil { - // Log error but don't fail - if debugMode { - fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err) - } - } - - // Check if hook blocked the prompt - if hookOutput != nil && hookOutput.Decision == "block" { - return fmt.Errorf("prompt blocked by hook: %s", hookOutput.Reason) - } - } - // Display user message (skip if quiet) if !config.Quiet && cli != nil { cli.DisplayUserMessage(config.InitialPrompt) @@ -793,7 +754,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes tempMessages := append(messages, fantasy.NewUserMessage(config.InitialPrompt)) // Process the initial prompt with tool calls - _, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config, hookExecutor) + _, conversationMessages, err := runAgenticStep(ctx, mcpAgent, cli, tempMessages, config) if err != nil { // Check if this was a user cancellation if err.Error() == "generation cancelled by user" && cli != nil { @@ -824,7 +785,7 @@ func runAgenticLoop(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes } // runAgenticStep processes a single step of the agentic loop (handles tool calls) -func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig, hookExecutor *hooks.Executor) (*fantasy.Response, []fantasy.Message, error) { +func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, messages []fantasy.Message, config AgenticLoopConfig) (*fantasy.Response, []fantasy.Message, error) { var currentSpinner *ui.Spinner // Start initial spinner (skip if quiet) @@ -863,19 +824,9 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes streamingStarted = false streamingContent.Reset() - // Variables to store tool information for hooks - var currentToolName string - var currentToolArgs string - var toolIsBlocked bool - var blockReason string - result, err := mcpAgent.GenerateWithLoopAndStreaming(ctx, messages, // Tool call handler - called when a tool is about to be executed func(toolName, toolArgs string) { - // Store tool info for use in execution handler - currentToolName = toolName - currentToolArgs = toolArgs - if !config.Quiet && cli != nil { // Stop spinner before displaying tool call if currentSpinner != nil { @@ -888,35 +839,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes // Tool execution handler - called when tool execution starts/ends func(toolName string, isStarting bool) { if isStarting { - // Execute PreToolUse hooks - if hookExecutor != nil { - input := &hooks.PreToolUseInput{ - CommonInput: hookExecutor.PopulateCommonFields(hooks.PreToolUse), - ToolName: currentToolName, - ToolInput: json.RawMessage(currentToolArgs), - } - - hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input) - if err != nil { - // Log error but don't fail the tool execution - if debugMode { - fmt.Fprintf(os.Stderr, "Hook execution error: %v\n", err) - } - } - - // Check if hook blocked the execution - if hookOutput != nil && hookOutput.Decision == "block" { - toolIsBlocked = true - blockReason = hookOutput.Reason - if blockReason == "" { - blockReason = "Tool execution blocked by security policy" - } - if !config.Quiet && cli != nil { - cli.DisplayInfo(fmt.Sprintf("Tool execution blocked by hook: %s", blockReason)) - } - } - } - if !config.Quiet && cli != nil { // Start spinner for tool execution currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName)) @@ -932,48 +854,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes }, // Tool result handler - called when a tool execution completes func(toolName, toolArgs, result string, isError bool) { - // Check if this tool was blocked - if toolIsBlocked { - // Reset the flag for next tool - toolIsBlocked = false - - // Display the blocked message - if !config.Quiet && cli != nil { - cli.DisplayToolMessage(toolName, toolArgs, fmt.Sprintf("Tool execution blocked: %s", blockReason), true) - } - - // Reset block reason - blockReason = "" - return - } - - // Execute PostToolUse hooks - var postToolHookOutput *hooks.HookOutput - if hookExecutor != nil && result != "" { - input := &hooks.PostToolUseInput{ - CommonInput: hookExecutor.PopulateCommonFields(hooks.PostToolUse), - ToolName: currentToolName, - ToolInput: json.RawMessage(currentToolArgs), - ToolResponse: json.RawMessage(result), - } - - hookOutput, err := hookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input) - if err != nil { - // Log error but don't fail - if debugMode { - fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err) - } - } - postToolHookOutput = hookOutput - } - - // Check if hook wants to suppress output - if postToolHookOutput != nil && postToolHookOutput.SuppressOutput { - // Skip displaying tool result to user - // Note: Result still goes to LLM unless ModifyOutput is used - return - } - if !config.Quiet && cli != nil { // Parse tool result content - it might be JSON-encoded MCP content resultContent := result @@ -1051,22 +931,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes }, // Add streaming callback handler streamingCallback, - // Tool call approval handler - called before tool execution to get user approval - func(toolName, toolArgs string) (bool, error) { - if !config.IsInteractive || !config.ApproveToolRun { - return true, nil - } - if currentSpinner != nil { - currentSpinner.Stop() - currentSpinner = nil - } - // Tool approval via CLI is no longer supported; always approve in legacy path. - // Start spinner again for tool calls - currentSpinner = ui.NewSpinner("") - currentSpinner.Start() - - return true, nil - }, ) // Make sure spinner is stopped if still running @@ -1129,42 +993,6 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes cli.DisplayUsageAfterResponse() } - // Execute Stop hook after agent has finished responding - executeStopHook(hookExecutor, response, "completed", config.ModelName) - // Return the final response and all conversation messages return response, conversationMessages, nil } - -// executeStopHook executes the Stop hook if a hook executor is available -func executeStopHook(hookExecutor *hooks.Executor, response *fantasy.Response, stopReason string, modelName string) { - if hookExecutor != nil { - // Prepare metadata - var meta json.RawMessage - if response != nil { - metaData := map[string]any{ - "model": modelName, - "has_tool_calls": len(response.Content.ToolCalls()) > 0, - } - if metaBytes, err := json.Marshal(metaData); err == nil { - meta = json.RawMessage(metaBytes) - } - } - - responseContent := "" - if response != nil { - responseContent = response.Content.Text() - } - - input := &hooks.StopInput{ - CommonInput: hookExecutor.PopulateCommonFields(hooks.Stop), - StopHookActive: true, - Response: responseContent, - StopReason: stopReason, - Meta: meta, - } - - // Execute Stop hook (ignore errors as we're exiting anyway) - _, _ = hookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input) - } -} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5e6dc229..a7caef69 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -41,9 +41,6 @@ type StreamingResponseHandler func(content string) // ToolCallContentHandler is a function type for handling content that accompanies tool calls. type ToolCallContentHandler func(content string) -// ToolApprovalHandler is a function type for handling user approval of tool calls. -type ToolApprovalHandler func(toolName, toolArgs string) (bool, error) - // Agent represents an AI agent with MCP tool integration using the fantasy library. // It manages the interaction between an LLM and various tools through the MCP protocol. type Agent struct { @@ -135,10 +132,10 @@ func NewAgent(ctx context.Context, agentConfig *AgentConfig) (*Agent, error) { // GenerateWithLoop processes messages with a custom loop that displays tool calls in real-time. func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message, onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler, - onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, onToolApproval ToolApprovalHandler, + onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, ) (*GenerateWithLoopResult, error) { return a.GenerateWithLoopAndStreaming(ctx, messages, onToolCall, onToolExecution, onToolResult, - onResponse, onToolCallContent, nil, onToolApproval) + onResponse, onToolCallContent, nil) } // GenerateWithLoopAndStreaming processes messages using the fantasy agent with streaming and callbacks. @@ -147,7 +144,7 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message, onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler, onResponse ResponseHandler, onToolCallContent ToolCallContentHandler, - onStreamingResponse StreamingResponseHandler, onToolApproval ToolApprovalHandler, + onStreamingResponse StreamingResponseHandler, ) (*GenerateWithLoopResult, error) { // Fantasy requires the current user input as Prompt, with prior messages as history. @@ -177,17 +174,6 @@ func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fan currentToolName = tc.ToolName currentToolArgs = tc.Input - // Check approval if handler is set - if onToolApproval != nil { - approved, err := onToolApproval(tc.ToolName, tc.Input) - if err != nil { - return err - } - if !approved { - return fmt.Errorf("tool call %s rejected by user", tc.ToolName) - } - } - // Notify about the tool call if onToolCall != nil { onToolCall(tc.ToolName, tc.Input) diff --git a/internal/app/app.go b/internal/app/app.go index a4bfaaaa..c851a882 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,17 +2,14 @@ package app import ( "context" - "encoding/json" "fmt" "io" - "os" "sync" tea "charm.land/bubbletea/v2" "charm.land/fantasy" "github.com/mark3labs/mcphost/internal/agent" - "github.com/mark3labs/mcphost/internal/hooks" ) // App is the application-layer orchestrator. It owns the agentic loop, @@ -93,17 +90,8 @@ func (a *App) SetProgram(p *tea.Program) { // executed immediately in a background goroutine; otherwise it is appended // to the queue and a QueueUpdatedEvent is sent to the program. // -// Before queuing, the UserPromptSubmit hook is fired. If the hook blocks the -// prompt, a HookBlockedEvent is sent and the prompt is dropped. -// // Satisfies ui.AppController. func (a *App) Run(prompt string) { - // Fire UserPromptSubmit hook before accepting the prompt. - if blocked, reason := a.fireUserPromptSubmitHook(prompt); blocked { - a.sendEvent(HookBlockedEvent{Message: fmt.Sprintf("Prompt blocked by hook: %s", reason)}) - return - } - a.mu.Lock() if a.closed { @@ -181,20 +169,12 @@ func (a *App) RunOnce(ctx context.Context, prompt string, w io.Writer) error { result, err := a.executeStep(stepCtx, prompt, nil /* program */, nil /* writer */) if err != nil { - stopReason := "error" - if stepCtx.Err() != nil { - stopReason = "cancelled" - } - a.fireStopHook(nil, stopReason) return err } // Record token usage for the completed step. a.updateUsage(result, prompt) - // Fire Stop hook on successful completion. - a.fireStopHook(result.FinalResponse, "completed") - responseText := "" if result.FinalResponse != nil { responseText = result.FinalResponse.Content.Text() @@ -283,12 +263,6 @@ func (a *App) runPrompt(prompt string) { result, err := a.executeStep(stepCtx, prompt, prog, nil) if err != nil { - // Fire Stop hook on error/cancellation. - stopReason := "error" - if stepCtx.Err() != nil { - stopReason = "cancelled" - } - a.fireStopHook(nil, stopReason) a.sendEvent(StepErrorEvent{Err: err}) return } @@ -296,9 +270,6 @@ func (a *App) runPrompt(prompt string) { // Record token usage for the completed step. a.updateUsage(result, prompt) - // Fire Stop hook on successful completion. - a.fireStopHook(result.FinalResponse, "completed") - a.sendEvent(StepCompleteEvent{ Response: result.FinalResponse, Usage: result.TotalUsage, @@ -332,58 +303,17 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program, // Signal spinner start. sendFn(SpinnerEvent{Show: true}) - // Wire the approval callback. - onApproval := a.buildApprovalFunc(ctx, prog) - - // Per-step state tracking for hook callbacks. - var ( - currentToolName string - currentToolArgs string - toolIsBlocked bool - blockReason string - ) - result, err := a.opts.Agent.GenerateWithLoopAndStreaming(ctx, msgs, - // onToolCall — store name/args for subsequent hook callbacks + // onToolCall func(toolName, toolArgs string) { - currentToolName = toolName - currentToolArgs = toolArgs sendFn(ToolCallStartedEvent{ToolName: toolName, ToolArgs: toolArgs}) }, - // onToolExecution — fire PreToolUse on isStarting=true + // onToolExecution func(toolName string, isStarting bool) { - if isStarting { - if blocked, reason := a.firePreToolUseHook(ctx, currentToolName, currentToolArgs); blocked { - toolIsBlocked = true - blockReason = reason - if blockReason == "" { - blockReason = "Tool execution blocked by security policy" - } - sendFn(HookBlockedEvent{Message: fmt.Sprintf("Tool execution blocked by hook: %s", blockReason)}) - } - } sendFn(ToolExecutionEvent{ToolName: toolName, IsStarting: isStarting}) }, - // onToolResult — fire PostToolUse; honour block flag + // onToolResult func(toolName, toolArgs, result string, isError bool) { - if toolIsBlocked { - // Reset flag; result event carries "blocked" info. - toolIsBlocked = false - blockMsg := fmt.Sprintf("Tool execution blocked: %s", blockReason) - blockReason = "" - sendFn(ToolResultEvent{ - ToolName: toolName, - ToolArgs: toolArgs, - Result: blockMsg, - IsError: true, - }) - return - } - // Fire PostToolUse hook. - if postOut := a.firePostToolUseHook(ctx, currentToolName, currentToolArgs, result); postOut != nil && postOut.SuppressOutput { - // Hook asked to suppress output; skip sending the result event. - return - } sendFn(ToolResultEvent{ ToolName: toolName, ToolArgs: toolArgs, @@ -404,8 +334,6 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program, sendFn(SpinnerEvent{Show: false}) sendFn(StreamChunkEvent{Content: chunk}) }, - // onToolApproval - onApproval, ) if err != nil { @@ -419,43 +347,6 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program, return result, nil } -// buildApprovalFunc returns the ToolApprovalHandler to use for a step. -// In interactive mode (prog != nil) it sends a ToolApprovalNeededEvent and -// blocks until the TUI responds; otherwise it delegates to opts.ToolApprovalFunc. -func (a *App) buildApprovalFunc(ctx context.Context, prog *tea.Program) agent.ToolApprovalHandler { - if prog == nil { - // Non-interactive: use the configured approval func. - return func(toolName, toolArgs string) (bool, error) { - if a.opts.ToolApprovalFunc == nil { - return true, nil - } - return a.opts.ToolApprovalFunc(ctx, toolName, toolArgs) - } - } - - return func(toolName, toolArgs string) (bool, error) { - // Parse toolArgs for display. If it's not JSON just use raw string. - displayArgs := toolArgs - if json.Valid([]byte(toolArgs)) { - displayArgs = toolArgs - } - - responseCh := make(chan bool, 1) - prog.Send(ToolApprovalNeededEvent{ - ToolName: toolName, - ToolArgs: displayArgs, - ResponseChan: responseCh, - }) - - select { - case <-ctx.Done(): - return false, ctx.Err() - case approved := <-responseCh: - return approved, nil - } - } -} - // -------------------------------------------------------------------------- // Internal: event helpers // -------------------------------------------------------------------------- @@ -471,79 +362,6 @@ func (a *App) sendEvent(msg tea.Msg) { } } -// -------------------------------------------------------------------------- -// Internal: hook helpers -// -------------------------------------------------------------------------- - -// fireUserPromptSubmitHook fires the UserPromptSubmit hook. -// Returns (blocked bool, reason string). -func (a *App) fireUserPromptSubmitHook(prompt string) (bool, string) { - if a.opts.HookExecutor == nil { - return false, "" - } - input := &hooks.UserPromptSubmitInput{ - CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.UserPromptSubmit), - Prompt: prompt, - } - output, err := a.opts.HookExecutor.ExecuteHooks(context.Background(), hooks.UserPromptSubmit, input) - if err != nil { - if a.opts.Debug { - fmt.Fprintf(os.Stderr, "UserPromptSubmit hook execution error: %v\n", err) - } - return false, "" - } - if output != nil && output.Decision == "block" { - return true, output.Reason - } - return false, "" -} - -// firePreToolUseHook fires the PreToolUse hook before a tool executes. -// Returns (blocked bool, reason string). -func (a *App) firePreToolUseHook(ctx context.Context, toolName, toolArgs string) (bool, string) { - if a.opts.HookExecutor == nil { - return false, "" - } - input := &hooks.PreToolUseInput{ - CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.PreToolUse), - ToolName: toolName, - ToolInput: json.RawMessage(toolArgs), - } - output, err := a.opts.HookExecutor.ExecuteHooks(ctx, hooks.PreToolUse, input) - if err != nil { - if a.opts.Debug { - fmt.Fprintf(os.Stderr, "PreToolUse hook execution error: %v\n", err) - } - return false, "" - } - if output != nil && output.Decision == "block" { - return true, output.Reason - } - return false, "" -} - -// firePostToolUseHook fires the PostToolUse hook after a tool executes. -// Returns the hook output (may be nil if no hooks configured or on error). -func (a *App) firePostToolUseHook(ctx context.Context, toolName, toolArgs, result string) *hooks.HookOutput { - if a.opts.HookExecutor == nil || result == "" { - return nil - } - input := &hooks.PostToolUseInput{ - CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.PostToolUse), - ToolName: toolName, - ToolInput: json.RawMessage(toolArgs), - ToolResponse: json.RawMessage(result), - } - output, err := a.opts.HookExecutor.ExecuteHooks(ctx, hooks.PostToolUse, input) - if err != nil { - if a.opts.Debug { - fmt.Fprintf(os.Stderr, "PostToolUse hook execution error: %v\n", err) - } - return nil - } - return output -} - // updateUsage records token usage from a completed agent step into the configured // UsageTracker (if any). It uses the actual token counts from the agent result's // TotalUsage field when available; otherwise it falls back to text-based estimation. @@ -568,38 +386,3 @@ func (a *App) updateUsage(result *agent.GenerateWithLoopResult, userPrompt strin a.opts.UsageTracker.EstimateAndUpdateUsage(userPrompt, responseText) } } - -// fireStopHook fires the Stop hook after a step completes, errors, or is cancelled. -// response may be nil for error/cancelled steps. -func (a *App) fireStopHook(response *fantasy.Response, stopReason string) { - if a.opts.HookExecutor == nil { - return - } - - var meta json.RawMessage - if response != nil { - metaData := map[string]any{ - "model": a.opts.ModelName, - "has_tool_calls": len(response.Content.ToolCalls()) > 0, - } - if metaBytes, err := json.Marshal(metaData); err == nil { - meta = json.RawMessage(metaBytes) - } - } - - responseContent := "" - if response != nil { - responseContent = response.Content.Text() - } - - input := &hooks.StopInput{ - CommonInput: a.opts.HookExecutor.PopulateCommonFields(hooks.Stop), - StopHookActive: true, - Response: responseContent, - StopReason: stopReason, - Meta: meta, - } - - // Execute Stop hook (ignore errors — we're completing regardless). - _, _ = a.opts.HookExecutor.ExecuteHooks(context.Background(), hooks.Stop, input) -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index fe54a592..f9f23b6e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -57,7 +57,6 @@ func (s *stubAgent) GenerateWithLoopAndStreaming( _ agent.ResponseHandler, _ agent.ToolCallContentHandler, _ agent.StreamingResponseHandler, - _ agent.ToolApprovalHandler, ) (*agent.GenerateWithLoopResult, error) { // Optional blocking: wait for a signal or ctx cancellation. if s.blockCh != nil { @@ -361,105 +360,6 @@ func TestCancelCurrentStep_safeWhenIdle(t *testing.T) { app.CancelCurrentStep() } -// -------------------------------------------------------------------------- -// Cancel during tool approval (ToolApprovalFunc unblocks via ctx) -// -------------------------------------------------------------------------- - -// TestCancelDuringApproval verifies that cancelling the step while the -// ToolApprovalFunc is waiting unblocks the func via ctx.Done() and the step -// terminates cleanly. -func TestCancelDuringApproval(t *testing.T) { - approvalUnblocked := make(chan struct{}, 1) - - // ToolApprovalFunc blocks until ctx is cancelled. - approvalFunc := func(ctx context.Context, _, _ string) (bool, error) { - <-ctx.Done() - approvalUnblocked <- struct{}{} - return false, ctx.Err() - } - - // captureApprovalStub calls onToolApproval() which delegates to approvalFunc. - stub := newCaptureApprovalStub() - - app := New(Options{ - Agent: stub, - ToolApprovalFunc: approvalFunc, - }, nil) - defer app.Close() - - app.Run("trigger approval") - - // Wait for the stub to signal it's started (and therefore calling approval). - select { - case <-stub.started: - case <-time.After(2 * time.Second): - t.Fatal("step never started") - } - - // Give the approval func a moment to block on ctx.Done(). - time.Sleep(10 * time.Millisecond) - - app.CancelCurrentStep() - - // Approval func should unblock when ctx is cancelled. - select { - case <-approvalUnblocked: - case <-time.After(2 * time.Second): - t.Fatal("approval func did not unblock after cancel") - } - - ok := waitForCondition(2*time.Second, func() bool { - app.mu.Lock() - defer app.mu.Unlock() - return !app.busy - }) - if !ok { - t.Fatal("app did not become idle after cancel-during-approval") - } -} - -// captureApprovalStub is a specialised stubAgent that calls the captured -// ToolApprovalHandler (which is the app-layer's approval wrapper that delegates -// to Options.ToolApprovalFunc) to simulate the agent needing tool approval. -type captureApprovalStub struct { - started chan struct{} -} - -func newCaptureApprovalStub() *captureApprovalStub { - return &captureApprovalStub{started: make(chan struct{}, 1)} -} - -func (s *captureApprovalStub) GenerateWithLoopAndStreaming( - ctx context.Context, - _ []fantasy.Message, - _ agent.ToolCallHandler, - _ agent.ToolExecutionHandler, - _ agent.ToolResultHandler, - _ agent.ResponseHandler, - _ agent.ToolCallContentHandler, - _ agent.StreamingResponseHandler, - onToolApproval agent.ToolApprovalHandler, -) (*agent.GenerateWithLoopResult, error) { - // Signal that we've started. - select { - case s.started <- struct{}{}: - default: - } - - // Call onToolApproval, which will delegate to ToolApprovalFunc and block. - if onToolApproval != nil { - approved, err := onToolApproval("test_tool", "{}") - if err != nil { - return nil, err - } - if !approved { - return nil, errors.New("tool denied") - } - } - - return makeResult("after approval"), nil -} - // -------------------------------------------------------------------------- // ClearQueue // -------------------------------------------------------------------------- diff --git a/internal/app/events.go b/internal/app/events.go index 35e3dfa0..d791f1c3 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -76,20 +76,6 @@ type QueueUpdatedEvent struct { Length int } -// ToolApprovalNeededEvent is sent when the agent requires user approval before -// executing a tool call. The TUI must display the approval dialog and send the -// user's decision on the provided ResponseChan. -type ToolApprovalNeededEvent struct { - // ToolName is the name of the tool awaiting approval. - ToolName string - // ToolArgs is the JSON-encoded arguments for the pending tool call. - ToolArgs string - // ResponseChan is the channel on which the TUI must send the approval decision. - // Sending true approves the tool call; sending false denies it. - // The app layer goroutine is blocking on this channel (select with ctx.Done). - ResponseChan chan<- bool -} - // SpinnerEvent is sent to show or hide the spinner animation in the stream component. // The spinner is shown before the first streaming chunk arrives and hidden once // content begins flowing or the step completes. @@ -98,13 +84,6 @@ type SpinnerEvent struct { Show bool } -// HookBlockedEvent is sent when a hook returns a block decision, preventing -// the operation from proceeding. The TUI displays the block reason to the user. -type HookBlockedEvent struct { - // Message is the human-readable reason the hook blocked the action. - Message string -} - // MessageCreatedEvent is sent when a new message is added to the message store. // This allows the TUI to stay in sync with the conversation history. type MessageCreatedEvent struct { diff --git a/internal/app/options.go b/internal/app/options.go index 000d10f4..c563ae82 100644 --- a/internal/app/options.go +++ b/internal/app/options.go @@ -7,7 +7,6 @@ import ( "github.com/mark3labs/mcphost/internal/agent" "github.com/mark3labs/mcphost/internal/config" - "github.com/mark3labs/mcphost/internal/hooks" "github.com/mark3labs/mcphost/internal/session" ) @@ -24,15 +23,9 @@ type AgentRunner interface { onResponse agent.ResponseHandler, onToolCallContent agent.ToolCallContentHandler, onStreamingResponse agent.StreamingResponseHandler, - onToolApproval agent.ToolApprovalHandler, ) (*agent.GenerateWithLoopResult, error) } -// ToolApprovalFunc is the callback invoked by the app layer when the agent needs -// user approval before executing a tool call. It must return true to approve or -// false to deny. The ctx is used to unblock the call if the app is shutting down. -type ToolApprovalFunc func(ctx context.Context, toolName, toolArgs string) (bool, error) - // UsageUpdater is the interface the app layer uses to record token usage after // each agent step. It is satisfied by *ui.UsageTracker (which lives in // internal/ui) without creating an import cycle — the concrete type is wired @@ -45,12 +38,6 @@ type UsageUpdater interface { EstimateAndUpdateUsage(inputText, outputText string) } -// AutoApproveFunc is a ToolApprovalFunc that always approves tool calls. -// Used in non-interactive mode. -var AutoApproveFunc ToolApprovalFunc = func(_ context.Context, _, _ string) (bool, error) { - return true, nil -} - // Options configures an App instance. It mirrors the fields from AgenticLoopConfig // in cmd/root.go but is owned by the app layer rather than the CLI. type Options struct { @@ -58,14 +45,6 @@ type Options struct { // *agent.Agent satisfies this interface; tests may supply stubs. Agent AgentRunner - // ToolApprovalFunc is called when the agent needs user confirmation before - // running a tool. Required; use AutoApproveFunc for non-interactive mode. - ToolApprovalFunc ToolApprovalFunc - - // HookExecutor is the optional hooks executor for firing UserPromptSubmit, - // PreToolUse, PostToolUse, and Stop events around the agentic loop. - HookExecutor *hooks.Executor - // SessionManager is the optional session manager for persisting conversation // history to disk. When non-nil, the MessageStore calls it on every mutation. SessionManager *session.Manager diff --git a/internal/config/config.go b/internal/config/config.go index fc9cc64d..0bd8a317 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -166,8 +166,6 @@ type Config struct { Stream *bool `json:"stream,omitempty" yaml:"stream,omitempty"` Theme any `json:"theme" yaml:"theme"` MarkdownTheme any `json:"markdown-theme" yaml:"markdown-theme"` - ApproveToolRun bool `json:"approve-tool-run" yaml:"approve-tool-run"` - // Model generation parameters MaxTokens int `json:"max-tokens,omitempty" yaml:"max-tokens,omitempty"` Temperature *float32 `json:"temperature,omitempty" yaml:"temperature,omitempty"` diff --git a/internal/ui/approval.go b/internal/ui/approval.go deleted file mode 100644 index d5e2ddc0..00000000 --- a/internal/ui/approval.go +++ /dev/null @@ -1,133 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// ApprovalComponent is the tool approval dialog for the parent AppModel. -// It displays tool name and arguments, lets the user approve or deny the call, -// and returns an approvalResultMsg tea.Cmd instead of tea.Quit — lifecycle is -// entirely managed by the parent. -// -// Key bindings: -// - y / Y → approve immediately -// - n / N → deny immediately -// - left → select "yes" -// - right → select "no" -// - enter → confirm current selection -// - esc / ctrl+c → deny (same as "no") -type ApprovalComponent struct { - toolName string - toolArgs string - width int - selected bool // true = "yes" highlighted, false = "no" highlighted -} - -// NewApprovalComponent creates a new ApprovalComponent for the given tool call. -// width is the terminal width passed down from the parent model. -// By default the "yes" option is highlighted. -func NewApprovalComponent(toolName, toolArgs string, width int) *ApprovalComponent { - return &ApprovalComponent{ - toolName: toolName, - toolArgs: toolArgs, - width: width, - selected: true, // default to "yes" - } -} - -// Init implements tea.Model. No startup commands needed. -func (a *ApprovalComponent) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. Handles keyboard input and returns an -// approvalResultMsg tea.Cmd when the user makes a decision. -// It does NOT return tea.Quit — the parent owns the program lifecycle. -func (a *ApprovalComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "y", "Y": - return a, approvalResult(true) - case "n", "N": - return a, approvalResult(false) - case "left": - a.selected = true - return a, nil - case "right": - a.selected = false - return a, nil - case "enter": - return a, approvalResult(a.selected) - case "esc", "ctrl+c": - return a, approvalResult(false) - } - case tea.WindowSizeMsg: - a.width = msg.Width - } - return a, nil -} - -// View implements tea.Model. Renders the approval dialog with tool info and -// yes/no selection. -func (a *ApprovalComponent) View() tea.View { - // Add left padding to entire component (2 spaces like other UI elements). - containerStyle := lipgloss.NewStyle().PaddingLeft(2) - - // Title - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")). - MarginBottom(1) - - // Input box with huh-like styling - inputBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.ThickBorder()). - BorderLeft(true). - BorderRight(false). - BorderTop(false). - BorderBottom(false). - BorderForeground(lipgloss.Color("39")). - PaddingLeft(1). - Width(a.width - 2) // Account for container padding - - // Style for the currently selected option - selectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). // Bright green - Bold(true). - Underline(true) - - // Style for the unselected option - unselectedStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) // Dark gray - - var view strings.Builder - view.WriteString(titleStyle.Render("Allow tool execution")) - view.WriteString("\n") - view.WriteString(fmt.Sprintf("Tool: %s\nArguments: %s\n\n", a.toolName, a.toolArgs)) - view.WriteString("Allow tool execution: ") - - var yesText, noText string - if a.selected { - yesText = selectedStyle.Render("[y]es") - noText = unselectedStyle.Render("[n]o") - } else { - yesText = unselectedStyle.Render("[y]es") - noText = selectedStyle.Render("[n]o") - } - view.WriteString(yesText + "/" + noText + "\n") - - return tea.NewView(containerStyle.Render(inputBoxStyle.Render(view.String()))) -} - -// approvalResult returns a tea.Cmd that emits an approvalResultMsg with the -// given decision. The parent AppModel receives this and sends the result on -// the stored approvalChan. -func approvalResult(approved bool) tea.Cmd { - return func() tea.Msg { - return approvalResultMsg{Approved: approved} - } -} diff --git a/internal/ui/children_test.go b/internal/ui/children_test.go index a9485240..d774598e 100644 --- a/internal/ui/children_test.go +++ b/internal/ui/children_test.go @@ -528,232 +528,3 @@ func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinner(t *testing.T) { t.Fatal("expected no tick reschedule when not in spinner phase") } } - -// ========================================================================== -// ApprovalComponent tests -// ========================================================================== - -// -------------------------------------------------------------------------- -// Helpers -// -------------------------------------------------------------------------- - -// newTestApproval creates a new ApprovalComponent with canned tool info. -func newTestApproval() *ApprovalComponent { - return NewApprovalComponent("test_tool", `{"arg":"val"}`, 80) -} - -// sendApprovalMsg calls component.Update and returns the updated component and cmd. -func sendApprovalMsg(a *ApprovalComponent, msg tea.Msg) (*ApprovalComponent, tea.Cmd) { - m, cmd := a.Update(msg) - return m.(*ApprovalComponent), cmd -} - -// extractApprovalResult runs the cmd and returns the approvalResultMsg, or fails. -func extractApprovalResult(t *testing.T, cmd tea.Cmd) approvalResultMsg { - t.Helper() - if cmd == nil { - t.Fatal("expected non-nil cmd from approval key press") - } - msg := cmd() - result, ok := msg.(approvalResultMsg) - if !ok { - t.Fatalf("expected approvalResultMsg, got %T", msg) - } - return result -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_DefaultSelection is "yes". -// -------------------------------------------------------------------------- - -func TestApprovalComponent_DefaultSelection_IsYes(t *testing.T) { - a := newTestApproval() - if !a.selected { - t.Fatal("expected default selection to be 'yes' (selected=true)") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_Y_ApproveEmitsTrue verifies that pressing 'y' emits -// approvalResultMsg{Approved: true}. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_Y_ApproveEmitsTrue(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'y'}) - result := extractApprovalResult(t, cmd) - if !result.Approved { - t.Fatal("expected Approved=true for 'y' key press") - } -} - -// TestApprovalComponent_YUpper_ApproveEmitsTrue verifies that pressing 'Y' -// also emits approvalResultMsg{Approved: true}. -func TestApprovalComponent_YUpper_ApproveEmitsTrue(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'Y'}) - result := extractApprovalResult(t, cmd) - if !result.Approved { - t.Fatal("expected Approved=true for 'Y' key press") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_N_DenyEmitsFalse verifies that pressing 'n' emits -// approvalResultMsg{Approved: false}. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_N_DenyEmitsFalse(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'n'}) - result := extractApprovalResult(t, cmd) - if result.Approved { - t.Fatal("expected Approved=false for 'n' key press") - } -} - -// TestApprovalComponent_NUpper_DenyEmitsFalse verifies that pressing 'N' -// also emits approvalResultMsg{Approved: false}. -func TestApprovalComponent_NUpper_DenyEmitsFalse(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'N'}) - result := extractApprovalResult(t, cmd) - if result.Approved { - t.Fatal("expected Approved=false for 'N' key press") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_Enter_ConfirmsSelection verifies that pressing enter -// confirms the currently selected option. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_Enter_ConfirmsYesSelection(t *testing.T) { - a := newTestApproval() - a.selected = true // "yes" selected - - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEnter}) - result := extractApprovalResult(t, cmd) - if !result.Approved { - t.Fatal("expected Approved=true when 'yes' is selected and enter pressed") - } -} - -func TestApprovalComponent_Enter_ConfirmsNoSelection(t *testing.T) { - a := newTestApproval() - a.selected = false // "no" selected - - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEnter}) - result := extractApprovalResult(t, cmd) - if result.Approved { - t.Fatal("expected Approved=false when 'no' is selected and enter pressed") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_ESC_DeniesApproval verifies that pressing ESC emits -// approvalResultMsg{Approved: false} (same as "no"). -// -------------------------------------------------------------------------- - -func TestApprovalComponent_ESC_DeniesApproval(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyEscape}) - result := extractApprovalResult(t, cmd) - if result.Approved { - t.Fatal("expected Approved=false for ESC") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_CtrlC_DeniesApproval verifies that ctrl+c emits -// approvalResultMsg{Approved: false}. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_CtrlC_DeniesApproval(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) - result := extractApprovalResult(t, cmd) - if result.Approved { - t.Fatal("expected Approved=false for ctrl+c") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_Left_SelectsYes verifies that pressing left navigates -// to "yes". -// -------------------------------------------------------------------------- - -func TestApprovalComponent_Left_SelectsYes(t *testing.T) { - a := newTestApproval() - a.selected = false // start on "no" - - a, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyLeft}) - - if !a.selected { - t.Fatal("expected selected=true (yes) after pressing left") - } - if cmd != nil { - t.Fatal("expected nil cmd from left arrow (just navigation)") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_Right_SelectsNo verifies that pressing right navigates -// to "no". -// -------------------------------------------------------------------------- - -func TestApprovalComponent_Right_SelectsNo(t *testing.T) { - a := newTestApproval() - a.selected = true // start on "yes" - - a, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: tea.KeyRight}) - - if a.selected { - t.Fatal("expected selected=false (no) after pressing right") - } - if cmd != nil { - t.Fatal("expected nil cmd from right arrow (just navigation)") - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_WindowSizeMsg updates the width field. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_WindowSizeMsg_UpdatesWidth(t *testing.T) { - a := newTestApproval() - - a, _ = sendApprovalMsg(a, tea.WindowSizeMsg{Width: 120, Height: 40}) - - if a.width != 120 { - t.Fatalf("expected width=120, got %d", a.width) - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_NoQuit verifies that the component never returns a -// tea.Quit cmd (parent manages lifecycle). -// -------------------------------------------------------------------------- - -func TestApprovalComponent_NoQuitFromYKey(t *testing.T) { - a := newTestApproval() - _, cmd := sendApprovalMsg(a, tea.KeyPressMsg{Code: 'y'}) - - if cmd != nil { - msg := runCmd(cmd) - if _, ok := msg.(tea.QuitMsg); ok { - t.Fatal("ApprovalComponent must not return tea.Quit (parent manages lifecycle)") - } - } -} - -// -------------------------------------------------------------------------- -// TestApprovalComponent_Init_ReturnsNil verifies Init() returns nil. -// -------------------------------------------------------------------------- - -func TestApprovalComponent_Init_ReturnsNil(t *testing.T) { - a := newTestApproval() - if cmd := a.Init(); cmd != nil { - t.Fatal("expected Init() to return nil cmd") - } -} diff --git a/internal/ui/events.go b/internal/ui/events.go index 7d942c23..42ff6ad1 100644 --- a/internal/ui/events.go +++ b/internal/ui/events.go @@ -7,14 +7,6 @@ type submitMsg struct { 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. diff --git a/internal/ui/model.go b/internal/ui/model.go index 6fee5ea3..adab7020 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -20,10 +20,6 @@ const ( // 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 @@ -90,10 +86,6 @@ type AppModel struct { // 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 @@ -113,10 +105,6 @@ type AppModel struct { // 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 @@ -145,12 +133,6 @@ type streamComponentIface interface { GetRenderedContent() string } -// 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 // -------------------------------------------------------------------------- @@ -162,8 +144,7 @@ type approvalComponentIface interface { // 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. +// using the provided options. func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { width := opts.Width if width == 0 { @@ -262,11 +243,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // 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 { + if m.input != nil { updated, cmd := m.input.Update(msg) m.input, _ = updated.(inputComponentIface) cmds = append(cmds, cmd) @@ -293,14 +270,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -361,25 +330,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.stream.Reset() // stop spinner } - case app.HookBlockedEvent: - // Print hook blocked message immediately to scrollback. - cmds = append(cmds, m.printHookBlocked(msg)) - 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 - // Construct the ApprovalComponent and init it (returns nil cmd, but good practice). - approvalComp := NewApprovalComponent(msg.ToolName, msg.ToolArgs, m.width) - cmds = append(cmds, approvalComp.Init()) - m.approval = approvalComp - case app.StepCompleteEvent: // Flush any remaining streamed text to scrollback, then reset stream // and return to input state. @@ -412,10 +368,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _, 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...) @@ -447,14 +399,6 @@ func (m *AppModel) renderStream() string { 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(). @@ -559,16 +503,6 @@ func (m *AppModel) printToolResult(evt app.ToolResultEvent) tea.Cmd { return tea.Println(rendered) } -// printHookBlocked renders a hook-blocked notice and emits it above the BT region. -func (m *AppModel) printHookBlocked(evt app.HookBlockedEvent) tea.Cmd { - theme := GetTheme() - rendered := lipgloss.NewStyle(). - Foreground(theme.Error). - Bold(true). - Render(" ⛔ Hook blocked: " + evt.Message) - return tea.Println(rendered) -} - // printErrorResponse renders an error message and emits it above the BT region. func (m *AppModel) printErrorResponse(evt app.StepErrorEvent) tea.Cmd { if evt.Err == nil { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a81abe0f..219b28cf 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -143,63 +143,6 @@ func TestStateTransition_InputToWorking(t *testing.T) { } } -// TestStateTransition_WorkingToApproval verifies that a ToolApprovalNeededEvent -// while in stateWorking transitions the model to stateApproval and creates the -// ApprovalComponent. -func TestStateTransition_WorkingToApproval(t *testing.T) { - ctrl := &stubAppController{} - m, _, _ := newTestAppModel(ctrl) - m.state = stateWorking - - responseChan := make(chan bool, 1) - m = sendMsg(m, app.ToolApprovalNeededEvent{ - ToolName: "test_tool", - ToolArgs: `{"arg":"val"}`, - ResponseChan: responseChan, - }) - - if m.state != stateApproval { - t.Fatalf("expected stateApproval after ToolApprovalNeededEvent, got %v", m.state) - } - if m.approval == nil { - t.Fatal("expected approval component to be set") - } - if m.approvalChan == nil { - t.Fatal("expected approvalChan to be set") - } -} - -// TestStateTransition_ApprovalToWorking verifies that an approvalResultMsg -// while in stateApproval sends the result on approvalChan and transitions back -// to stateWorking. -func TestStateTransition_ApprovalToWorking(t *testing.T) { - ctrl := &stubAppController{} - m, _, _ := newTestAppModel(ctrl) - m.state = stateApproval - - responseChan := make(chan bool, 1) - m.approvalChan = responseChan - - m = sendMsg(m, approvalResultMsg{Approved: true}) - - if m.state != stateWorking { - t.Fatalf("expected stateWorking after approvalResultMsg, got %v", m.state) - } - if m.approvalChan != nil { - t.Fatal("expected approvalChan to be cleared") - } - - // Verify the approval result was sent on the channel. - select { - case approved := <-responseChan: - if !approved { - t.Fatal("expected approved=true to be sent on responseChan") - } - default: - t.Fatal("expected a value on responseChan") - } -} - // TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent // transitions from stateWorking back to stateInput and resets the stream component. func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) { diff --git a/sdk/mcphost.go b/sdk/mcphost.go index 1be1aa4a..e09a0349 100644 --- a/sdk/mcphost.go +++ b/sdk/mcphost.go @@ -142,7 +142,6 @@ func (m *MCPHost) Prompt(ctx context.Context, message string) (string, error) { nil, // onToolResult nil, // onResponse nil, // onToolCallContent - nil, // onToolApproval ) if err != nil { return "", err @@ -181,7 +180,6 @@ func (m *MCPHost) PromptWithCallbacks( nil, // onResponse nil, // onToolCallContent onStreaming, - nil, // onToolApproval ) if err != nil { return "", err