Files
go-llm/cmd/llm/view.go
Steve Dudenhoeffer 97d54c10ae 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.
2026-01-24 15:53:36 -05:00

297 lines
7.5 KiB
Go

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())
}