Files
ollama/cmd/tui/chat/debug.go
2026-07-09 17:27:31 -07:00

559 lines
16 KiB
Go

package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
)
type chatPromptDebug struct {
request api.ChatRequest
tokens int
scroll int
}
const maxPromptDebugToolResultRunes = 400
func (m *chatModel) handleSaveCommand(args string) (tea.Model, tea.Cmd) {
filename, err := saveRequestFilename(args)
if err != nil {
return m.addDebugError(err)
}
raw, err := m.rawRequestJSON()
if err != nil {
return m.addDebugError(err)
}
dir, err := m.debugWorkingDir()
if err != nil {
return m.addDebugError(err)
}
path := filepath.Join(dir, filename)
if err := os.WriteFile(path, []byte(raw+"\n"), 0o644); err != nil {
return m.addDebugError(err)
}
m.entries = append(m.entries, newSlashEntry(fmt.Sprintf("saved as %s", filename)))
m.status = "saved"
return *m, nil
}
func (m *chatModel) handlePromptCommand(args string) (tea.Model, tea.Cmd) {
if strings.TrimSpace(args) != "" {
return m.addDebugError(fmt.Errorf("usage: /prompt"))
}
req, tokens := m.requestPreview()
m.promptDebug = &chatPromptDebug{
request: req,
tokens: tokens,
}
m.flowPrintedLines = 0
m.selection = chatSelection{}
m.status = "prompt"
return *m, tea.Batch(tea.ClearScreen, tea.EnableMouseCellMotion)
}
func (m chatModel) updatePromptDebug(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.promptDebug == nil {
return m, nil
}
switch msg.Type {
case tea.KeyEsc, tea.KeyCtrlC, tea.KeyEnter:
return m.closePromptDebug()
case tea.KeyUp, tea.KeyCtrlP:
m.promptDebug.scroll--
case tea.KeyDown, tea.KeyCtrlN:
m.promptDebug.scroll++
case tea.KeyPgUp:
m.promptDebug.scroll -= max(1, m.promptDebugPageSize())
case tea.KeyPgDown:
m.promptDebug.scroll += max(1, m.promptDebugPageSize())
case tea.KeyHome, tea.KeyCtrlHome:
m.promptDebug.scroll = 0
case tea.KeyEnd, tea.KeyCtrlEnd:
m.promptDebug.scroll = m.promptDebugMaxScroll()
}
if m.promptDebug != nil {
m.promptDebug.scroll = clamp(m.promptDebug.scroll, 0, m.promptDebugMaxScroll())
}
return m, nil
}
func (m chatModel) closePromptDebug() (tea.Model, tea.Cmd) {
m.promptDebug = nil
m.status = "ready"
m.flowPrintedLines = 0
next, printCmd := m.flowTranscriptFlushCmd()
return next, tea.Sequence(tea.DisableMouse, tea.ClearScreen, printCmd)
}
func (m chatModel) renderPromptDebug(width, height int) string {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
if m.promptDebug == nil {
return renderFullFrame("", width, height)
}
header := []string{
chatPickerTitleStyle.Render("Prompt"),
chatPickerMetaStyle.Render("full request preview • /save <filename> saved as <filename>.json"),
"",
}
footer := chatPickerMetaStyle.Render("↑/↓ scroll • pgup/pgdn page • enter/esc close")
bodyHeight := max(0, height-len(header)-1)
body := m.promptDebugLines(width)
maxScroll := max(0, len(body)-bodyHeight)
scroll := clamp(m.promptDebug.scroll, 0, maxScroll)
if bodyHeight < len(body) {
body = body[scroll:min(len(body), scroll+bodyHeight)]
}
lines := slices.Clone(header)
lines = append(lines, body...)
for len(lines) < height-1 {
lines = append(lines, "")
}
lines = append(lines, footer)
return renderFrameLines(lines, width, height)
}
func (m chatModel) promptDebugPageSize() int {
height := m.height
if height <= 0 {
height = 24
}
return max(1, height-5)
}
func (m chatModel) promptDebugMaxScroll() int {
if m.promptDebug == nil {
return 0
}
width := m.viewWidth()
height := m.height
if height <= 0 {
height = 24
}
bodyHeight := max(0, height-4)
return max(0, len(m.promptDebugLines(width))-bodyHeight)
}
func (m *chatModel) addDebugError(err error) (tea.Model, tea.Cmd) {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
m.status = "error"
return *m, nil
}
func (m chatModel) rawRequestJSON() (string, error) {
req, _ := m.requestPreview()
data, err := json.MarshalIndent(req, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
func (m chatModel) requestPreview() (api.ChatRequest, int) {
opts := m.previewRunOptions()
messages := m.previewMessages()
req := m.previewChatRequest(opts, messages)
return req, m.estimatePromptTokens(messages, opts.SystemPrompt)
}
func (m chatModel) previewRunOptions() coreagent.RunOptions {
return coreagent.RunOptions{
ChatID: m.chatID,
Model: m.opts.Model,
SystemPrompt: m.systemPrompt(""),
Format: m.opts.Format,
Options: m.opts.Options,
Think: m.opts.Think,
KeepAlive: m.opts.KeepAlive,
}
}
func (m chatModel) previewMessages() []api.Message {
if len(m.liveMessages) > 0 {
return slices.Clone(m.liveMessages)
}
return slices.Clone(m.messages)
}
func (m chatModel) previewChatRequest(opts coreagent.RunOptions, messages []api.Message) api.ChatRequest {
requestMessages := slices.Clone(messages)
if strings.TrimSpace(opts.SystemPrompt) != "" {
withSystem := make([]api.Message, 0, len(requestMessages)+1)
withSystem = append(withSystem, api.Message{Role: "system", Content: opts.SystemPrompt})
requestMessages = append(withSystem, requestMessages...)
}
format := opts.Format
if format == "json" {
format = `"` + format + `"`
}
req := api.ChatRequest{
Model: opts.Model,
Messages: requestMessages,
Format: json.RawMessage(format),
Options: opts.Options,
Think: opts.Think,
}
if opts.KeepAlive != nil {
req.KeepAlive = opts.KeepAlive
}
if m.opts.Tools != nil && !m.opts.ToolsDisabled {
req.Tools = m.opts.Tools.Tools()
}
return req
}
func (m chatModel) promptDebugLines(width int) []string {
if m.promptDebug == nil {
return nil
}
req := m.promptDebug.request
innerWidth := max(20, width-2)
lines := []string{
chatHeaderStyle.Render("Request"),
promptDebugFieldLine("model", req.Model, innerWidth),
promptDebugFieldLine("estimated prompt", m.promptTokenText(m.promptDebug.tokens), innerWidth),
promptDebugFieldLine("messages", fmt.Sprint(len(req.Messages)), innerWidth),
promptDebugFieldLine("tools", fmt.Sprint(len(req.Tools)), innerWidth),
}
if len(req.Format) > 0 {
lines = append(lines, promptDebugFieldLine("format", strings.TrimSpace(string(req.Format)), innerWidth))
}
if req.Options != nil {
lines = append(lines, promptDebugMapLines("options", req.Options, innerWidth)...)
}
if req.Think != nil {
lines = append(lines, promptDebugBlockLines("think", req.Think.String(), innerWidth, chatHistoryTextStyle)...)
}
if req.KeepAlive != nil {
lines = append(lines, promptDebugFieldLine("keep_alive", req.KeepAlive.String(), innerWidth))
}
lines = append(lines, "", chatHeaderStyle.Render("Messages"))
if len(req.Messages) == 0 {
lines = append(lines, chatMetaStyle.Render("none"))
} else {
for i, msg := range req.Messages {
if i > 0 {
lines = append(lines, "")
}
lines = append(lines, promptDebugMessageLines(i+1, msg, innerWidth)...)
}
}
lines = append(lines, "", chatHeaderStyle.Render("Tools"))
if len(req.Tools) == 0 {
lines = append(lines, chatMetaStyle.Render("none"))
return lines
}
for i, tool := range req.Tools {
if i > 0 {
lines = append(lines, "")
}
lines = append(lines, promptDebugToolLines(i+1, tool, innerWidth)...)
}
return lines
}
func promptDebugFieldLine(label, value string, width int) string {
labelText := label + ":"
value = strings.TrimSpace(value)
if value == "" {
value = "_empty_"
}
line := chatHistoryLabelStyle.Render(labelText) + " " + chatHistoryTextStyle.Render(value)
return truncateRenderedLine(line, width)
}
func promptDebugMessageLines(index int, msg api.Message, width int) []string {
role := promptMessageLabel(msg)
header := fmt.Sprintf("%d. %s", index, role)
lines := []string{historyRoleStyle(msg.Role).Render(header)}
if strings.TrimSpace(msg.Thinking) != "" {
lines = append(lines, promptDebugBlockLines("thinking", msg.Thinking, width, chatHistoryTextStyle)...)
}
if msg.Role != "tool" && (strings.TrimSpace(msg.Content) != "" || (msg.Role != "assistant" && len(msg.ToolCalls) == 0 && len(msg.Images) == 0 && msg.Thinking == "")) {
lines = append(lines, promptDebugBlockLines("content", msg.Content, width, chatHistoryTextStyle)...)
}
if len(msg.ToolCalls) > 0 {
for i, call := range msg.ToolCalls {
lines = append(lines, promptDebugToolCallLines(i+1, call, width)...)
}
}
if msg.Role == "tool" {
if msg.ToolName != "" {
lines = append(lines, " "+chatHistoryLabelStyle.Render("tool_name:")+" "+chatHistoryTextStyle.Render(msg.ToolName))
}
if msg.ToolCallID != "" {
lines = append(lines, " "+chatHistoryLabelStyle.Render("tool_call_id:")+" "+chatHistoryTextStyle.Render(msg.ToolCallID))
}
lines = append(lines, promptDebugBlockLines("tool result", promptDebugToolResult(msg.Content), width, chatHistoryTextStyle)...)
}
if len(msg.Images) > 0 {
lines = append(lines, " "+chatHistoryLabelStyle.Render(fmt.Sprintf("%d image%s", len(msg.Images), pluralSuffix(len(msg.Images)))))
}
return lines
}
func promptDebugToolResult(content string) string {
runes := []rune(content)
if len(runes) <= maxPromptDebugToolResultRunes {
return content
}
return string(runes[:maxPromptDebugToolResultRunes-3]) + "..."
}
func promptDebugMapLines(label string, values map[string]any, width int) []string {
lines := []string{" " + chatHistoryLabelStyle.Render(label+":")}
if len(values) == 0 {
return append(lines, " "+chatMetaStyle.Render("_empty_"))
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
lines = append(lines, promptDebugValueLine(4, key, values[key], width)...)
}
return lines
}
func promptDebugToolLines(index int, tool api.Tool, width int) []string {
name := strings.TrimSpace(tool.Function.Name)
if name == "" {
name = "_unnamed_"
}
lines := []string{historyRoleStyle("tool").Render(fmt.Sprintf("%d. %s", index, name))}
if strings.TrimSpace(tool.Function.Description) != "" {
lines = append(lines, promptDebugBlockLines("description", tool.Function.Description, width, chatHistoryTextStyle)...)
}
params := tool.Function.Parameters
if params.Type != "" || params.Properties != nil {
kind := params.Type
if kind == "" {
kind = "object"
}
lines = append(lines, " "+chatHistoryLabelStyle.Render("parameters:")+" "+chatHistoryTextStyle.Render(kind))
}
if params.Properties == nil || params.Properties.Len() == 0 {
return lines
}
lines = append(lines, " "+chatHistoryLabelStyle.Render("properties:"))
required := map[string]bool{}
for _, name := range params.Required {
required[name] = true
}
for name, property := range params.Properties.All() {
label := name
propertyType := property.ToTypeScriptType()
switch {
case propertyType != "" && required[name]:
label += " (" + propertyType + ", required)"
case propertyType != "":
label += " (" + propertyType + ")"
case required[name]:
label += " (required)"
}
value := strings.TrimSpace(property.Description)
if value == "" {
value = promptDebugPropertyDetails(property)
}
lines = append(lines, promptDebugTextLine(4, label, value, width)...)
}
return lines
}
func promptDebugToolCallLines(index int, call api.ToolCall, width int) []string {
name := strings.TrimSpace(call.Function.Name)
if name == "" {
name = "_unnamed_"
}
lines := []string{" " + chatHistoryLabelStyle.Render(fmt.Sprintf("tool call %d:", index)) + " " + chatHistoryTextStyle.Render(name)}
if strings.TrimSpace(call.ID) != "" {
lines = append(lines, promptDebugTextLine(4, "id", call.ID, width)...)
}
if call.Function.Arguments.Len() == 0 {
lines = append(lines, " "+chatHistoryLabelStyle.Render("arguments:")+" "+chatMetaStyle.Render("none"))
return lines
}
lines = append(lines, " "+chatHistoryLabelStyle.Render("arguments:"))
for key, value := range call.Function.Arguments.All() {
lines = append(lines, promptDebugValueLine(6, key, value, width)...)
}
return lines
}
func promptDebugPropertyDetails(property api.ToolProperty) string {
var parts []string
if len(property.Enum) > 0 {
values := make([]string, 0, len(property.Enum))
for _, value := range property.Enum {
values = append(values, promptDebugValueText(value))
}
parts = append(parts, "one of "+strings.Join(values, ", "))
}
if property.Properties != nil && property.Properties.Len() > 0 {
count := property.Properties.Len()
noun := "property"
if count != 1 {
noun = "properties"
}
parts = append(parts, fmt.Sprintf("%d nested %s", count, noun))
}
if property.Items != nil {
parts = append(parts, "array items: "+promptDebugValueText(property.Items))
}
if len(parts) == 0 {
return "_empty_"
}
return strings.Join(parts, "; ")
}
func promptDebugValueLine(indent int, label string, value any, width int) []string {
return promptDebugTextLine(indent, label, promptDebugValueText(value), width)
}
func promptDebugTextLine(indent int, label, value string, width int) []string {
prefix := strings.Repeat(" ", indent) + chatHistoryLabelStyle.Render(label+":")
value = strings.TrimSpace(value)
if value == "" {
value = "_empty_"
}
wrapWidth := max(20, width-indent-lipgloss.Width(label)-2)
wrapped := wrapChatText(value, wrapWidth)
if len(wrapped) == 0 {
return []string{prefix + " " + chatMetaStyle.Render("_empty_")}
}
lines := []string{prefix + " " + chatHistoryTextStyle.Render(wrapped[0])}
for _, line := range wrapped[1:] {
lines = append(lines, strings.Repeat(" ", indent+2)+chatHistoryTextStyle.Render(line))
}
return lines
}
func promptDebugValueText(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return v
case fmt.Stringer:
return v.String()
case []any:
parts := make([]string, 0, len(v))
for _, item := range v {
parts = append(parts, promptDebugValueText(item))
}
return strings.Join(parts, ", ")
case map[string]any:
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
slices.Sort(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+": "+promptDebugValueText(v[key]))
}
return strings.Join(parts, ", ")
default:
return fmt.Sprint(value)
}
}
func promptDebugBlockLines(label, value string, width int, style lipgloss.Style) []string {
lines := []string{" " + chatHistoryLabelStyle.Render(label+":")}
if value == "" {
return append(lines, " "+chatMetaStyle.Render("_empty_"))
}
for _, raw := range strings.Split(strings.TrimRight(value, "\n"), "\n") {
if raw == "" {
lines = append(lines, "")
continue
}
for _, wrapped := range wrapChatText(raw, max(20, width-4)) {
lines = append(lines, " "+style.Render(wrapped))
}
}
return lines
}
func (m chatModel) promptTokenText(tokens int) string {
window := m.displayContextWindowTokens()
if window > 0 {
return fmt.Sprintf("%s / %s tokens", formatPromptTokenCount(max(tokens, 0)), formatPromptTokenCount(window))
}
return formatTokenCount(tokens)
}
func formatPromptTokenCount(count int) string {
sign := ""
if count < 0 {
sign = "-"
count = -count
}
if count < 100_000 {
return sign + fmt.Sprint(count)
}
if count >= 950_000 {
return fmt.Sprintf("%s%dM", sign, int(float64(count)/1_000_000+0.5))
}
return fmt.Sprintf("%s%dk", sign, int(float64(count)/1024+0.5))
}
func promptMessageLabel(msg api.Message) string {
if msg.Role == "tool" && msg.ToolName != "" {
return msg.Role + ":" + msg.ToolName
}
return msg.Role
}
func saveRequestFilename(args string) (string, error) {
args = strings.TrimSpace(args)
if args == "" {
return "", fmt.Errorf("usage: /save <filename>")
}
if strings.HasPrefix(args, ">") {
args = strings.TrimSpace(strings.TrimPrefix(args, ">"))
}
fields := strings.Fields(args)
if len(fields) != 1 {
return "", fmt.Errorf("usage: /save <filename>")
}
filename := strings.TrimSpace(fields[0])
if filename == "" || filename == "." || filename == ".." || strings.ContainsAny(filename, `/\`) || filepath.IsAbs(filename) {
return "", fmt.Errorf("save filename must be a file name, not a path")
}
if !strings.HasSuffix(strings.ToLower(filename), ".json") {
filename += ".json"
}
return filename, nil
}
func (m chatModel) debugWorkingDir() (string, error) {
dir := strings.TrimSpace(m.currentWorkingDir())
if dir != "" {
return dir, nil
}
return os.Getwd()
}