From 97d54c10ae93a9e92b6a36e8dc45ea14891975d6 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 24 Jan 2026 15:53:36 -0500 Subject: [PATCH] Implement interactive CLI for LLM providers with chat, tools, and image support - Add Bubble Tea-based CLI interface for LLM interactions. - Implement `.env.example` for environment variable setup. - Add provider, model, and tool selection screens. - Include support for API key configuration. - Enable chat interactions with optional image and tool support. - Introduce core utility functions: image handling, tool execution, chat request management, and response rendering. - Implement style customization with Lip Gloss. --- .gitignore | 1 + CLAUDE.md | 22 +++ cmd/llm/.env.example | 11 ++ cmd/llm/commands.go | 182 ++++++++++++++++++ cmd/llm/main.go | 25 +++ cmd/llm/model.go | 295 +++++++++++++++++++++++++++++ cmd/llm/styles.go | 113 +++++++++++ cmd/llm/tools.go | 105 +++++++++++ cmd/llm/update.go | 435 +++++++++++++++++++++++++++++++++++++++++++ cmd/llm/view.go | 296 +++++++++++++++++++++++++++++ go.mod | 20 ++ go.sum | 45 +++++ 12 files changed, 1550 insertions(+) create mode 100644 cmd/llm/.env.example create mode 100644 cmd/llm/commands.go create mode 100644 cmd/llm/main.go create mode 100644 cmd/llm/model.go create mode 100644 cmd/llm/styles.go create mode 100644 cmd/llm/tools.go create mode 100644 cmd/llm/update.go create mode 100644 cmd/llm/view.go diff --git a/.gitignore b/.gitignore index dad72d5..0a94273 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .claude .idea +*.exe \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 00f2113..1185e07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,3 +23,25 @@ - **Imports**: Organize imports into groups: standard library, then third-party libraries. - **Documentation**: Use standard Go doc comments for exported symbols. - **README.md**: The README.md file should always be kept up to date with any significant changes to the project. + +## CLI Tool +- Build CLI: `go build ./cmd/llm` +- Run CLI: `./llm` (or `llm.exe` on Windows) +- Run without building: `go run ./cmd/llm` + +### CLI Features +- Interactive TUI for testing all go-llm features +- Support for OpenAI, Anthropic, and Google providers +- Image input (file path, URL, or base64) +- Tool/function calling with demo tools +- Temperature control and settings + +### Key Bindings +- `Enter` - Send message +- `Ctrl+I` - Add image +- `Ctrl+T` - Toggle tools panel +- `Ctrl+P` - Change provider +- `Ctrl+M` - Change model +- `Ctrl+S` - Settings +- `Ctrl+N` - New conversation +- `Esc` - Exit/Cancel diff --git a/cmd/llm/.env.example b/cmd/llm/.env.example new file mode 100644 index 0000000..3ddaa80 --- /dev/null +++ b/cmd/llm/.env.example @@ -0,0 +1,11 @@ +# go-llm CLI Environment Variables +# Copy this file to .env and fill in your API keys + +# OpenAI API Key (https://platform.openai.com/api-keys) +OPENAI_API_KEY= + +# Anthropic API Key (https://console.anthropic.com/settings/keys) +ANTHROPIC_API_KEY= + +# Google AI API Key (https://aistudio.google.com/apikey) +GOOGLE_API_KEY= diff --git a/cmd/llm/commands.go b/cmd/llm/commands.go new file mode 100644 index 0000000..e5e7941 --- /dev/null +++ b/cmd/llm/commands.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + llm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// Message types for async operations + +// ChatResponseMsg contains the response from a chat completion +type ChatResponseMsg struct { + Response llm.Response + Err error +} + +// ToolExecutionMsg contains results from tool execution +type ToolExecutionMsg struct { + Results []llm.ToolCallResponse + Err error +} + +// ImageLoadedMsg contains a loaded image +type ImageLoadedMsg struct { + Image llm.Image + Err error +} + +// sendChatRequest sends a chat completion request +func sendChatRequest(chat llm.ChatCompletion, req llm.Request) tea.Cmd { + return func() tea.Msg { + resp, err := chat.ChatComplete(context.Background(), req) + return ChatResponseMsg{Response: resp, Err: err} + } +} + +// executeTools executes tool calls and returns results +func executeTools(toolbox llm.ToolBox, req llm.Request, resp llm.ResponseChoice) tea.Cmd { + return func() tea.Msg { + ctx := llm.NewContext(context.Background(), req, &resp, nil) + var results []llm.ToolCallResponse + + for _, call := range resp.Calls { + result, err := toolbox.Execute(ctx, call) + results = append(results, llm.ToolCallResponse{ + ID: call.ID, + Result: result, + Error: err, + }) + } + + return ToolExecutionMsg{Results: results, Err: nil} + } +} + +// loadImageFromPath loads an image from a file path +func loadImageFromPath(path string) tea.Cmd { + return func() tea.Msg { + // Clean up the path + path = strings.TrimSpace(path) + path = strings.Trim(path, "\"'") + + // Read the file + data, err := os.ReadFile(path) + if err != nil { + return ImageLoadedMsg{Err: fmt.Errorf("failed to read image file: %w", err)} + } + + // Detect content type + contentType := http.DetectContentType(data) + if !strings.HasPrefix(contentType, "image/") { + return ImageLoadedMsg{Err: fmt.Errorf("file is not an image: %s", contentType)} + } + + // Base64 encode + encoded := base64.StdEncoding.EncodeToString(data) + + return ImageLoadedMsg{ + Image: llm.Image{ + Base64: encoded, + ContentType: contentType, + }, + } + } +} + +// loadImageFromURL loads an image from a URL +func loadImageFromURL(url string) tea.Cmd { + return func() tea.Msg { + url = strings.TrimSpace(url) + + // For URL images, we can just use the URL directly + return ImageLoadedMsg{ + Image: llm.Image{ + Url: url, + }, + } + } +} + +// loadImageFromBase64 loads an image from base64 data +func loadImageFromBase64(data string) tea.Cmd { + return func() tea.Msg { + data = strings.TrimSpace(data) + + // Check if it's a data URL + if strings.HasPrefix(data, "data:") { + // Parse data URL: data:image/png;base64,.... + parts := strings.SplitN(data, ",", 2) + if len(parts) != 2 { + return ImageLoadedMsg{Err: fmt.Errorf("invalid data URL format")} + } + + // Extract content type from first part + mediaType := strings.TrimPrefix(parts[0], "data:") + mediaType = strings.TrimSuffix(mediaType, ";base64") + + return ImageLoadedMsg{ + Image: llm.Image{ + Base64: parts[1], + ContentType: mediaType, + }, + } + } + + // Assume it's raw base64, try to detect content type + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return ImageLoadedMsg{Err: fmt.Errorf("invalid base64 data: %w", err)} + } + + contentType := http.DetectContentType(decoded) + if !strings.HasPrefix(contentType, "image/") { + return ImageLoadedMsg{Err: fmt.Errorf("data is not an image: %s", contentType)} + } + + return ImageLoadedMsg{ + Image: llm.Image{ + Base64: data, + ContentType: contentType, + }, + } + } +} + +// buildRequest builds a chat request from the current state +func buildRequest(m *Model, userText string) llm.Request { + // Create the user message with any pending images + userMsg := llm.Message{ + Role: llm.RoleUser, + Text: userText, + Images: m.pendingImages, + } + + req := llm.Request{ + Conversation: m.conversation, + Messages: []llm.Message{ + {Role: llm.RoleSystem, Text: m.systemPrompt}, + userMsg, + }, + Temperature: m.temperature, + } + + // Add toolbox if enabled + if m.toolsEnabled && len(m.toolbox.Functions()) > 0 { + req.Toolbox = m.toolbox.WithRequireTool(false) + } + + return req +} + +// buildFollowUpRequest builds a follow-up request after tool execution +func buildFollowUpRequest(m *Model, previousReq llm.Request, resp llm.ResponseChoice, toolResults []llm.ToolCallResponse) llm.Request { + return previousReq.NextRequest(resp, toolResults) +} diff --git a/cmd/llm/main.go b/cmd/llm/main.go new file mode 100644 index 0000000..6f0bbe8 --- /dev/null +++ b/cmd/llm/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/joho/godotenv" +) + +func main() { + // Load .env file if it exists (ignore error if not found) + _ = godotenv.Load() + + p := tea.NewProgram( + InitialModel(), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + if _, err := p.Run(); err != nil { + fmt.Printf("Error running program: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/llm/model.go b/cmd/llm/model.go new file mode 100644 index 0000000..282a0b5 --- /dev/null +++ b/cmd/llm/model.go @@ -0,0 +1,295 @@ +package main + +import ( + "os" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + llm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// State represents the current view/screen of the application +type State int + +const ( + StateChat State = iota + StateProviderSelect + StateModelSelect + StateImageInput + StateToolsPanel + StateSettings + StateAPIKeyInput +) + +// DisplayMessage represents a message for display in the UI +type DisplayMessage struct { + Role llm.Role + Content string + Images int // number of images attached +} + +// ProviderInfo contains information about a provider +type ProviderInfo struct { + Name string + EnvVar string + Models []string + HasAPIKey bool + ModelIndex int +} + +// Model is the main Bubble Tea model +type Model struct { + // State + state State + previousState State + + // Provider + provider llm.LLM + providerName string + chat llm.ChatCompletion + modelName string + apiKeys map[string]string + providers []ProviderInfo + providerIndex int + + // Conversation + conversation []llm.Input + messages []DisplayMessage + + // Tools + toolbox llm.ToolBox + toolsEnabled bool + + // Settings + systemPrompt string + temperature *float64 + + // Pending images + pendingImages []llm.Image + + // UI Components + input textinput.Model + viewport viewport.Model + viewportReady bool + + // Selection state (for lists) + listIndex int + listItems []string + + // Dimensions + width int + height int + + // Loading state + loading bool + err error + + // For API key input + apiKeyInput textinput.Model +} + +// InitialModel creates and returns the initial model +func InitialModel() Model { + ti := textinput.New() + ti.Placeholder = "Type your message..." + ti.Focus() + ti.CharLimit = 4096 + ti.Width = 60 + + aki := textinput.New() + aki.Placeholder = "Enter API key..." + aki.CharLimit = 256 + aki.Width = 60 + aki.EchoMode = textinput.EchoPassword + + // Initialize providers with environment variable checks + providers := []ProviderInfo{ + { + Name: "OpenAI", + EnvVar: "OPENAI_API_KEY", + Models: []string{ + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o1", + "o1-mini", + "o1-preview", + "o3-mini", + }, + }, + { + Name: "Anthropic", + EnvVar: "ANTHROPIC_API_KEY", + Models: []string{ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3-7-sonnet-20250219", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229", + "claude-3-sonnet-20240229", + "claude-3-haiku-20240307", + }, + }, + { + Name: "Google", + EnvVar: "GOOGLE_API_KEY", + Models: []string{ + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-1.5-flash-8b", + "gemini-1.0-pro", + }, + }, + } + + // Check for API keys in environment + apiKeys := make(map[string]string) + for i := range providers { + if key := os.Getenv(providers[i].EnvVar); key != "" { + apiKeys[providers[i].Name] = key + providers[i].HasAPIKey = true + } + } + + m := Model{ + state: StateProviderSelect, + input: ti, + apiKeyInput: aki, + apiKeys: apiKeys, + providers: providers, + systemPrompt: "You are a helpful assistant.", + toolbox: createDemoToolbox(), + toolsEnabled: false, + messages: []DisplayMessage{}, + conversation: []llm.Input{}, + } + + // Build list items for provider selection + m.listItems = make([]string, len(providers)) + for i, p := range providers { + status := " (no key)" + if p.HasAPIKey { + status = " (ready)" + } + m.listItems[i] = p.Name + status + } + + return m +} + +// Init initializes the model +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +// selectProvider sets up the selected provider +func (m *Model) selectProvider(index int) error { + if index < 0 || index >= len(m.providers) { + return nil + } + + p := m.providers[index] + key, ok := m.apiKeys[p.Name] + if !ok || key == "" { + return nil + } + + m.providerName = p.Name + m.providerIndex = index + + switch p.Name { + case "OpenAI": + m.provider = llm.OpenAI(key) + case "Anthropic": + m.provider = llm.Anthropic(key) + case "Google": + m.provider = llm.Google(key) + } + + // Select default model + if len(p.Models) > 0 { + return m.selectModel(p.ModelIndex) + } + + return nil +} + +// selectModel sets the current model +func (m *Model) selectModel(index int) error { + if m.provider == nil { + return nil + } + + p := m.providers[m.providerIndex] + if index < 0 || index >= len(p.Models) { + return nil + } + + modelName := p.Models[index] + chat, err := m.provider.ModelVersion(modelName) + if err != nil { + return err + } + + m.chat = chat + m.modelName = modelName + m.providers[m.providerIndex].ModelIndex = index + + return nil +} + +// newConversation resets the conversation +func (m *Model) newConversation() { + m.conversation = []llm.Input{} + m.messages = []DisplayMessage{} + m.pendingImages = []llm.Image{} + m.err = nil +} + +// addUserMessage adds a user message to the conversation +func (m *Model) addUserMessage(text string, images []llm.Image) { + msg := llm.Message{ + Role: llm.RoleUser, + Text: text, + Images: images, + } + m.conversation = append(m.conversation, msg) + m.messages = append(m.messages, DisplayMessage{ + Role: llm.RoleUser, + Content: text, + Images: len(images), + }) +} + +// addAssistantMessage adds an assistant message to the conversation +func (m *Model) addAssistantMessage(content string) { + m.messages = append(m.messages, DisplayMessage{ + Role: llm.RoleAssistant, + Content: content, + }) +} + +// addToolCallMessage adds a tool call message to display +func (m *Model) addToolCallMessage(name string, args string) { + m.messages = append(m.messages, DisplayMessage{ + Role: llm.Role("tool_call"), + Content: name + ": " + args, + }) +} + +// addToolResultMessage adds a tool result message to display +func (m *Model) addToolResultMessage(name string, result string) { + m.messages = append(m.messages, DisplayMessage{ + Role: llm.Role("tool_result"), + Content: name + " -> " + result, + }) +} diff --git a/cmd/llm/styles.go b/cmd/llm/styles.go new file mode 100644 index 0000000..119324b --- /dev/null +++ b/cmd/llm/styles.go @@ -0,0 +1,113 @@ +package main + +import ( + "github.com/charmbracelet/lipgloss" +) + +var ( + // Colors + primaryColor = lipgloss.Color("205") + secondaryColor = lipgloss.Color("39") + accentColor = lipgloss.Color("212") + mutedColor = lipgloss.Color("241") + errorColor = lipgloss.Color("196") + successColor = lipgloss.Color("82") + + // App styles + appStyle = lipgloss.NewStyle().Padding(1, 2) + + // Header + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(primaryColor). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(mutedColor). + Padding(0, 1) + + // Provider badge + providerBadgeStyle = lipgloss.NewStyle(). + Background(secondaryColor). + Foreground(lipgloss.Color("0")). + Padding(0, 1). + Bold(true) + + // Messages + systemMsgStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true). + Padding(0, 1) + + userMsgStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Padding(0, 1) + + assistantMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")). + Padding(0, 1) + + roleLabelStyle = lipgloss.NewStyle(). + Bold(true). + Width(12) + + // Tool calls + toolCallStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Italic(true). + Padding(0, 1) + + toolResultStyle = lipgloss.NewStyle(). + Foreground(successColor). + Padding(0, 1) + + // Input area + inputStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + inputHelpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Italic(true) + + // Error + errorStyle = lipgloss.NewStyle(). + Foreground(errorColor). + Bold(true) + + // Loading + loadingStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Italic(true) + + // List selection + selectedItemStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) + + normalItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")) + + // Settings panel + settingLabelStyle = lipgloss.NewStyle(). + Foreground(secondaryColor). + Width(15) + + settingValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")) + + // Help text + helpStyle = lipgloss.NewStyle(). + Foreground(mutedColor). + Padding(1, 0) + + // Image indicator + imageIndicatorStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) + + // Viewport + viewportStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(mutedColor) +) diff --git a/cmd/llm/tools.go b/cmd/llm/tools.go new file mode 100644 index 0000000..58f8164 --- /dev/null +++ b/cmd/llm/tools.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + llm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// TimeParams is the parameter struct for the GetTime function +type TimeParams struct{} + +// GetTime returns the current time +func GetTime(_ *llm.Context, _ TimeParams) (any, error) { + return time.Now().Format("Monday, January 2, 2006 3:04:05 PM MST"), nil +} + +// CalcParams is the parameter struct for the Calculate function +type CalcParams struct { + A float64 `json:"a" description:"First number"` + B float64 `json:"b" description:"Second number"` + Op string `json:"op" description:"Operation: add, subtract, multiply, divide, power, sqrt, mod"` +} + +// Calculate performs basic math operations +func Calculate(_ *llm.Context, params CalcParams) (any, error) { + switch strings.ToLower(params.Op) { + case "add", "+": + return params.A + params.B, nil + case "subtract", "sub", "-": + return params.A - params.B, nil + case "multiply", "mul", "*": + return params.A * params.B, nil + case "divide", "div", "/": + if params.B == 0 { + return nil, fmt.Errorf("division by zero") + } + return params.A / params.B, nil + case "power", "pow", "^": + return math.Pow(params.A, params.B), nil + case "sqrt": + if params.A < 0 { + return nil, fmt.Errorf("cannot take square root of negative number") + } + return math.Sqrt(params.A), nil + case "mod", "%": + return math.Mod(params.A, params.B), nil + default: + return nil, fmt.Errorf("unknown operation: %s", params.Op) + } +} + +// WeatherParams is the parameter struct for the GetWeather function +type WeatherParams struct { + Location string `json:"location" description:"City name or location"` +} + +// GetWeather returns mock weather data (for demo purposes) +func GetWeather(_ *llm.Context, params WeatherParams) (any, error) { + // This is a demo function - returns mock data + weathers := []string{"sunny", "cloudy", "rainy", "partly cloudy", "windy"} + temps := []int{65, 72, 58, 80, 45} + + // Use location string to deterministically pick weather + idx := len(params.Location) % len(weathers) + + return map[string]any{ + "location": params.Location, + "temperature": strconv.Itoa(temps[idx]) + "F", + "condition": weathers[idx], + "humidity": "45%", + "note": "This is mock data for demonstration purposes", + }, nil +} + +// RandomNumberParams is the parameter struct for the RandomNumber function +type RandomNumberParams struct { + Min int `json:"min" description:"Minimum value (inclusive)"` + Max int `json:"max" description:"Maximum value (inclusive)"` +} + +// RandomNumber generates a pseudo-random number (using current time nanoseconds) +func RandomNumber(_ *llm.Context, params RandomNumberParams) (any, error) { + if params.Min > params.Max { + return nil, fmt.Errorf("min cannot be greater than max") + } + // Simple pseudo-random using time + n := time.Now().UnixNano() + rangeSize := params.Max - params.Min + 1 + result := params.Min + int(n%int64(rangeSize)) + return result, nil +} + +// createDemoToolbox creates a toolbox with demo tools for testing +func createDemoToolbox() llm.ToolBox { + return llm.NewToolBox( + llm.NewFunction("get_time", "Get the current date and time", GetTime), + llm.NewFunction("calculate", "Perform basic math operations (add, subtract, multiply, divide, power, sqrt, mod)", Calculate), + llm.NewFunction("get_weather", "Get weather information for a location (demo data)", GetWeather), + llm.NewFunction("random_number", "Generate a random number between min and max", RandomNumber), + ) +} diff --git a/cmd/llm/update.go b/cmd/llm/update.go new file mode 100644 index 0000000..4592683 --- /dev/null +++ b/cmd/llm/update.go @@ -0,0 +1,435 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + llm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// pendingRequest stores the request being processed for follow-up +var pendingRequest llm.Request +var pendingResponse llm.ResponseChoice + +// Update handles messages and updates the model +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKeyMsg(msg) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + headerHeight := 3 + footerHeight := 4 + verticalMargins := headerHeight + footerHeight + + if !m.viewportReady { + m.viewport = viewport.New(msg.Width-4, msg.Height-verticalMargins) + m.viewport.HighPerformanceRendering = false + m.viewportReady = true + } else { + m.viewport.Width = msg.Width - 4 + m.viewport.Height = msg.Height - verticalMargins + } + + m.input.Width = msg.Width - 6 + m.apiKeyInput.Width = msg.Width - 6 + + m.viewport.SetContent(m.renderMessages()) + + case ChatResponseMsg: + m.loading = false + if msg.Err != nil { + m.err = msg.Err + return m, nil + } + + if len(msg.Response.Choices) == 0 { + m.err = fmt.Errorf("no response choices returned") + return m, nil + } + + choice := msg.Response.Choices[0] + + // Check for tool calls + if len(choice.Calls) > 0 && m.toolsEnabled { + // Store for follow-up + pendingResponse = choice + + // Add assistant's response to conversation if there's content + if choice.Content != "" { + m.addAssistantMessage(choice.Content) + } + + // Display tool calls + for _, call := range choice.Calls { + m.addToolCallMessage(call.FunctionCall.Name, call.FunctionCall.Arguments) + } + + m.viewport.SetContent(m.renderMessages()) + m.viewport.GotoBottom() + + // Execute tools + m.loading = true + return m, executeTools(m.toolbox, pendingRequest, choice) + } + + // Regular response - add to conversation and display + m.conversation = append(m.conversation, choice) + m.addAssistantMessage(choice.Content) + + m.viewport.SetContent(m.renderMessages()) + m.viewport.GotoBottom() + + case ToolExecutionMsg: + if msg.Err != nil { + m.loading = false + m.err = msg.Err + return m, nil + } + + // Display tool results + for i, result := range msg.Results { + name := pendingResponse.Calls[i].FunctionCall.Name + resultStr := fmt.Sprintf("%v", result.Result) + if result.Error != nil { + resultStr = "Error: " + result.Error.Error() + } + m.addToolResultMessage(name, resultStr) + } + + // Add tool call responses to conversation + for _, result := range msg.Results { + m.conversation = append(m.conversation, result) + } + + // Add the assistant's response to conversation + m.conversation = append(m.conversation, pendingResponse) + + m.viewport.SetContent(m.renderMessages()) + m.viewport.GotoBottom() + + // Send follow-up request + followUp := buildFollowUpRequest(&m, pendingRequest, pendingResponse, msg.Results) + pendingRequest = followUp + return m, sendChatRequest(m.chat, followUp) + + case ImageLoadedMsg: + if msg.Err != nil { + m.err = msg.Err + m.state = m.previousState + return m, nil + } + + m.pendingImages = append(m.pendingImages, msg.Image) + m.state = m.previousState + m.err = nil + + default: + // Update text input + if m.state == StateChat { + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + } else if m.state == StateAPIKeyInput { + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +// handleKeyMsg handles keyboard input +func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global key handling + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + + case "esc": + if m.state != StateChat { + m.state = StateChat + m.input.Focus() + return m, nil + } + return m, tea.Quit + } + + // State-specific key handling + switch m.state { + case StateChat: + return m.handleChatKeys(msg) + case StateProviderSelect: + return m.handleProviderSelectKeys(msg) + case StateModelSelect: + return m.handleModelSelectKeys(msg) + case StateImageInput: + return m.handleImageInputKeys(msg) + case StateToolsPanel: + return m.handleToolsPanelKeys(msg) + case StateSettings: + return m.handleSettingsKeys(msg) + case StateAPIKeyInput: + return m.handleAPIKeyInputKeys(msg) + } + + return m, nil +} + +// handleChatKeys handles keys in chat state +func (m Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + if m.loading { + return m, nil + } + + text := strings.TrimSpace(m.input.Value()) + if text == "" { + return m, nil + } + + if m.chat == nil { + m.err = fmt.Errorf("no model selected - press Ctrl+P to select a provider") + return m, nil + } + + // Build and send request + req := buildRequest(&m, text) + pendingRequest = req + + // Add user message to display + m.addUserMessage(text, m.pendingImages) + + // Clear input and pending images + m.input.Reset() + m.pendingImages = nil + m.err = nil + m.loading = true + + m.viewport.SetContent(m.renderMessages()) + m.viewport.GotoBottom() + + return m, sendChatRequest(m.chat, req) + + case "ctrl+i": + m.previousState = StateChat + m.state = StateImageInput + m.input.SetValue("") + m.input.Placeholder = "Enter image path or URL..." + return m, nil + + case "ctrl+t": + m.state = StateToolsPanel + return m, nil + + case "ctrl+p": + m.state = StateProviderSelect + m.listIndex = m.providerIndex + return m, nil + + case "ctrl+m": + if m.provider == nil { + m.err = fmt.Errorf("select a provider first") + return m, nil + } + m.state = StateModelSelect + m.listItems = m.providers[m.providerIndex].Models + m.listIndex = m.providers[m.providerIndex].ModelIndex + return m, nil + + case "ctrl+s": + m.state = StateSettings + return m, nil + + case "ctrl+n": + m.newConversation() + m.viewport.SetContent(m.renderMessages()) + return m, nil + + case "up", "down", "pgup", "pgdown": + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } +} + +// handleProviderSelectKeys handles keys in provider selection state +func (m Model) handleProviderSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.listIndex > 0 { + m.listIndex-- + } + case "down", "j": + if m.listIndex < len(m.providers)-1 { + m.listIndex++ + } + case "enter": + p := m.providers[m.listIndex] + if !p.HasAPIKey { + // Need to get API key + m.state = StateAPIKeyInput + m.apiKeyInput.Focus() + m.apiKeyInput.SetValue("") + return m, textinput.Blink + } + + err := m.selectProvider(m.listIndex) + if err != nil { + m.err = err + return m, nil + } + + m.state = StateChat + m.input.Focus() + m.newConversation() + return m, nil + } + return m, nil +} + +// handleAPIKeyInputKeys handles keys in API key input state +func (m Model) handleAPIKeyInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + key := strings.TrimSpace(m.apiKeyInput.Value()) + if key == "" { + return m, nil + } + + // Store the API key + p := m.providers[m.listIndex] + m.apiKeys[p.Name] = key + m.providers[m.listIndex].HasAPIKey = true + + // Update list items + for i, prov := range m.providers { + status := " (no key)" + if prov.HasAPIKey { + status = " (ready)" + } + m.listItems[i] = prov.Name + status + } + + // Select the provider + err := m.selectProvider(m.listIndex) + if err != nil { + m.err = err + return m, nil + } + + m.state = StateChat + m.input.Focus() + m.newConversation() + return m, nil + + default: + var cmd tea.Cmd + m.apiKeyInput, cmd = m.apiKeyInput.Update(msg) + return m, cmd + } +} + +// handleModelSelectKeys handles keys in model selection state +func (m Model) handleModelSelectKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.listIndex > 0 { + m.listIndex-- + } + case "down", "j": + if m.listIndex < len(m.listItems)-1 { + m.listIndex++ + } + case "enter": + err := m.selectModel(m.listIndex) + if err != nil { + m.err = err + return m, nil + } + m.state = StateChat + m.input.Focus() + } + return m, nil +} + +// handleImageInputKeys handles keys in image input state +func (m Model) handleImageInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + input := strings.TrimSpace(m.input.Value()) + if input == "" { + m.state = m.previousState + m.input.Placeholder = "Type your message..." + return m, nil + } + + m.input.Placeholder = "Type your message..." + + // Determine input type and load + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + return m, loadImageFromURL(input) + } else if strings.HasPrefix(input, "data:") || len(input) > 100 && !strings.Contains(input, "/") && !strings.Contains(input, "\\") { + return m, loadImageFromBase64(input) + } else { + return m, loadImageFromPath(input) + } + + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } +} + +// handleToolsPanelKeys handles keys in tools panel state +func (m Model) handleToolsPanelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "t": + m.toolsEnabled = !m.toolsEnabled + case "enter", "q": + m.state = StateChat + m.input.Focus() + } + return m, nil +} + +// handleSettingsKeys handles keys in settings state +func (m Model) handleSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "1": + // Set temperature to nil (default) + m.temperature = nil + case "2": + t := 0.0 + m.temperature = &t + case "3": + t := 0.5 + m.temperature = &t + case "4": + t := 0.7 + m.temperature = &t + case "5": + t := 1.0 + m.temperature = &t + case "enter", "q": + m.state = StateChat + m.input.Focus() + } + return m, nil +} diff --git a/cmd/llm/view.go b/cmd/llm/view.go new file mode 100644 index 0000000..bc63e0e --- /dev/null +++ b/cmd/llm/view.go @@ -0,0 +1,296 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + llm "gitea.stevedudenhoeffer.com/steve/go-llm" +) + +// View renders the current state +func (m Model) View() string { + switch m.state { + case StateProviderSelect: + return m.renderProviderSelect() + case StateModelSelect: + return m.renderModelSelect() + case StateImageInput: + return m.renderImageInput() + case StateToolsPanel: + return m.renderToolsPanel() + case StateSettings: + return m.renderSettings() + case StateAPIKeyInput: + return m.renderAPIKeyInput() + default: + return m.renderChat() + } +} + +// renderChat renders the main chat view +func (m Model) renderChat() string { + var b strings.Builder + + // Header + provider := m.providerName + if provider == "" { + provider = "None" + } + model := m.modelName + if model == "" { + model = "None" + } + + header := headerStyle.Render(fmt.Sprintf("go-llm CLI %s", + providerBadgeStyle.Render(fmt.Sprintf("%s/%s", provider, model)))) + + b.WriteString(header) + b.WriteString("\n") + + // Messages viewport + if m.viewportReady { + b.WriteString(m.viewport.View()) + b.WriteString("\n") + } + + // Image indicator + if len(m.pendingImages) > 0 { + b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s) attached]", len(m.pendingImages)))) + b.WriteString("\n") + } + + // Error + if m.err != nil { + b.WriteString(errorStyle.Render(" Error: " + m.err.Error())) + b.WriteString("\n") + } + + // Loading + if m.loading { + b.WriteString(loadingStyle.Render(" Thinking...")) + b.WriteString("\n") + } + + // Input + inputBox := inputStyle.Render(m.input.View()) + b.WriteString(inputBox) + b.WriteString("\n") + + // Help + help := inputHelpStyle.Render("Enter: send | Ctrl+I: image | Ctrl+T: tools | Ctrl+P: provider | Ctrl+M: model | Ctrl+S: settings | Ctrl+N: new | Esc: quit") + b.WriteString(help) + + return appStyle.Render(b.String()) +} + +// renderMessages renders all messages for the viewport +func (m Model) renderMessages() string { + var b strings.Builder + + if len(m.messages) == 0 { + b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt)) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(mutedColor).Render("Start a conversation by typing a message below.")) + return b.String() + } + + b.WriteString(systemMsgStyle.Render("[System] " + m.systemPrompt)) + b.WriteString("\n\n") + + for _, msg := range m.messages { + var content string + var style lipgloss.Style + + switch msg.Role { + case llm.RoleUser: + style = userMsgStyle + label := roleLabelStyle.Foreground(secondaryColor).Render("[User]") + content = label + " " + msg.Content + if msg.Images > 0 { + content += imageIndicatorStyle.Render(fmt.Sprintf(" [%d image(s)]", msg.Images)) + } + case llm.RoleAssistant: + style = assistantMsgStyle + label := roleLabelStyle.Foreground(lipgloss.Color("255")).Render("[Assistant]") + content = label + " " + msg.Content + case llm.Role("tool_call"): + style = toolCallStyle + content = " -> Calling: " + msg.Content + case llm.Role("tool_result"): + style = toolResultStyle + content = " <- Result: " + msg.Content + default: + style = assistantMsgStyle + content = msg.Content + } + + b.WriteString(style.Render(content)) + b.WriteString("\n\n") + } + + return b.String() +} + +// renderProviderSelect renders the provider selection view +func (m Model) renderProviderSelect() string { + var b strings.Builder + + b.WriteString(headerStyle.Render("Select Provider")) + b.WriteString("\n\n") + + for i, item := range m.listItems { + cursor := " " + style := normalItemStyle + if i == m.listIndex { + cursor = "> " + style = selectedItemStyle + } + b.WriteString(style.Render(cursor + item)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel")) + + return appStyle.Render(b.String()) +} + +// renderAPIKeyInput renders the API key input view +func (m Model) renderAPIKeyInput() string { + var b strings.Builder + + provider := m.providers[m.listIndex] + + b.WriteString(headerStyle.Render(fmt.Sprintf("Enter API Key for %s", provider.Name))) + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf("Environment variable: %s\n\n", provider.EnvVar)) + b.WriteString("Enter your API key below (it will be hidden):\n\n") + + inputBox := inputStyle.Render(m.apiKeyInput.View()) + b.WriteString(inputBox) + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("Enter to confirm, Esc to cancel")) + + return appStyle.Render(b.String()) +} + +// renderModelSelect renders the model selection view +func (m Model) renderModelSelect() string { + var b strings.Builder + + b.WriteString(headerStyle.Render(fmt.Sprintf("Select Model (%s)", m.providerName))) + b.WriteString("\n\n") + + for i, item := range m.listItems { + cursor := " " + style := normalItemStyle + if i == m.listIndex { + cursor = "> " + style = selectedItemStyle + } + if item == m.modelName { + item += " (current)" + } + b.WriteString(style.Render(cursor + item)) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Use arrow keys or j/k to navigate, Enter to select, Esc to cancel")) + + return appStyle.Render(b.String()) +} + +// renderImageInput renders the image input view +func (m Model) renderImageInput() string { + var b strings.Builder + + b.WriteString(headerStyle.Render("Add Image")) + b.WriteString("\n\n") + + b.WriteString("Enter an image source:\n") + b.WriteString(" - File path (e.g., /path/to/image.png)\n") + b.WriteString(" - URL (e.g., https://example.com/image.jpg)\n") + b.WriteString(" - Base64 data or data URL\n\n") + + if len(m.pendingImages) > 0 { + b.WriteString(imageIndicatorStyle.Render(fmt.Sprintf("Currently attached: %d image(s)\n\n", len(m.pendingImages)))) + } + + inputBox := inputStyle.Render(m.input.View()) + b.WriteString(inputBox) + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("Enter to add image, Esc to cancel")) + + return appStyle.Render(b.String()) +} + +// renderToolsPanel renders the tools panel +func (m Model) renderToolsPanel() string { + var b strings.Builder + + b.WriteString(headerStyle.Render("Tools / Function Calling")) + b.WriteString("\n\n") + + status := "DISABLED" + statusStyle := errorStyle + if m.toolsEnabled { + status = "ENABLED" + statusStyle = lipgloss.NewStyle().Foreground(successColor).Bold(true) + } + + b.WriteString(settingLabelStyle.Render("Tools Status:")) + b.WriteString(statusStyle.Render(status)) + b.WriteString("\n\n") + + b.WriteString("Available tools:\n") + for _, fn := range m.toolbox.Functions() { + b.WriteString(fmt.Sprintf(" - %s: %s\n", selectedItemStyle.Render(fn.Name), fn.Description)) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Press 't' to toggle tools, Enter or 'q' to close")) + + return appStyle.Render(b.String()) +} + +// renderSettings renders the settings view +func (m Model) renderSettings() string { + var b strings.Builder + + b.WriteString(headerStyle.Render("Settings")) + b.WriteString("\n\n") + + // Temperature + tempStr := "default" + if m.temperature != nil { + tempStr = fmt.Sprintf("%.1f", *m.temperature) + } + b.WriteString(settingLabelStyle.Render("Temperature:")) + b.WriteString(settingValueStyle.Render(tempStr)) + b.WriteString("\n\n") + + b.WriteString("Press a key to set temperature:\n") + b.WriteString(" 1 - Default (model decides)\n") + b.WriteString(" 2 - 0.0 (deterministic)\n") + b.WriteString(" 3 - 0.5 (balanced)\n") + b.WriteString(" 4 - 0.7 (creative)\n") + b.WriteString(" 5 - 1.0 (very creative)\n") + + b.WriteString("\n") + + // System prompt + b.WriteString(settingLabelStyle.Render("System Prompt:")) + b.WriteString("\n") + b.WriteString(settingValueStyle.Render(" " + m.systemPrompt)) + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("Enter or 'q' to close")) + + return appStyle.Render(b.String()) +} diff --git a/go.mod b/go.mod index 320049a..c9bec2a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.24.0 toolchain go1.24.2 require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/liushuangls/go-anthropic/v2 v2.17.0 github.com/openai/openai-go v1.12.0 golang.org/x/image v0.35.0 @@ -15,7 +18,14 @@ require ( cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -24,10 +34,20 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/gax-go/v2 v2.16.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect diff --git a/go.sum b/go.sum index 1e34c32..c250d8c 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,30 @@ cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -29,12 +49,31 @@ github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5 github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/liushuangls/go-anthropic/v2 v2.17.0 h1:iBA6h7aghi1q86owEQ95XE2R2MF/0dQ7bCxtwTxOg4c= github.com/liushuangls/go-anthropic/v2 v2.17.0/go.mod h1:a550cJXPoTG2FL3DvfKG2zzD5O2vjgvo4tHtoGPzFLU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -48,6 +87,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -64,12 +105,16 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6 go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=