wip: show hooks in ui

Kujtim Hoxha created

Change summary

internal/agent/coordinator.go                     |  7 +
internal/hooks/hooks.go                           | 66 ++++++++++++++++
internal/message/content.go                       | 13 +-
internal/tui/components/chat/messages/messages.go | 32 +++++++
4 files changed, 108 insertions(+), 10 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -107,6 +107,12 @@ func NewCoordinator(
 	}
 	c.currentAgent = agent
 	c.agents[config.AgentCoder] = agent
+
+	_, small, err := c.buildAgentModels(ctx)
+	if err != nil {
+		return nil, err
+	}
+	hooks.SetSmallModel(small.Model)
 	return c, nil
 }
 
@@ -745,6 +751,7 @@ func (c *coordinator) UpdateModels(ctx context.Context) error {
 		return err
 	}
 	c.currentAgent.SetModels(large, small)
+	c.hooks.SetSmallModel(small.Model)
 
 	agentCfg, ok := c.cfg.Agents[config.AgentCoder]
 	if !ok {

internal/hooks/hooks.go 🔗

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"log/slog"
+	"os"
 	"regexp"
 	"strings"
 	"sync"
@@ -231,11 +232,71 @@ func (s *service) executeHook(ctx context.Context, hook config.Hook, hookCtx Hoo
 		return nil, fmt.Errorf("unsupported hook type: %s", hook.Type)
 	}
 
+	if result != nil {
+		result.EventType = string(hookCtx.EventType)
+	}
+
 	return result, err
 }
 
 func (s *service) executePromptHook(ctx context.Context, hook config.Hook, hookCtx HookContext) (*message.HookOutput, error) {
-	panic("not implemented")
+	contextJSON, err := json.Marshal(hookCtx)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal hook context: %w", err)
+	}
+
+	var finalPrompt string
+	if strings.Contains(hook.Prompt, "$ARGUMENTS") {
+		finalPrompt = strings.ReplaceAll(hook.Prompt, "$ARGUMENTS", string(contextJSON))
+	} else {
+		finalPrompt = fmt.Sprintf("%s\n\nContext: %s", hook.Prompt, string(contextJSON))
+	}
+
+	timeout := DefaultHookTimeout
+	if hook.Timeout != nil {
+		timeout = time.Duration(*hook.Timeout) * time.Second
+	}
+
+	execCtx, cancel := context.WithTimeout(ctx, timeout)
+	defer cancel()
+
+	type readTranscriptParams struct{}
+	readTranscriptTool := fantasy.NewAgentTool(
+		"read_transcript",
+		"Used to read the conversation so far",
+		func(ctx context.Context, params readTranscriptParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if hookCtx.TranscriptPath == "" {
+				return fantasy.NewTextErrorResponse("No transcript available"), nil
+			}
+			data, err := os.ReadFile(hookCtx.TranscriptPath)
+			if err != nil {
+				return fantasy.NewTextErrorResponse(err.Error()), nil
+			}
+			return fantasy.NewTextResponse(string(data)), nil
+		})
+
+	var output *message.HookOutput
+	outputTool := fantasy.NewAgentTool(
+		"output",
+		"Used to submit the output, remember you MUST call this tool at the end",
+		func(ctx context.Context, params message.HookOutput, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			output = &params
+			return fantasy.NewTextResponse("ouptut submitted"), nil
+		})
+	agent := fantasy.NewAgent(
+		s.smallModel,
+		fantasy.WithSystemPrompt(`You are a helpful sub agent used in a larger agents conversation loop,
+			your goal is to intercept the conversation and fulfill the intermediate requests, makesure to ALWAYS use the output tool at the end to output your decision`),
+		fantasy.WithTools(readTranscriptTool, outputTool),
+	)
+
+	_, err = agent.Generate(execCtx, fantasy.AgentCall{
+		Prompt: finalPrompt,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return output, nil
 }
 
 func (s *service) executeCommandHook(ctx context.Context, hook config.Hook, hookCtx HookContext) (*message.HookOutput, error) {
@@ -342,9 +403,8 @@ func parseHookOutput(stdout string) *message.HookOutput {
 	return &output
 }
 
-// SetSmallModel implements Service.
 func (s *service) SetSmallModel(model fantasy.LanguageModel) {
-	panic("unimplemented")
+	s.smallModel = model
 }
 
 func (s *service) collectMatchingHooks(hookCtx HookContext) []config.Hook {

internal/message/content.go 🔗

@@ -137,12 +137,13 @@ type Message struct {
 }
 
 type HookOutput struct {
-	Stop              bool   `json:"stop"`
-	Error             string `json:"error"`
-	Message           string `json:"message"`
-	Decision          string `json:"decision"`
-	UpdatedInput      string `json:"updated_input"`
-	AdditionalContext string `json:"additional_context"`
+	Stop              bool   `json:"stop,omitempty" description:"set to true if the execution should stop"`
+	EventType         string `json:"event_type" description:"ignore"`
+	Error             string `json:"error,omitempty"`
+	Message           string `json:"message,omitempty" description:"a message to send to show the user"`
+	Decision          string `json:"decision" description:"block, allow, deny, ask, only set if the request asks you to do so"`
+	UpdatedInput      string `json:"updated_input" description:"the updated tool input json, only set if the user requests you update a tool input"`
+	AdditionalContext string `json:"additional_context" description:"additional context to send to the LLM, only set if the user asks to add additional context"`
 }
 
 func (m *Message) Content() TextContent {

internal/tui/components/chat/messages/messages.go 🔗

@@ -10,6 +10,7 @@ import (
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/tree"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/ordered"
@@ -246,7 +247,36 @@ func (m *messageCmp) renderUserMessage() string {
 	}
 
 	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
-	return m.style().Render(joined)
+	hooks := m.getHookOutputs(t)
+	if len(hooks) == 0 {
+		return m.style().Render(joined)
+	}
+	root := tree.Root(joined)
+	root.EnumeratorStyle(t.S().Subtle)
+	for _, h := range hooks {
+		root.Child(h)
+	}
+	return m.style().Render(root.Enumerator(RoundedEnumeratorWithWidth(0, 1)).String())
+}
+
+func (m *messageCmp) getHookOutputs(t *styles.Theme) []string {
+	var hooks []string
+	for _, h := range m.message.HookOutputs {
+		var hookStatus []string
+		hookStatus = append(hookStatus, t.S().Base.Foreground(t.Blue).Render(fmt.Sprintf(" hook:%s", h.EventType)))
+		if h.Stop {
+			hookStatus = append(hookStatus, t.S().Error.Render("hook stopped execution"))
+		} else if h.AdditionalContext != "" {
+			hookStatus = append(hookStatus, t.S().Subtle.Render("additional context added"))
+		}
+
+		if h.Message != "" {
+			hookStatus = append(hookStatus, t.S().Muted.Render(h.Message))
+		}
+		hookOutput := ansi.Truncate(strings.Join(hookStatus, " "), m.textWidth()-2, "…")
+		hooks = append(hooks, hookOutput)
+	}
+	return hooks
 }
 
 // toMarkdown converts text content to rendered markdown using the configured renderer