ollama/cmd/tui/chat/input.go
ParthSareen 3052b16971 feat(chat): save raw request with /raw > filename
Adds /raw > <file> to write the JSON request preview to a file in the
working directory instead of opening the popup. Routes /raw through a
new handleRawCommand that distinguishes the popup form from the
redirect form, and extracts rawRequestPreviewJSON so the popup and the
save path share one preview build.
2026-06-25 13:19:24 -07:00

1399 lines
36 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package chat
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"unicode"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/agent/skills"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/filedata"
)
type chatSlashCommand struct {
name string
description string
aliases []string
hidden bool
}
type chatCompletion struct {
value string
label string
description string
directory bool
}
const (
chatPromptPrefix = " "
maxInputBoxBodyLines = 6
)
const (
pastedTextPlaceholderMinRunes = 1000
pastedTextPlaceholderMinLines = 8
)
var chatSlashCommands = []chatSlashCommand{
{name: "/clear", description: "clear this chat"},
{name: "/model", description: "switch models"},
{name: "/history", description: "show prompt message history", hidden: true},
{name: "/raw", description: "show raw request preview", hidden: true},
{name: "/skills", description: "show or import installed skills"},
{name: "/new", description: "start a new chat"},
{name: "/resume", description: "resume a saved chat"},
{name: "/think", description: "set thinking mode"},
{name: "/verbose", description: "toggle model metrics"},
{name: "/compact", description: "summarize older context"},
{name: "/help", description: "show commands", aliases: []string{"/?"}},
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
{name: "/load", hidden: true},
{name: "/set", hidden: true},
{name: "/show", hidden: true},
}
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
m.syncInputPlaceholders()
input := strings.TrimSpace(string(m.input))
if selected, ok := m.selectedSlashCommand(); ok {
input = selected
}
attachments := cloneInputAttachments(m.inputAttachments)
pastedTexts := cloneInputPastedTexts(m.inputPastedTexts)
m.input = nil
m.inputCursor = 0
m.inputCursorSet = false
m.inputAttachments = nil
m.inputPastedTexts = nil
m.complete = 0
m.resetPromptHistoryCursor()
if input == "" {
return *m, nil
}
if m.running || m.compacting {
m.queued = append(m.queued, input)
m.queuedAttachments = append(m.queuedAttachments, attachments)
m.queuedPastedTexts = append(m.queuedPastedTexts, pastedTexts)
m.status = "queued"
return *m, nil
}
m.inputAttachments = attachments
m.inputPastedTexts = pastedTexts
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, hasSlashCommand := slashCommandInvocation(input)
if hasSlashCommand {
input = strings.TrimSpace(command + " " + args)
}
switch {
case command == "/bye":
m.quitting = true
return *m, m.quitCmd()
case command == "/help" && args == "":
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
return *m, nil
case command == "/help":
return m.handleLegacyHelpCommand(input)
case command == "/clear" && args == "":
return m.resetChat("cleared")
case command == "/model":
return m.openModelPicker(args)
case command == "/load":
return m.handleLegacyLoadCommand(input)
case command == "/think" && args == "":
return m.openThinkPicker()
case command == "/think":
return m.handleThinkCommand(args)
case command == "/set":
return m.handleLegacySetCommand(input)
case command == "/show":
return m.handleLegacyShowCommand(input)
case command == "/history" && args == "":
return m.openHistoryPopup()
case command == "/raw":
return m.handleRawCommand(args)
case command == "/skills":
m.entries = append(m.entries, newSlashEntry(m.handleSkillsCommand(input)))
return *m, nil
case command == "/new" && args == "":
return m.resetChat("new chat")
case command == "/resume" && args == "":
return m.openResumePicker()
case command == "/verbose":
return m.handleVerboseCommand(input)
case command == "/compact" && args == "":
return m.startManualCompaction()
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
return m.startRun(input)
case strings.HasPrefix(input, "/"):
if skill, request, ok := m.skillTrigger(input); ok {
historyInput := m.expandPastedTextPlaceholders(input)
displayInput, userMessage, err := m.userMessageFromInput(input, request)
if err != nil {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
return *m, nil
}
manualMessages, err := agenttools.ManualSkillMessages(skill, request, len(m.messages)+1)
if err != nil {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
return *m, nil
}
if strings.TrimSpace(userMessage.Content) != "" {
manualMessages[0].Content = userMessage.Content
}
manualMessages[0].Images = userMessage.Images
return m.startRunWithMessages(displayInput, historyInput, manualMessages, "")
}
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) 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 (m *chatModel) handleVerboseCommand(input string) (tea.Model, tea.Cmd) {
arg := strings.TrimSpace(strings.TrimPrefix(input, "/verbose"))
switch strings.ToLower(arg) {
case "":
m.opts.Verbose = !m.opts.Verbose
case "on", "true", "1":
m.opts.Verbose = true
case "off", "false", "0":
m.opts.Verbose = false
default:
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: `Usage: /verbose [on|off]`}))
m.status = "error"
return *m, nil
}
if m.opts.Verbose {
m.status = "verbose on"
} else {
m.status = "verbose off"
}
return *m, nil
}
func initialPromptHistory(ctx context.Context, opts Options) []string {
if ctx == nil {
ctx = context.Background()
}
if store, ok := opts.Store.(chatPromptHistoryStore); ok && store != nil {
prompts, err := store.ListUserMessages(ctx, maxPromptHistory)
if err == nil {
return normalizePromptHistory(prompts)
}
}
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) {
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 (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) 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, '█')
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.modelPicker != nil || m.resumePicker != nil || m.thinkPicker != nil || m.historyPopup != 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 || len(m.queued) > 0 {
return ""
}
return m.emptyChatHint()
}
func renderInputBoxLines(input string, cursor int, width, maxBodyLines int, placeholder string) []string {
if width < 8 {
width = 8
}
if maxBodyLines < 1 {
maxBodyLines = 1
}
prefix := chatPromptPrefix
continuationPrefix := "... "
prefixWidth := lipgloss.Width(prefix)
bodyWidth := max(1, width-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]), width)
}
lines := make([]string, 0, len(raw))
for i, line := range raw {
if placeholderMode {
if i == 0 && strings.HasPrefix(line, prefix) {
rest := strings.TrimPrefix(line, prefix)
if strings.HasPrefix(rest, "█") {
lines = append(lines, chatUserStyle.Render(prefix+"█")+chatMetaStyle.Render(strings.TrimPrefix(rest, "█")))
continue
}
lines = append(lines, chatUserStyle.Render(prefix)+chatMetaStyle.Render(rest))
continue
}
if strings.HasPrefix(line, continuationPrefix) {
lines = append(lines, chatMetaStyle.Render(continuationPrefix+strings.TrimPrefix(line, continuationPrefix)))
continue
}
lines = append(lines, chatMetaStyle.Render(line))
continue
}
lines = append(lines, chatUserStyle.Render(line))
}
return lines
}
func renderInputPromptRawLines(text, prefix, continuationPrefix string, width int) []string {
if width <= 0 {
width = 1
}
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, "... "} {
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 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 renderPromptRow(text string, width int) []string {
if width < 20 {
width = 20
}
lines := wrapChatText(text, width)
for i, line := range lines {
lines[i] = chatUserStyle.Render(line)
}
return lines
}
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)
skillCompletions := m.skillSlashCompletions(input)
if len(commands) == 0 && len(skillCompletions) == 0 {
return []chatCompletion{{label: "No matching commands"}}
}
completions := make([]chatCompletion, 0, len(commands)+len(skillCompletions))
for _, command := range commands {
completions = append(completions, chatCompletion{
value: command.name,
label: command.name,
description: command.description,
})
}
completions = append(completions, skillCompletions...)
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
}
lines = append(lines, fmt.Sprintf("- `%s`: %s", command.name, command.description))
if command.name == "/skills" {
lines = append(lines, "- `/<skill>`: run the next message with a skill")
}
}
lines = append(lines,
"",
"**Shortcuts**",
"",
"- `ctrl+o`: toggle tool output",
"- `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) historyMessages() []api.Message {
var messages []api.Message
if systemPrompt := strings.TrimSpace(m.systemPrompt("")); systemPrompt != "" {
messages = append(messages, api.Message{Role: "system", Content: systemPrompt})
}
return append(messages, m.messages...)
}
func (m chatModel) skillsSummary() string {
if m.opts.Skills == nil || m.opts.Skills.Empty() {
return "No skills are installed.\n\nImport skills with `/skills import claude`, `/skills import codex`, `/skills import pi`, or `/skills import all`."
}
return m.opts.Skills.SummaryMarkdown() + "\n\nImport more with `/skills import claude`, `/skills import codex`, `/skills import pi`, or `/skills import all`."
}
func (m *chatModel) handleSkillsCommand(input string) string {
fields := strings.Fields(input)
if len(fields) == 1 {
return m.skillsSummary()
}
if len(fields) >= 2 && fields[1] == "import" {
return m.importSkills(fields[2:])
}
return "Usage:\n\n/skills\n/skills import claude|codex|pi|agents|all [--force]"
}
func (m *chatModel) importSkills(args []string) string {
source := "all"
force := false
for _, arg := range args {
switch arg {
case "--force":
force = true
default:
if strings.TrimSpace(arg) != "" {
source = arg
}
}
}
results, err := skills.Import(source, force)
if err != nil {
return err.Error()
}
catalog, err := skills.LoadDefault()
if err != nil {
return fmt.Sprintf("imported skills but could not reload catalog: %v", err)
}
if m.opts.Skills != nil {
*m.opts.Skills = *catalog
catalog = m.opts.Skills
} else {
m.opts.Skills = catalog
}
if m.opts.Tools != nil && !catalog.Empty() {
m.opts.Tools.Register(agenttools.NewSkill(catalog))
}
if m.opts.SystemPromptForModel != nil {
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools)
}
if len(results) == 0 {
return fmt.Sprintf("No skills found for %s.", source)
}
var imported, skipped int
var lines []string
for _, result := range results {
name := result.Skill.Name
if name == "" {
name = result.From
}
if result.Skipped {
skipped++
line := "skipped " + name
if result.Error != "" {
line += " (" + result.Error + ")"
}
lines = append(lines, line)
continue
}
imported++
line := "imported " + name
if result.Error != "" {
line += " (" + result.Error + ")"
}
lines = append(lines, line)
}
lines = append(lines, "", fmt.Sprintf("%d imported, %d skipped", imported, skipped))
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")
}
func (m chatModel) skillTrigger(input string) (skills.Skill, string, bool) {
if m.opts.Skills == nil || m.opts.Skills.Empty() {
return skills.Skill{}, "", false
}
command, rest, _ := strings.Cut(strings.TrimSpace(input), " ")
name := strings.TrimPrefix(command, "/")
skill, ok := m.opts.Skills.Find(name)
return skill, strings.TrimSpace(rest), ok
}
func (m chatModel) skillSlashCompletions(input string) []chatCompletion {
if m.opts.Skills == nil || m.opts.Skills.Empty() {
return nil
}
prefix := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input)), "/")
var completions []chatCompletion
for _, skill := range m.opts.Skills.Skills {
if !strings.HasPrefix(skill.Name, prefix) {
continue
}
completions = append(completions, chatCompletion{
value: "/" + skill.Name,
label: "/" + skill.Name,
description: skill.Description,
})
}
return completions
}