mirror of
https://github.com/ollama/ollama.git
synced 2026-07-15 20:54:54 -05:00
1306 lines
33 KiB
Go
1306 lines
33 KiB
Go
package chat
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"slices"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/mattn/go-runewidth"
|
||
|
||
coreagent "github.com/ollama/ollama/agent"
|
||
"github.com/ollama/ollama/api"
|
||
"github.com/ollama/ollama/cmd/internal/filedata"
|
||
)
|
||
|
||
type chatSlashCommand struct {
|
||
name string
|
||
usage string
|
||
description string
|
||
aliases []string
|
||
hidden bool
|
||
}
|
||
|
||
type chatCompletion struct {
|
||
value string
|
||
label string
|
||
description string
|
||
directory bool
|
||
}
|
||
|
||
const (
|
||
chatPromptPrefix = ""
|
||
inputBoxHorizontalPadding = 1
|
||
inputCursorGlyph = "█"
|
||
inputCursorMarker = "\x00"
|
||
maxInputBoxBodyLines = 12
|
||
)
|
||
|
||
const (
|
||
pastedTextPlaceholderMinRunes = 1000
|
||
pastedTextPlaceholderMinLines = 8
|
||
)
|
||
|
||
var chatSlashCommands = []chatSlashCommand{
|
||
{name: "/clear", description: "clear this chat"},
|
||
{name: "/model", description: "switch models"},
|
||
{name: "/new", description: "start a new chat"},
|
||
{name: "/think", description: "set thinking mode"},
|
||
{name: "/tools", description: "toggle tools on or off"},
|
||
{name: "/compact", description: "summarize older context"},
|
||
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
||
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
||
{name: "/prompt", description: "show full prompt, tools, and messages"},
|
||
{name: "/save", usage: "/save <filename>", description: "save request JSON; saved as <filename>.json"},
|
||
}
|
||
|
||
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||
m.syncInputPlaceholders()
|
||
input := strings.TrimSpace(string(m.input))
|
||
if selected, ok := m.selectedSlashCommand(); ok {
|
||
input = selected
|
||
}
|
||
if input == "" {
|
||
return *m, nil
|
||
}
|
||
_, _, hasSlashCommand := slashCommandInvocation(input)
|
||
if (m.running || m.compacting) && !hasSlashCommand {
|
||
m.status = "wait for current response"
|
||
return *m, nil
|
||
}
|
||
|
||
attachments := cloneInputAttachments(m.inputAttachments)
|
||
pastedTexts := cloneInputPastedTexts(m.inputPastedTexts)
|
||
m.input = nil
|
||
m.inputCursor = 0
|
||
m.inputCursorSet = false
|
||
m.inputAttachments = attachments
|
||
m.inputPastedTexts = pastedTexts
|
||
m.complete = 0
|
||
m.resetPromptHistoryCursor()
|
||
return m.submitInput(input)
|
||
}
|
||
|
||
func (m chatModel) selectedSlashCommand() (string, bool) {
|
||
input := strings.TrimSpace(string(m.input))
|
||
if !strings.HasPrefix(input, "/") {
|
||
return "", false
|
||
}
|
||
completions := m.slashCompletions()
|
||
if len(completions) == 0 || !completionIsSelectable(completions) {
|
||
return "", false
|
||
}
|
||
return completions[clamp(m.complete, 0, len(completions)-1)].value, true
|
||
}
|
||
|
||
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
||
command, args, _ := slashCommandInvocation(input)
|
||
if command != "" {
|
||
input = strings.TrimSpace(command + " " + args)
|
||
}
|
||
|
||
switch {
|
||
case command == "/bye":
|
||
m.quitting = true
|
||
return *m, m.quitCmd()
|
||
case command == "/help":
|
||
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
|
||
return *m, nil
|
||
case command == "/clear" && args == "":
|
||
return m.resetChat("cleared")
|
||
case command == "/model":
|
||
return m.openModelPicker(args)
|
||
case command == "/think" && args == "":
|
||
return m.openThinkPicker()
|
||
case command == "/think":
|
||
return m.handleThinkCommand(args)
|
||
case command == "/tools":
|
||
return m.handleToolsCommand(args)
|
||
case command == "/prompt":
|
||
return m.handlePromptCommand(args)
|
||
case command == "/save":
|
||
return m.handleSaveCommand(args)
|
||
case command == "/new" && args == "":
|
||
return m.resetChat("new chat")
|
||
case command == "/compact" && args == "":
|
||
return m.startManualCompaction()
|
||
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
|
||
return m.startRun(input)
|
||
case strings.HasPrefix(input, "/"):
|
||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Unknown command %q", strings.Fields(input)[0])}))
|
||
return *m, nil
|
||
}
|
||
|
||
return m.startRun(input)
|
||
}
|
||
|
||
func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
|
||
if strings.TrimSpace(args) != "" {
|
||
m.status = "error"
|
||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /tools"}))
|
||
return *m, nil
|
||
}
|
||
if m.opts.ToolsDisabled {
|
||
m.opts.ToolsDisabled = false
|
||
if m.opts.ToolRegistryForModel != nil {
|
||
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, m.opts.Model)
|
||
}
|
||
m.status = "tools on"
|
||
} else {
|
||
m.opts.ToolsDisabled = true
|
||
m.status = "tools off"
|
||
}
|
||
return *m, nil
|
||
}
|
||
|
||
func (m chatModel) slashInputIsMultimodalFile(input string) bool {
|
||
if !m.opts.MultiModal {
|
||
return false
|
||
}
|
||
fields := strings.Fields(input)
|
||
if len(fields) == 0 {
|
||
return false
|
||
}
|
||
for _, file := range filedata.ExtractNames(input) {
|
||
if strings.HasPrefix(file, fields[0]) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func initialPromptHistory(ctx context.Context, opts Options) []string {
|
||
var prompts []string
|
||
for _, msg := range opts.Messages {
|
||
if msg.Role == "user" {
|
||
prompts = append(prompts, msg.Content)
|
||
}
|
||
}
|
||
return normalizePromptHistory(prompts)
|
||
}
|
||
|
||
func normalizePromptHistory(prompts []string) []string {
|
||
history := make([]string, 0, min(len(prompts), maxPromptHistory))
|
||
for _, prompt := range prompts {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" || coreagent.IsCompactionSummary(api.Message{Role: "user", Content: prompt}) {
|
||
continue
|
||
}
|
||
history = append(history, prompt)
|
||
}
|
||
if len(history) > maxPromptHistory {
|
||
history = history[len(history)-maxPromptHistory:]
|
||
}
|
||
return history
|
||
}
|
||
|
||
func (m *chatModel) addPromptHistory(prompt string) {
|
||
prompt = strings.TrimSpace(prompt)
|
||
if prompt == "" {
|
||
return
|
||
}
|
||
m.promptHistory = append(m.promptHistory, prompt)
|
||
if len(m.promptHistory) > maxPromptHistory {
|
||
m.promptHistory = m.promptHistory[len(m.promptHistory)-maxPromptHistory:]
|
||
}
|
||
m.resetPromptHistoryCursor()
|
||
}
|
||
|
||
func (m *chatModel) movePromptHistory(delta int) bool {
|
||
if len(m.promptHistory) == 0 || delta == 0 {
|
||
return false
|
||
}
|
||
if !m.promptActive {
|
||
if delta > 0 {
|
||
return false
|
||
}
|
||
m.promptDraft = slices.Clone(m.input)
|
||
m.promptCursor = len(m.promptHistory) - 1
|
||
m.promptActive = true
|
||
} else {
|
||
m.promptCursor += delta
|
||
if m.promptCursor >= len(m.promptHistory) {
|
||
m.input = slices.Clone(m.promptDraft)
|
||
m.inputCursor = len(m.input)
|
||
m.inputCursorSet = true
|
||
m.inputAttachments = nil
|
||
m.inputPastedTexts = nil
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
if m.promptCursor < 0 {
|
||
m.promptCursor = 0
|
||
}
|
||
}
|
||
|
||
m.inputPastedTexts = nil
|
||
input := m.promptHistory[m.promptCursor]
|
||
if placeholder, ok := m.pastedTextPlaceholder(input); ok {
|
||
input = placeholder
|
||
}
|
||
m.input = []rune(input)
|
||
m.inputCursor = len(m.input)
|
||
m.inputCursorSet = true
|
||
m.inputAttachments = nil
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) resetPromptHistoryCursor() {
|
||
m.promptActive = false
|
||
m.promptCursor = 0
|
||
m.promptDraft = nil
|
||
}
|
||
|
||
func (m *chatModel) insertInputNewline() {
|
||
m.insertInputRunes([]rune{'\n'})
|
||
}
|
||
|
||
func (m *chatModel) insertInputRunesFromKey(runes []rune, pasted bool) {
|
||
if len(runes) == 0 {
|
||
return
|
||
}
|
||
if m.opts.MultiModal && (pasted || len(runes) > 1) && m.insertInputFilePlaceholders(string(runes)) {
|
||
return
|
||
}
|
||
if pasted && m.insertPastedTextPlaceholder(string(runes)) {
|
||
return
|
||
}
|
||
m.insertInputRunes(runes)
|
||
}
|
||
|
||
func (m *chatModel) insertInputFilePlaceholders(input string) bool {
|
||
cleaned, files, err := filedata.ExtractWithFiles(input)
|
||
if err != nil {
|
||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
|
||
m.status = "attachment failed"
|
||
return true
|
||
}
|
||
if len(files) == 0 {
|
||
return false
|
||
}
|
||
|
||
var parts []string
|
||
if strings.TrimSpace(cleaned) != "" {
|
||
if placeholder, ok := m.pastedTextPlaceholder(cleaned); ok {
|
||
parts = append(parts, placeholder)
|
||
} else {
|
||
parts = append(parts, cleaned)
|
||
}
|
||
}
|
||
for _, file := range files {
|
||
kind := filedata.Kind(file.Path)
|
||
placeholder := m.nextInputAttachmentPlaceholder(kind)
|
||
m.inputAttachments = append(m.inputAttachments, chatInputAttachment{
|
||
placeholder: placeholder,
|
||
kind: kind,
|
||
data: file.Data,
|
||
})
|
||
parts = append(parts, placeholder)
|
||
}
|
||
m.insertInputRunes([]rune(strings.Join(parts, " ")))
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) insertPastedTextPlaceholder(input string) bool {
|
||
placeholder, ok := m.pastedTextPlaceholder(input)
|
||
if !ok {
|
||
return false
|
||
}
|
||
m.insertInputRunes([]rune(placeholder))
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) pastedTextPlaceholder(input string) (string, bool) {
|
||
if !shouldCollapsePastedText(input) {
|
||
return "", false
|
||
}
|
||
placeholder := m.nextInputPastedTextPlaceholder(input)
|
||
m.inputPastedTexts = append(m.inputPastedTexts, chatInputPastedText{
|
||
placeholder: placeholder,
|
||
content: input,
|
||
})
|
||
return placeholder, true
|
||
}
|
||
|
||
func shouldCollapsePastedText(input string) bool {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
return len([]rune(trimmed)) >= pastedTextPlaceholderMinRunes || pastedTextLineCount(trimmed) >= pastedTextPlaceholderMinLines
|
||
}
|
||
|
||
func pastedTextLineCount(input string) int {
|
||
if input == "" {
|
||
return 0
|
||
}
|
||
return strings.Count(input, "\n") + 1
|
||
}
|
||
|
||
func (m *chatModel) insertInputRunes(runes []rune) {
|
||
runes = normalizeInputRunes(runes)
|
||
if len(runes) == 0 {
|
||
return
|
||
}
|
||
m.resetPromptHistoryCursor()
|
||
m.disarmQuit()
|
||
cursor := m.normalizedInputCursor()
|
||
next := make([]rune, 0, len(m.input)+len(runes))
|
||
next = append(next, m.input[:cursor]...)
|
||
next = append(next, runes...)
|
||
next = append(next, m.input[cursor:]...)
|
||
m.input = next
|
||
m.inputCursor = cursor + len(runes)
|
||
m.inputCursorSet = true
|
||
m.complete = 0
|
||
}
|
||
|
||
func normalizeInputRunes(runes []rune) []rune {
|
||
for _, r := range runes {
|
||
if r == '\r' {
|
||
return normalizeInputRunesSlow(runes)
|
||
}
|
||
}
|
||
return runes
|
||
}
|
||
|
||
func normalizeInputRunesSlow(runes []rune) []rune {
|
||
out := make([]rune, 0, len(runes))
|
||
for i := 0; i < len(runes); i++ {
|
||
if runes[i] != '\r' {
|
||
out = append(out, runes[i])
|
||
continue
|
||
}
|
||
out = append(out, '\n')
|
||
if i+1 < len(runes) && runes[i+1] == '\n' {
|
||
i++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m *chatModel) deleteInputBackward() {
|
||
cursor := m.normalizedInputCursor()
|
||
if cursor <= 0 {
|
||
return
|
||
}
|
||
start, end, ok := m.placeholderRangeForBackspace(cursor)
|
||
if !ok {
|
||
start, end = cursor-1, cursor
|
||
}
|
||
m.deleteInputRange(start, end)
|
||
}
|
||
|
||
func (m *chatModel) deleteInputWordBackward() {
|
||
cursor := m.normalizedInputCursor()
|
||
if cursor <= 0 {
|
||
return
|
||
}
|
||
start, end, ok := m.placeholderRangeForWordDelete(cursor)
|
||
if !ok {
|
||
start, end = previousInputWordStart(m.input, cursor), cursor
|
||
}
|
||
m.deleteInputRange(start, end)
|
||
}
|
||
|
||
func (m *chatModel) deleteInputForward() {
|
||
cursor := m.normalizedInputCursor()
|
||
if cursor >= len(m.input) {
|
||
return
|
||
}
|
||
start, end, ok := m.placeholderRangeForForwardDelete(cursor)
|
||
if !ok {
|
||
start, end = cursor, cursor+1
|
||
}
|
||
m.deleteInputRange(start, end)
|
||
}
|
||
|
||
func (m *chatModel) deleteInputRange(start, end int) {
|
||
start = clamp(start, 0, len(m.input))
|
||
end = clamp(end, start, len(m.input))
|
||
m.input = append(slices.Clone(m.input[:start]), m.input[end:]...)
|
||
m.inputCursor = start
|
||
m.inputCursorSet = true
|
||
m.complete = 0
|
||
m.syncInputPlaceholders()
|
||
}
|
||
|
||
func (m chatModel) placeholderRangeForBackspace(cursor int) (int, int, bool) {
|
||
cursor = clamp(cursor, 0, len(m.input))
|
||
input := string(m.input)
|
||
for _, placeholder := range m.inputPlaceholders() {
|
||
if placeholder == "" {
|
||
continue
|
||
}
|
||
start, end, ok := inputPlaceholderRuneRange(input, placeholder)
|
||
if ok && cursor > start && cursor <= end {
|
||
return start, end, true
|
||
}
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
func (m chatModel) placeholderRangeForForwardDelete(cursor int) (int, int, bool) {
|
||
cursor = clamp(cursor, 0, len(m.input))
|
||
input := string(m.input)
|
||
for _, placeholder := range m.inputPlaceholders() {
|
||
if placeholder == "" {
|
||
continue
|
||
}
|
||
start, end, ok := inputPlaceholderRuneRange(input, placeholder)
|
||
if ok && cursor >= start && cursor < end {
|
||
return start, end, true
|
||
}
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
func (m chatModel) placeholderRangeForWordDelete(cursor int) (int, int, bool) {
|
||
cursor = clamp(cursor, 0, len(m.input))
|
||
end := cursor
|
||
for end > 0 && unicode.IsSpace(m.input[end-1]) {
|
||
end--
|
||
}
|
||
input := string(m.input)
|
||
for _, placeholder := range m.inputPlaceholders() {
|
||
if placeholder == "" {
|
||
continue
|
||
}
|
||
start, placeholderEnd, ok := inputPlaceholderRuneRange(input, placeholder)
|
||
if ok && end > start && end <= placeholderEnd {
|
||
return start, cursor, true
|
||
}
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
func (m chatModel) inputPlaceholders() []string {
|
||
placeholders := make([]string, 0, len(m.inputAttachments)+len(m.inputPastedTexts))
|
||
for _, attachment := range m.inputAttachments {
|
||
placeholders = append(placeholders, attachment.placeholder)
|
||
}
|
||
for _, pastedText := range m.inputPastedTexts {
|
||
placeholders = append(placeholders, pastedText.placeholder)
|
||
}
|
||
return placeholders
|
||
}
|
||
|
||
func inputPlaceholderRuneRange(input, placeholder string) (int, int, bool) {
|
||
byteStart := strings.Index(input, placeholder)
|
||
if byteStart < 0 {
|
||
return 0, 0, false
|
||
}
|
||
start := len([]rune(input[:byteStart]))
|
||
end := start + len([]rune(placeholder))
|
||
return start, end, true
|
||
}
|
||
|
||
func previousInputWordStart(input []rune, cursor int) int {
|
||
end := clamp(cursor, 0, len(input))
|
||
for end > 0 && unicode.IsSpace(input[end-1]) {
|
||
end--
|
||
}
|
||
start := end
|
||
for start > 0 && !unicode.IsSpace(input[start-1]) {
|
||
start--
|
||
}
|
||
return start
|
||
}
|
||
|
||
func (m *chatModel) syncInputPlaceholders() {
|
||
m.inputAttachments = m.activeInputAttachmentsFor(string(m.input))
|
||
m.inputPastedTexts = m.activeInputPastedTextsFor(string(m.input))
|
||
}
|
||
|
||
func (m chatModel) activeInputAttachmentsFor(input string) []chatInputAttachment {
|
||
if len(m.inputAttachments) == 0 {
|
||
return nil
|
||
}
|
||
active := make([]chatInputAttachment, 0, len(m.inputAttachments))
|
||
for _, attachment := range m.inputAttachments {
|
||
if strings.Contains(input, attachment.placeholder) {
|
||
active = append(active, attachment)
|
||
}
|
||
}
|
||
return active
|
||
}
|
||
|
||
func cloneInputAttachments(in []chatInputAttachment) []chatInputAttachment {
|
||
return slices.Clone(in)
|
||
}
|
||
|
||
type chatInputPastedText struct {
|
||
placeholder string
|
||
content string
|
||
}
|
||
|
||
func (m chatModel) activeInputPastedTextsFor(input string) []chatInputPastedText {
|
||
if len(m.inputPastedTexts) == 0 {
|
||
return nil
|
||
}
|
||
active := make([]chatInputPastedText, 0, len(m.inputPastedTexts))
|
||
for _, pastedText := range m.inputPastedTexts {
|
||
if strings.Contains(input, pastedText.placeholder) {
|
||
active = append(active, pastedText)
|
||
}
|
||
}
|
||
return active
|
||
}
|
||
|
||
func cloneInputPastedTexts(in []chatInputPastedText) []chatInputPastedText {
|
||
return slices.Clone(in)
|
||
}
|
||
|
||
func (m chatModel) expandPastedTextPlaceholders(input string) string {
|
||
for _, pastedText := range m.activeInputPastedTextsFor(input) {
|
||
input = strings.ReplaceAll(input, pastedText.placeholder, pastedText.content)
|
||
}
|
||
return input
|
||
}
|
||
|
||
func (m *chatModel) nextInputPastedTextPlaceholder(content string) string {
|
||
if m.nextPastedTextID <= 0 {
|
||
m.nextPastedTextID = 1
|
||
}
|
||
id := m.nextPastedTextID
|
||
m.nextPastedTextID++
|
||
return fmt.Sprintf("[Pasted text #%d +%d lines]", id, pastedTextLineCount(strings.TrimSpace(content)))
|
||
}
|
||
|
||
func (m *chatModel) nextInputAttachmentPlaceholder(kind string) string {
|
||
label := inputAttachmentLabel(kind)
|
||
switch kind {
|
||
case "audio":
|
||
id := m.nextAudioID
|
||
m.nextAudioID++
|
||
return fmt.Sprintf("[%s #%d]", label, id)
|
||
default:
|
||
id := m.nextImageID
|
||
m.nextImageID++
|
||
return fmt.Sprintf("[%s #%d]", label, id)
|
||
}
|
||
}
|
||
|
||
func inputAttachmentLabel(kind string) string {
|
||
switch kind {
|
||
case "audio":
|
||
return "Audio"
|
||
default:
|
||
return "Image"
|
||
}
|
||
}
|
||
|
||
var (
|
||
inputAttachmentPlaceholderPattern = regexp.MustCompile(`\[(Image|Audio) #([0-9]+)\]`)
|
||
inputPastedTextPlaceholderPattern = regexp.MustCompile(`\[Pasted text #([0-9]+) \+[0-9]+ lines?\]`)
|
||
)
|
||
|
||
func nextInputAttachmentIDsFromMessages(messages []api.Message) (imageID int, audioID int) {
|
||
for _, msg := range messages {
|
||
for _, match := range inputAttachmentPlaceholderPattern.FindAllStringSubmatch(msg.Content, -1) {
|
||
if len(match) != 3 {
|
||
continue
|
||
}
|
||
id, err := strconv.Atoi(match[2])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
switch match[1] {
|
||
case "Image":
|
||
imageID = max(imageID, id+1)
|
||
case "Audio":
|
||
audioID = max(audioID, id+1)
|
||
}
|
||
}
|
||
}
|
||
return imageID, audioID
|
||
}
|
||
|
||
func nextInputPastedTextIDFromMessages(messages []api.Message) int {
|
||
nextID := 1
|
||
for _, msg := range messages {
|
||
for _, match := range inputPastedTextPlaceholderPattern.FindAllStringSubmatch(msg.Content, -1) {
|
||
if len(match) != 2 {
|
||
continue
|
||
}
|
||
id, err := strconv.Atoi(match[1])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
nextID = max(nextID, id+1)
|
||
}
|
||
}
|
||
return nextID
|
||
}
|
||
|
||
func (m *chatModel) moveInputCursorHorizontal(delta int) bool {
|
||
if delta == 0 {
|
||
return false
|
||
}
|
||
cursor := clamp(m.normalizedInputCursor()+delta, 0, len(m.input))
|
||
if cursor == m.normalizedInputCursor() {
|
||
return false
|
||
}
|
||
m.inputCursor = cursor
|
||
m.inputCursorSet = true
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) moveInputCursorToLineStart() bool {
|
||
cursor := m.normalizedInputCursor()
|
||
start, _ := inputLineBounds(m.input, cursor)
|
||
if start == cursor {
|
||
return false
|
||
}
|
||
m.inputCursor = start
|
||
m.inputCursorSet = true
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) moveInputCursorToLineEnd() bool {
|
||
cursor := m.normalizedInputCursor()
|
||
_, end := inputLineBounds(m.input, cursor)
|
||
if end == cursor {
|
||
return false
|
||
}
|
||
m.inputCursor = end
|
||
m.inputCursorSet = true
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) moveInputCursorWord(delta int) bool {
|
||
if delta == 0 || len(m.input) == 0 {
|
||
return false
|
||
}
|
||
cursor := m.normalizedInputCursor()
|
||
target := cursor
|
||
if delta < 0 {
|
||
for target > 0 && unicode.IsSpace(m.input[target-1]) {
|
||
target--
|
||
}
|
||
for target > 0 && !unicode.IsSpace(m.input[target-1]) {
|
||
target--
|
||
}
|
||
} else {
|
||
for target < len(m.input) && !unicode.IsSpace(m.input[target]) {
|
||
target++
|
||
}
|
||
for target < len(m.input) && unicode.IsSpace(m.input[target]) {
|
||
target++
|
||
}
|
||
}
|
||
if target == cursor {
|
||
return false
|
||
}
|
||
m.inputCursor = target
|
||
m.inputCursorSet = true
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) handleInputAltRunes(runes []rune) bool {
|
||
if len(runes) != 1 {
|
||
return false
|
||
}
|
||
switch runes[0] {
|
||
case 'b', 'B':
|
||
m.moveInputCursorWord(-1)
|
||
return true
|
||
case 'f', 'F':
|
||
m.moveInputCursorWord(1)
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (m *chatModel) moveInputCursorVertical(delta int) bool {
|
||
if delta == 0 || len(m.input) == 0 {
|
||
return false
|
||
}
|
||
cursor := m.normalizedInputCursor()
|
||
lineStart, lineEnd := inputLineBounds(m.input, cursor)
|
||
column := cursor - lineStart
|
||
var targetStart, targetEnd int
|
||
if delta < 0 {
|
||
if lineStart == 0 {
|
||
return false
|
||
}
|
||
targetEnd = lineStart - 1
|
||
targetStart, _ = inputLineBounds(m.input, targetEnd)
|
||
} else {
|
||
if lineEnd >= len(m.input) {
|
||
return false
|
||
}
|
||
targetStart = lineEnd + 1
|
||
_, targetEnd = inputLineBounds(m.input, targetStart)
|
||
}
|
||
target := min(targetStart+column, targetEnd)
|
||
m.inputCursor = target
|
||
m.inputCursorSet = true
|
||
m.resetPromptHistoryCursor()
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func inputLineBounds(input []rune, cursor int) (int, int) {
|
||
cursor = clamp(cursor, 0, len(input))
|
||
start := cursor
|
||
for start > 0 && input[start-1] != '\n' {
|
||
start--
|
||
}
|
||
end := cursor
|
||
for end < len(input) && input[end] != '\n' {
|
||
end++
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
func (m chatModel) normalizedInputCursor() int {
|
||
if !m.inputCursorSet {
|
||
return len(m.input)
|
||
}
|
||
return clamp(m.inputCursor, 0, len(m.input))
|
||
}
|
||
|
||
func inputWithCursor(input []rune, cursor int) string {
|
||
cursor = clamp(cursor, 0, len(input))
|
||
next := make([]rune, 0, len(input)+1)
|
||
next = append(next, input[:cursor]...)
|
||
next = append(next, []rune(inputCursorMarker)...)
|
||
next = append(next, input[cursor:]...)
|
||
return string(next)
|
||
}
|
||
|
||
func isShiftEnterCSI(msg tea.Msg) bool {
|
||
switch fmt.Sprint(msg) {
|
||
case "?CSI[49 51 59 50 117]?", // \x1b[13;2u
|
||
"?CSI[49 51 59 50 126]?", // \x1b[13;2~
|
||
"?CSI[50 55 59 50 59 49 51 126]?": // \x1b[27;2;13~
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func (m chatModel) emptyInputPlaceholder() string {
|
||
if m.promptDebug != nil || m.modelPicker != nil || m.thinkPicker != nil || m.approvalPrompt != nil || m.cloudAuthPrompt != nil {
|
||
return ""
|
||
}
|
||
if len(m.entries) > 0 || len(m.messages) > 0 || len(m.input) > 0 || len(m.inputAttachments) > 0 || len(m.inputPastedTexts) > 0 {
|
||
return ""
|
||
}
|
||
return m.emptyChatHint()
|
||
}
|
||
|
||
func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, placeholder string) []string {
|
||
if width < 12 {
|
||
width = 12
|
||
}
|
||
if maxBodyLines < 1 {
|
||
maxBodyLines = 1
|
||
}
|
||
|
||
prefix := chatPromptPrefix
|
||
continuationPrefix := strings.Repeat(" ", lipgloss.Width(prefix))
|
||
prefixWidth := lipgloss.Width(prefix)
|
||
innerWidth := max(1, width-2)
|
||
contentWidth := max(1, innerWidth-inputBoxHorizontalPadding*2)
|
||
bodyWidth := max(1, contentWidth-prefixWidth)
|
||
|
||
var raw []string
|
||
placeholderMode := input == "" && strings.TrimSpace(placeholder) != ""
|
||
if input == "" && strings.TrimSpace(placeholder) != "" {
|
||
raw = renderInputPromptRawLines(inputWithCursor([]rune(placeholder), 0), prefix, continuationPrefix, bodyWidth)
|
||
} else {
|
||
if cursor >= 0 {
|
||
input = inputWithCursor([]rune(input), cursor)
|
||
}
|
||
raw = renderInputPromptRawLines(input, prefix, continuationPrefix, bodyWidth)
|
||
}
|
||
if len(raw) > maxBodyLines {
|
||
raw = slices.Clone(raw[len(raw)-maxBodyLines:])
|
||
raw[0] = truncateInputLine(continuationPrefix+trimInputPromptPrefix(raw[0]), contentWidth)
|
||
}
|
||
|
||
lines := make([]string, 0, len(raw)+2)
|
||
lines = append(lines, chatInputBorderStyle.Render(inputBoxTopBorderLine(width)))
|
||
for i, line := range raw {
|
||
rendered := line
|
||
if placeholderMode {
|
||
if i == 0 && strings.HasPrefix(line, prefix) {
|
||
rest := strings.TrimPrefix(line, prefix)
|
||
if strings.HasPrefix(rest, inputCursorMarker) {
|
||
rendered = chatUserStyle.Render(prefix) + renderInputTextWithCursorStyle(rest, chatInputPlaceholderStyle)
|
||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||
continue
|
||
}
|
||
rendered = chatUserStyle.Render(prefix) + chatInputPlaceholderStyle.Render(rest)
|
||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, continuationPrefix) {
|
||
rendered = chatInputPlaceholderStyle.Render(continuationPrefix + strings.TrimPrefix(line, continuationPrefix))
|
||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||
continue
|
||
}
|
||
rendered = chatInputPlaceholderStyle.Render(line)
|
||
lines = append(lines, renderInputBoxBodyLine(rendered, contentWidth))
|
||
continue
|
||
}
|
||
lines = append(lines, renderInputBoxBodyLine(renderInputTextWithCursor(rendered), contentWidth))
|
||
}
|
||
lines = append(lines, chatInputBorderStyle.Render(inputBoxBottomBorderLine(width)))
|
||
return lines
|
||
}
|
||
|
||
func renderInputTextWithCursor(line string) string {
|
||
return renderInputTextWithCursorStyle(line, chatUserStyle)
|
||
}
|
||
|
||
func renderInputTextWithCursorStyle(line string, style lipgloss.Style) string {
|
||
before, after, ok := strings.Cut(line, inputCursorMarker)
|
||
if !ok {
|
||
return style.Render(line)
|
||
}
|
||
cell, rest := inputCursorCell(after)
|
||
return style.Render(before) + renderInputCursorCell(cell) + style.Render(rest)
|
||
}
|
||
|
||
func inputCursorCell(after string) (string, string) {
|
||
if after == "" {
|
||
return "", ""
|
||
}
|
||
r, size := utf8.DecodeRuneInString(after)
|
||
if r == '\n' {
|
||
return "", after
|
||
}
|
||
return after[:size], after[size:]
|
||
}
|
||
|
||
func renderInputCursorCell(cell string) string {
|
||
if cell == "" {
|
||
return chatBlankCursorStyle.Render(inputCursorGlyph)
|
||
}
|
||
return chatCursorStyle.Render(cell)
|
||
}
|
||
|
||
func renderInputPromptRawLines(text, prefix, continuationPrefix string, width int) []string {
|
||
if width <= 0 {
|
||
width = 1
|
||
}
|
||
text = string(normalizeInputRunes([]rune(text)))
|
||
body := wrapChatText(text, width)
|
||
for i, line := range body {
|
||
if i == 0 {
|
||
body[i] = prefix + line
|
||
continue
|
||
}
|
||
body[i] = continuationPrefix + line
|
||
}
|
||
if len(body) == 0 {
|
||
return []string{prefix}
|
||
}
|
||
return body
|
||
}
|
||
|
||
func trimInputPromptPrefix(line string) string {
|
||
for _, prefix := range []string{chatPromptPrefix, strings.Repeat(" ", lipgloss.Width(chatPromptPrefix))} {
|
||
if prefix == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, prefix) {
|
||
return strings.TrimPrefix(line, prefix)
|
||
}
|
||
}
|
||
return strings.TrimSpace(line)
|
||
}
|
||
|
||
func inputBoxTopBorderLine(width int) string {
|
||
return inputBoxBorderLine(width, "╭", "╮")
|
||
}
|
||
|
||
func inputBoxBottomBorderLine(width int) string {
|
||
return inputBoxBorderLine(width, "╰", "╯")
|
||
}
|
||
|
||
func inputBoxBorderLine(width int, left, right string) string {
|
||
if width < 4 {
|
||
width = 4
|
||
}
|
||
return left + strings.Repeat("─", max(0, width-2)) + right
|
||
}
|
||
|
||
func renderInputBoxBodyLine(line string, width int) string {
|
||
padding := strings.Repeat(" ", inputBoxHorizontalPadding)
|
||
return chatInputBorderStyle.Render("│") + padding + padRenderedLine(line, width) + padding + chatInputBorderStyle.Render("│")
|
||
}
|
||
|
||
func padRenderedLine(line string, width int) string {
|
||
if width <= 0 {
|
||
return line
|
||
}
|
||
if renderedWidth := lipgloss.Width(line); renderedWidth < width {
|
||
return line + strings.Repeat(" ", width-renderedWidth)
|
||
}
|
||
return line
|
||
}
|
||
|
||
func truncateInputLine(line string, width int) string {
|
||
if width <= 0 {
|
||
return line
|
||
}
|
||
if runewidth.StringWidth(line) <= width {
|
||
return line
|
||
}
|
||
return runewidth.Truncate(line, width, "")
|
||
}
|
||
|
||
func (m chatModel) slashCommandLines(width int) []string {
|
||
return m.renderCompletions(m.slashCompletions(), width)
|
||
}
|
||
|
||
func (m chatModel) completionLines(width int) []string {
|
||
return m.renderCompletions(m.completions(), width)
|
||
}
|
||
|
||
func (m chatModel) renderCompletions(completions []chatCompletion, width int) []string {
|
||
if len(completions) == 0 {
|
||
return nil
|
||
}
|
||
selected := clamp(m.complete, 0, len(completions)-1)
|
||
start, end := completionWindow(len(completions), selected, m.completionVisibleLimit(len(completions)))
|
||
completions = completions[start:end]
|
||
|
||
nameWidth := 0
|
||
for _, completion := range completions {
|
||
nameWidth = max(nameWidth, lipgloss.Width(completion.label))
|
||
}
|
||
|
||
lines := make([]string, 0, len(completions))
|
||
for i, completion := range completions {
|
||
marker := " "
|
||
if start+i == selected {
|
||
marker = "› "
|
||
}
|
||
name := chatCommandNameStyle.Render(completion.label)
|
||
padding := strings.Repeat(" ", max(1, nameWidth-lipgloss.Width(completion.label)+2))
|
||
line := marker + name + padding + chatMetaStyle.Render(completion.description)
|
||
lines = append(lines, truncateRenderedLine(line, width))
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func (m chatModel) completionVisibleLimit(total int) int {
|
||
if strings.HasPrefix(strings.TrimSpace(string(m.input)), "/") {
|
||
return min(maxSlashCompletions, total)
|
||
}
|
||
return total
|
||
}
|
||
|
||
func completionWindow(total, selected, limit int) (int, int) {
|
||
if total <= 0 || limit <= 0 || limit >= total {
|
||
return 0, total
|
||
}
|
||
selected = clamp(selected, 0, total-1)
|
||
start := selected - limit + 1
|
||
if start < 0 {
|
||
start = 0
|
||
}
|
||
end := start + limit
|
||
if end > total {
|
||
end = total
|
||
start = max(0, end-limit)
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
func (m chatModel) completions() []chatCompletion {
|
||
if completions := m.slashCompletions(); len(completions) > 0 {
|
||
return completions
|
||
}
|
||
return m.mentionCompletions()
|
||
}
|
||
|
||
func (m chatModel) slashCompletions() []chatCompletion {
|
||
input := strings.TrimSpace(string(m.input))
|
||
if !strings.HasPrefix(input, "/") {
|
||
return nil
|
||
}
|
||
|
||
commands := matchingSlashCommands(input)
|
||
if len(commands) == 0 {
|
||
return []chatCompletion{{label: "No matching commands"}}
|
||
}
|
||
|
||
completions := make([]chatCompletion, 0, len(commands))
|
||
for _, command := range commands {
|
||
completions = append(completions, chatCompletion{
|
||
value: command.name,
|
||
label: command.name,
|
||
description: command.description,
|
||
})
|
||
}
|
||
return completions
|
||
}
|
||
|
||
func matchingSlashCommands(input string) []chatSlashCommand {
|
||
prefix := strings.ToLower(strings.TrimSpace(input))
|
||
if prefix == "" {
|
||
return nil
|
||
}
|
||
|
||
var commands []chatSlashCommand
|
||
for _, command := range chatSlashCommands {
|
||
if command.hidden {
|
||
continue
|
||
}
|
||
if command.matchesPrefix(prefix) {
|
||
commands = append(commands, command)
|
||
}
|
||
}
|
||
return commands
|
||
}
|
||
|
||
func (c chatSlashCommand) matchesPrefix(prefix string) bool {
|
||
if strings.HasPrefix(c.name, prefix) {
|
||
return true
|
||
}
|
||
for _, alias := range c.aliases {
|
||
if strings.HasPrefix(alias, prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func slashCommandInvocation(input string) (string, string, bool) {
|
||
input = strings.TrimSpace(input)
|
||
if !strings.HasPrefix(input, "/") {
|
||
return "", "", false
|
||
}
|
||
token, args, _ := strings.Cut(input, " ")
|
||
token = strings.ToLower(token)
|
||
for _, command := range chatSlashCommands {
|
||
if command.name == token || slices.Contains(command.aliases, token) {
|
||
return command.name, strings.TrimSpace(args), true
|
||
}
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
func (m chatModel) mentionCompletions() []chatCompletion {
|
||
input := string(m.input)
|
||
_, query, ok := activeMentionToken(input)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
|
||
workingDir := m.currentWorkingDir()
|
||
if strings.TrimSpace(workingDir) == "" {
|
||
var err error
|
||
workingDir, err = os.Getwd()
|
||
if err != nil {
|
||
return []chatCompletion{{label: "No working directory"}}
|
||
}
|
||
}
|
||
|
||
dirPart, prefix := splitMentionQuery(query)
|
||
dir, err := resolveCompletionDir(workingDir, dirPart)
|
||
if err != nil {
|
||
return []chatCompletion{{label: "No matching files"}}
|
||
}
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return []chatCompletion{{label: "No matching files"}}
|
||
}
|
||
sort.SliceStable(entries, func(i, j int) bool {
|
||
if entries[i].IsDir() != entries[j].IsDir() {
|
||
return entries[i].IsDir()
|
||
}
|
||
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
||
})
|
||
|
||
includeHidden := strings.HasPrefix(prefix, ".")
|
||
completions := make([]chatCompletion, 0, 8)
|
||
for _, entry := range entries {
|
||
name := entry.Name()
|
||
if !includeHidden && strings.HasPrefix(name, ".") {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) {
|
||
continue
|
||
}
|
||
value := filepath.ToSlash(filepath.Join(dirPart, name))
|
||
label := "@" + value
|
||
description := "file"
|
||
if entry.IsDir() {
|
||
value += "/"
|
||
label += "/"
|
||
description = "directory"
|
||
}
|
||
completions = append(completions, chatCompletion{
|
||
value: value,
|
||
label: label,
|
||
description: description,
|
||
directory: entry.IsDir(),
|
||
})
|
||
if len(completions) >= 8 {
|
||
break
|
||
}
|
||
}
|
||
if len(completions) == 0 {
|
||
return []chatCompletion{{label: "No matching files"}}
|
||
}
|
||
return completions
|
||
}
|
||
|
||
func activeMentionToken(input string) (int, string, bool) {
|
||
runes := []rune(input)
|
||
start := len(runes)
|
||
for start > 0 && !unicode.IsSpace(runes[start-1]) {
|
||
start--
|
||
}
|
||
token := string(runes[start:])
|
||
if !strings.HasPrefix(token, "@") {
|
||
return 0, "", false
|
||
}
|
||
return start, token[1:], true
|
||
}
|
||
|
||
func splitMentionQuery(query string) (string, string) {
|
||
query = filepath.ToSlash(query)
|
||
index := strings.LastIndex(query, "/")
|
||
if index < 0 {
|
||
return ".", query
|
||
}
|
||
return query[:index+1], query[index+1:]
|
||
}
|
||
|
||
func resolveCompletionDir(workingDir, dir string) (string, error) {
|
||
if filepath.IsAbs(dir) {
|
||
return "", fmt.Errorf("absolute paths are not allowed")
|
||
}
|
||
base := workingDir
|
||
if base == "" {
|
||
base = "."
|
||
}
|
||
base, err := filepath.Abs(base)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
resolved := filepath.Clean(filepath.Join(base, dir))
|
||
rel, err := filepath.Rel(base, resolved)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return "", fmt.Errorf("path escapes working directory")
|
||
}
|
||
return resolved, nil
|
||
}
|
||
|
||
func (m *chatModel) moveCompletion(delta int) bool {
|
||
completions := m.completions()
|
||
if len(completions) == 0 || !completionIsSelectable(completions) {
|
||
return false
|
||
}
|
||
m.complete = (m.complete + delta) % len(completions)
|
||
if m.complete < 0 {
|
||
m.complete += len(completions)
|
||
}
|
||
return true
|
||
}
|
||
|
||
func (m *chatModel) applyCompletion() bool {
|
||
completions := m.completions()
|
||
if len(completions) == 0 || !completionIsSelectable(completions) {
|
||
return false
|
||
}
|
||
m.resetPromptHistoryCursor()
|
||
selected := completions[clamp(m.complete, 0, len(completions)-1)]
|
||
input := string(m.input)
|
||
if strings.HasPrefix(strings.TrimSpace(input), "/") {
|
||
m.input = []rune(selected.value)
|
||
m.inputCursor = len(m.input)
|
||
m.inputCursorSet = true
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
start, _, ok := activeMentionToken(input)
|
||
if !ok {
|
||
return false
|
||
}
|
||
suffix := ""
|
||
if !selected.directory {
|
||
suffix = " "
|
||
}
|
||
next := string([]rune(input)[:start]) + "@" + selected.value + suffix
|
||
m.input = []rune(next)
|
||
m.inputCursor = len(m.input)
|
||
m.inputCursorSet = true
|
||
m.complete = 0
|
||
return true
|
||
}
|
||
|
||
func completionIsSelectable(completions []chatCompletion) bool {
|
||
return len(completions) > 0 && completions[0].value != ""
|
||
}
|
||
|
||
func (m chatModel) helpSummary() string {
|
||
lines := []string{
|
||
"**Commands**",
|
||
"",
|
||
}
|
||
for _, command := range chatSlashCommands {
|
||
if command.hidden || strings.TrimSpace(command.description) == "" {
|
||
continue
|
||
}
|
||
usage := command.name
|
||
if command.usage != "" {
|
||
usage = command.usage
|
||
}
|
||
lines = append(lines, fmt.Sprintf("- `%s`: %s", usage, command.description))
|
||
}
|
||
lines = append(lines,
|
||
"",
|
||
"**Shortcuts**",
|
||
"",
|
||
"- `shift+enter`: insert a newline",
|
||
"- `shift+tab`: toggle permission mode",
|
||
"- `↑/↓`: previous or next prompt",
|
||
"- `ctrl+a/e`: move to line start or end",
|
||
)
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func (m chatModel) systemPrompt(extra string) string {
|
||
var parts []string
|
||
if strings.TrimSpace(m.opts.SystemPrompt) != "" {
|
||
parts = append(parts, strings.TrimSpace(m.opts.SystemPrompt))
|
||
}
|
||
if strings.TrimSpace(extra) != "" {
|
||
parts = append(parts, strings.TrimSpace(extra))
|
||
}
|
||
return strings.Join(parts, "\n\n")
|
||
}
|