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 }