1232 lines
33 KiB
Go
1232 lines
33 KiB
Go
package chat
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
coreagent "github.com/ollama/ollama/agent"
|
|
"github.com/ollama/ollama/api"
|
|
"github.com/ollama/ollama/cmd/internal/filedata"
|
|
)
|
|
|
|
var chatSpinnerFrames = []string{".", "..", "..."}
|
|
|
|
var chatRuntimeGOOS = runtime.GOOS
|
|
|
|
const (
|
|
maxPickerItems = 8
|
|
maxInlineModelPickerItems = 5
|
|
maxSlashCompletions = 5
|
|
maxPromptHistory = 50
|
|
idleWorkingDelayTicks = 4
|
|
)
|
|
|
|
var chatEmptyPrompts = []string{
|
|
`read this repo and tell me where to start`,
|
|
`what changed on this branch?`,
|
|
`run the tests and summarize failures`,
|
|
`find the riskiest code path in this folder`,
|
|
`search the web and compare the latest docs with this implementation`,
|
|
`summarize this file and suggest edits`,
|
|
}
|
|
|
|
type ModelOption struct {
|
|
Name string
|
|
Description string
|
|
Recommended bool
|
|
RequiredPlan string
|
|
Cloud bool
|
|
AvailabilityBadge string
|
|
SignInURL string
|
|
}
|
|
|
|
type Options struct {
|
|
Model string
|
|
OpenModelPicker bool
|
|
ChatID string
|
|
Messages []api.Message
|
|
Client coreagent.ChatClient
|
|
Tools *coreagent.Registry
|
|
Skills *coreagent.SkillCatalog
|
|
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
|
|
ToolsDisabled bool
|
|
MultiModalForModel func(context.Context, string) bool
|
|
ModelOptions func(context.Context) ([]ModelOption, error)
|
|
OnModelSelected func(context.Context, string) error
|
|
SystemPromptForModel func(context.Context, string, *coreagent.Registry, bool) string
|
|
ApprovalPrompter coreagent.ApprovalPrompter
|
|
EventSinks []coreagent.EventSink
|
|
AllowAllTools bool
|
|
WorkingDir string
|
|
RootDir string
|
|
Format string
|
|
Options map[string]any
|
|
Think *api.ThinkValue
|
|
KeepAlive *api.Duration
|
|
Images []api.ImageData
|
|
MultiModal bool
|
|
Compactor coreagent.Compactor
|
|
ContextWindowTokens int
|
|
ContextWindowTokensForModel func(context.Context, string, int) int
|
|
PreloadModel func(context.Context, string, *api.ThinkValue) (int, error)
|
|
CheckCloudModel func(context.Context, string, string) error
|
|
OpenBrowser func(string)
|
|
PollCloudAuth func(context.Context) (string, bool, error)
|
|
CompactionThreshold float64
|
|
SystemPrompt string
|
|
}
|
|
|
|
type Result struct {
|
|
ChatID string
|
|
Messages []api.Message
|
|
}
|
|
|
|
//nolint:containedctx // chatModel is a Bubble Tea session model; the context is the run-scoped cancellation root.
|
|
type chatModel struct {
|
|
ctx context.Context
|
|
opts Options
|
|
chatID string
|
|
messages []api.Message
|
|
liveMessages []api.Message
|
|
entries []chatEntry
|
|
workingDir string
|
|
|
|
input []rune
|
|
inputCursor int
|
|
inputCursorSet bool
|
|
inputAttachments []chatInputAttachment
|
|
inputPastedTexts []chatInputPastedText
|
|
nextImageID int
|
|
nextAudioID int
|
|
nextPastedTextID int
|
|
promptHistory []string
|
|
promptCursor int
|
|
promptDraft []rune
|
|
promptActive bool
|
|
running bool
|
|
awaitingModel bool
|
|
compacting bool
|
|
cancel context.CancelFunc
|
|
events <-chan tea.Msg
|
|
compactEvents <-chan tea.Msg
|
|
detectedToolCalls []chatEntry
|
|
scroll int
|
|
toolOutputMode bool
|
|
toolOutputOpen bool
|
|
flowPrintedLines int
|
|
thinking bool
|
|
thinkingTokens int
|
|
compactingTokens int
|
|
contextTokens int
|
|
contextEstimate bool
|
|
modelPicker *chatModelPicker
|
|
modelPickerModels []ModelOption
|
|
thinkPicker *chatThinkPicker
|
|
promptDebug *chatPromptDebug
|
|
approvalPrompt *chatApprovalPrompt
|
|
approvalController *chatApprovalController
|
|
approvalState *coreagent.ApprovalState
|
|
cloudAuthPrompt *cloudAuthPrompt
|
|
pendingModel string
|
|
defaultAllowAll bool
|
|
permissionNotice string
|
|
selection chatSelection
|
|
|
|
width int
|
|
height int
|
|
status string
|
|
spinner int
|
|
tickActive bool
|
|
preloadingModel string
|
|
complete int
|
|
quitting bool
|
|
openModelOnInit bool
|
|
quitArmed bool
|
|
quitArmedKey string
|
|
escArmed bool
|
|
eventErrorRendered bool
|
|
err error
|
|
}
|
|
|
|
type chatSelectionPoint struct {
|
|
line int
|
|
col int
|
|
}
|
|
|
|
type chatSelection struct {
|
|
active bool
|
|
dragging bool
|
|
anchor chatSelectionPoint
|
|
cursor chatSelectionPoint
|
|
}
|
|
|
|
func startChatSelection(selection *chatSelection, msg tea.MouseMsg, contains func(tea.MouseMsg) bool, point func(tea.MouseMsg) chatSelectionPoint) {
|
|
if !contains(msg) {
|
|
*selection = chatSelection{}
|
|
return
|
|
}
|
|
p := point(msg)
|
|
*selection = chatSelection{active: true, dragging: true, anchor: p, cursor: p}
|
|
}
|
|
|
|
func dragChatSelection(selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint, scrollEdge func(tea.MouseMsg)) {
|
|
if !selection.active || !selection.dragging {
|
|
return
|
|
}
|
|
selection.cursor = point(msg)
|
|
scrollEdge(msg)
|
|
}
|
|
|
|
func finishChatSelection(selection *chatSelection, msg tea.MouseMsg, point func(tea.MouseMsg) chatSelectionPoint) {
|
|
if !selection.active || !selection.dragging {
|
|
return
|
|
}
|
|
selection.cursor = point(msg)
|
|
selection.dragging = false
|
|
if selection.anchor == selection.cursor {
|
|
*selection = chatSelection{}
|
|
}
|
|
}
|
|
|
|
type chatInputAttachment struct {
|
|
placeholder string
|
|
kind string
|
|
data api.ImageData
|
|
}
|
|
|
|
func Run(ctx context.Context, opts Options) (*Result, error) {
|
|
if opts.RootDir == "" {
|
|
opts.RootDir = opts.WorkingDir
|
|
}
|
|
|
|
approvalState := &coreagent.ApprovalState{}
|
|
approvalState.Set(opts.AllowAllTools, nil)
|
|
|
|
m := chatModel{
|
|
ctx: ctx,
|
|
opts: opts,
|
|
chatID: opts.ChatID,
|
|
messages: slices.Clone(opts.Messages),
|
|
workingDir: opts.WorkingDir,
|
|
approvalState: approvalState,
|
|
defaultAllowAll: opts.AllowAllTools,
|
|
promptHistory: initialPromptHistory(ctx, opts),
|
|
status: "ready",
|
|
openModelOnInit: opts.OpenModelPicker || (strings.TrimSpace(opts.Model) == "" && opts.ModelOptions != nil),
|
|
}
|
|
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
|
|
m.nextPastedTextID = nextInputPastedTextIDFromMessages(m.messages)
|
|
m.entries = entriesFromMessages(m.messages)
|
|
// Context window is resolved post-load (chatModelPreloadDoneMsg) rather than
|
|
// here: for local models /api/ps only reports the running num_ctx after the
|
|
// model loads, and opts.ContextWindowTokens already holds Show's max as a
|
|
// pre-load fallback. Refreshing now would just re-derive that same value
|
|
// (and block construction on a network call).
|
|
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
|
m.contextEstimate = true
|
|
if m.openModelOnInit {
|
|
updated, cmd := m.openModelPicker("")
|
|
if cmd != nil {
|
|
return nil, errors.New("initial model picker returned an unexpected command")
|
|
}
|
|
m = updated.(chatModel)
|
|
}
|
|
if opts.PreloadModel != nil && strings.TrimSpace(opts.Model) != "" && !m.openModelOnInit {
|
|
m.preloadingModel = strings.TrimSpace(opts.Model)
|
|
}
|
|
|
|
p := tea.NewProgram(m, tea.WithReportFocus())
|
|
finalModel, err := p.Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fm := finalModel.(chatModel)
|
|
if fm.err != nil {
|
|
return nil, fm.err
|
|
}
|
|
return &Result{ChatID: fm.chatID, Messages: fm.messages}, nil
|
|
}
|
|
|
|
func (m chatModel) Init() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
if m.preloadingModel != "" && m.opts.PreloadModel != nil {
|
|
cmds = append(cmds, preloadModelCmd(m.ctx, m.opts.PreloadModel, m.preloadingModel, m.opts.Think), chatTickCmd())
|
|
}
|
|
if cmd := cloudModelPreflightCmd(m.ctx, m.opts, m.opts.Model, ""); cmd != nil && !m.openModelOnInit {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
if m.canEditInput() && isShiftEnterCSI(msg) {
|
|
m.insertInputNewline()
|
|
return m, nil
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
wasSet := m.width > 0 || m.height > 0
|
|
resized := wasSet && (m.width != msg.Width || m.height != msg.Height)
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
if resized {
|
|
return m.withFlowTranscriptRepaint(nil)
|
|
}
|
|
return m.withFlowTranscriptFlush(nil)
|
|
|
|
case tea.FocusMsg:
|
|
return m, nil
|
|
|
|
case chatTickMsg:
|
|
m.tickActive = false
|
|
if !m.running && !m.compacting && m.preloadingModel == "" {
|
|
return m, nil
|
|
}
|
|
m.spinner++
|
|
cmd := m.scheduleTick()
|
|
return m, cmd
|
|
|
|
case chatModelPreloadDoneMsg:
|
|
if msg.model != "" && msg.model != m.preloadingModel {
|
|
return m, nil
|
|
}
|
|
m.preloadingModel = ""
|
|
if msg.err != nil {
|
|
if isUnsupportedThinkingError(msg.err) && thinkRequestsThinking(m.opts.Think) {
|
|
m.opts.Think = &api.ThinkValue{Value: false}
|
|
m.status = fmt.Sprintf("Thinking disabled for %s", msg.model)
|
|
if msg.model != "" && m.opts.PreloadModel != nil {
|
|
m.preloadingModel = msg.model
|
|
return m, tea.Batch(preloadModelCmd(m.ctx, m.opts.PreloadModel, msg.model, m.opts.Think), m.scheduleTick())
|
|
}
|
|
return m, nil
|
|
}
|
|
if !errors.Is(msg.err, context.Canceled) {
|
|
m.status = "error"
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Could not load model: %v", msg.err), err: msg.err.Error()}))
|
|
}
|
|
return m, nil
|
|
}
|
|
if msg.contextWindowTokens > 0 {
|
|
m.updateContextWindowTokens(msg.contextWindowTokens)
|
|
} else {
|
|
m.refreshContextWindowTokens(msg.model)
|
|
}
|
|
return m, nil
|
|
|
|
case chatAgentMsg:
|
|
printedLines := m.flowPrintedLines
|
|
var printedTranscript []string
|
|
if printedLines > 0 {
|
|
printedTranscript = slices.Clone(m.transcriptLines(m.viewWidth()))
|
|
}
|
|
m.applyAgentEvent(msg.event)
|
|
return m.withFlowTranscriptRefreshAfter(printedTranscript, printedLines, waitForChatMsg(m.events))
|
|
|
|
case chatClipboardErrorMsg:
|
|
if msg.err != nil {
|
|
m.status = "copy failed: " + msg.err.Error()
|
|
}
|
|
return m, nil
|
|
|
|
case chatApprovalPromptMsg:
|
|
// Full access may have been enabled while this request was in flight
|
|
// (the user toggled it on after the agent sent the approval request
|
|
// but before this buffered message was handled). In that window
|
|
// togglePermissionMode's auto-resolve sees no open prompt yet, so
|
|
// short-circuit here rather than surfacing a stale approval prompt.
|
|
if m.allowAllToolsEnabled() {
|
|
m.approvalPrompt = &chatApprovalPrompt{request: msg.request, reply: msg.reply}
|
|
return m.resolveApprovalPrompt(chatApprovalChoice{allow: true, allowAll: true})
|
|
}
|
|
printedLines := m.flowPrintedLines
|
|
var printedTranscript []string
|
|
if printedLines > 0 {
|
|
printedTranscript = slices.Clone(m.transcriptLines(m.viewWidth()))
|
|
}
|
|
m.modelPicker = nil
|
|
m.modelPickerModels = nil
|
|
m.openApprovalPrompt(msg)
|
|
return m.withFlowTranscriptRefreshAfter(printedTranscript, printedLines, nil)
|
|
|
|
case chatRunDoneMsg:
|
|
wasCanceling := m.status == "canceling"
|
|
m.finishThinkingEntry()
|
|
m.running = false
|
|
m.awaitingModel = false
|
|
m.compacting = false
|
|
m.compactingTokens = 0
|
|
m.cancel = nil
|
|
m.events = nil
|
|
m.approvalController = nil
|
|
m.thinking = false
|
|
m.thinkingTokens = 0
|
|
m.approvalPrompt = nil
|
|
if msg.result != nil {
|
|
m.messages = msg.result.Messages
|
|
m.liveMessages = nil
|
|
if msg.result.WorkingDir != "" {
|
|
m.workingDir = msg.result.WorkingDir
|
|
}
|
|
// Context window is settled by preload (local num_ctx) or is
|
|
// static (cloud); no refresh needed post-run.
|
|
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
|
m.contextEstimate = true
|
|
if !messagesEndWithCompactionResult(m.messages) {
|
|
m.applyResponseMetrics(&msg.result.Latest)
|
|
}
|
|
}
|
|
if msg.result == nil {
|
|
m.finishLiveMessagesForStoppedRun(msg.newMessagesPersisted, msg.persistedMessages)
|
|
}
|
|
if wasCanceling || isChatContextCanceledError(msg.err) {
|
|
m.status = "Tell the model what to do instead."
|
|
return m.withFlowTranscriptFlush(nil)
|
|
}
|
|
if msg.err != nil {
|
|
if !m.eventErrorRendered {
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: msg.err.Error(), err: msg.err.Error()}))
|
|
}
|
|
m.status = "error"
|
|
return m.withFlowTranscriptFlush(nil)
|
|
}
|
|
m.status = "ready"
|
|
return m.withFlowTranscriptFlush(nil)
|
|
|
|
case chatCompactDoneMsg:
|
|
return m.finishManualCompaction(msg)
|
|
|
|
case chatCompactProgressMsg:
|
|
if msg.tokens > m.compactingTokens {
|
|
m.compactingTokens = msg.tokens
|
|
}
|
|
return m, waitForChatMsg(m.compactEvents)
|
|
|
|
case chatEventsClosedMsg:
|
|
if m.compacting {
|
|
return m.Update(chatCompactDoneMsg{err: context.Canceled})
|
|
}
|
|
if m.running {
|
|
return m.Update(chatRunDoneMsg{err: context.Canceled, newMessagesPersisted: true})
|
|
}
|
|
return m, nil
|
|
|
|
case cloudAuthTickMsg:
|
|
if m.cloudAuthPrompt != nil {
|
|
return m.updateCloudAuthPrompt(msg)
|
|
}
|
|
return m, nil
|
|
|
|
case cloudAuthCheckMsg:
|
|
if m.cloudAuthPrompt != nil {
|
|
return m.updateCloudAuthPrompt(msg)
|
|
}
|
|
return m, nil
|
|
|
|
case cloudModelPreflightMsg:
|
|
return m.updateCloudModelPreflight(msg)
|
|
|
|
case cloudAuthPollMsg:
|
|
if m.cloudAuthPrompt != nil {
|
|
return m.updateCloudAuthPrompt(msg)
|
|
}
|
|
return m, nil
|
|
|
|
case tea.MouseMsg:
|
|
return m.updateMouse(msg)
|
|
|
|
case tea.KeyMsg:
|
|
return m.updateKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
if m.promptDebug != nil {
|
|
switch msg.Type {
|
|
case tea.MouseWheelUp:
|
|
m.promptDebug.scroll = clamp(m.promptDebug.scroll-3, 0, m.promptDebugMaxScroll())
|
|
case tea.MouseWheelDown:
|
|
m.promptDebug.scroll = clamp(m.promptDebug.scroll+3, 0, m.promptDebugMaxScroll())
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.modelPicker != nil {
|
|
switch msg.Type {
|
|
case tea.MouseWheelUp:
|
|
m.modelPicker.Move(-3)
|
|
case tea.MouseWheelDown:
|
|
m.modelPicker.Move(3)
|
|
}
|
|
return m, nil
|
|
}
|
|
if !m.mouseInTranscript(msg) && !m.selection.active {
|
|
switch msg.Type {
|
|
case tea.MouseWheelUp:
|
|
m.scrollBy(3)
|
|
case tea.MouseWheelDown:
|
|
m.scrollBy(-3)
|
|
}
|
|
return m, nil
|
|
}
|
|
switch msg.Type {
|
|
case tea.MouseWheelUp:
|
|
m.scrollBy(3)
|
|
case tea.MouseWheelDown:
|
|
m.scrollBy(-3)
|
|
case tea.MouseLeft:
|
|
switch msg.Action {
|
|
case tea.MouseActionPress:
|
|
m.startTranscriptSelection(msg)
|
|
case tea.MouseActionMotion:
|
|
m.dragTranscriptSelection(msg)
|
|
default:
|
|
if msg.Action == 0 {
|
|
m.startTranscriptSelection(msg)
|
|
}
|
|
}
|
|
case tea.MouseMotion:
|
|
m.dragTranscriptSelection(msg)
|
|
case tea.MouseRelease:
|
|
return m, m.finishTranscriptSelection(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatModel) mouseInTranscript(msg tea.MouseMsg) bool {
|
|
top, height := m.transcriptLayout()
|
|
if msg.X < 0 || msg.X >= m.viewWidth() {
|
|
return false
|
|
}
|
|
return msg.Y >= top && msg.Y < top+height
|
|
}
|
|
|
|
func (m chatModel) mouseTranscriptPoint(msg tea.MouseMsg) chatSelectionPoint {
|
|
top, height := m.transcriptLayout()
|
|
visibleY := clamp(msg.Y-top, 0, max(0, height-1))
|
|
line := m.visibleTranscriptStartLine(m.viewWidth(), height) + visibleY
|
|
col := max(0, msg.X)
|
|
return chatSelectionPoint{line: line, col: col}
|
|
}
|
|
|
|
func (m *chatModel) startTranscriptSelection(msg tea.MouseMsg) {
|
|
startChatSelection(&m.selection, msg, m.mouseInTranscript, m.mouseTranscriptPoint)
|
|
}
|
|
|
|
func (m *chatModel) dragTranscriptSelection(msg tea.MouseMsg) {
|
|
dragChatSelection(&m.selection, msg, m.mouseTranscriptPoint, func(msg tea.MouseMsg) {
|
|
top, height := m.transcriptLayout()
|
|
if msg.Y <= top {
|
|
m.scrollBy(1)
|
|
} else if msg.Y >= top+height-1 {
|
|
m.scrollBy(-1)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (m *chatModel) finishTranscriptSelection(msg tea.MouseMsg) tea.Cmd {
|
|
finishChatSelection(&m.selection, msg, m.mouseTranscriptPoint)
|
|
if chatRuntimeGOOS != "windows" || !m.selection.active {
|
|
return nil
|
|
}
|
|
selected := m.selectedTranscriptText(m.viewWidth())
|
|
if strings.TrimSpace(selected) == "" {
|
|
m.selection = chatSelection{}
|
|
return nil
|
|
}
|
|
m.selection = chatSelection{}
|
|
m.status = "copied"
|
|
return copyTextCmd(m.ctx, selected)
|
|
}
|
|
|
|
func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.promptDebug != nil {
|
|
return m.updatePromptDebug(msg)
|
|
}
|
|
if msg.Type == tea.KeyCtrlO {
|
|
m.toggleInlineToolOutput()
|
|
m.disarmQuit()
|
|
m.disarmEsc()
|
|
return m.withFlowTranscriptRepaint(nil)
|
|
}
|
|
if msg.Type == tea.KeyShiftTab {
|
|
return m.togglePermissionMode()
|
|
}
|
|
if m.approvalPrompt != nil {
|
|
return m.updateApprovalPrompt(msg)
|
|
}
|
|
if m.cloudAuthPrompt != nil {
|
|
return m.updateCloudAuthPrompt(msg)
|
|
}
|
|
if m.modelPicker != nil {
|
|
return m.updateModelPicker(msg)
|
|
}
|
|
if m.thinkPicker != nil {
|
|
return m.updateThinkPicker(msg)
|
|
}
|
|
if msg.Type != tea.KeyCtrlC && msg.Type != tea.KeyCtrlD {
|
|
m.disarmQuit()
|
|
}
|
|
if msg.Type != tea.KeyEsc {
|
|
m.disarmEsc()
|
|
}
|
|
|
|
switch msg.Type {
|
|
case tea.KeyCtrlC:
|
|
return m.updateCtrlC()
|
|
case tea.KeyCtrlD:
|
|
return m.updateCtrlD()
|
|
case tea.KeyEsc:
|
|
return m.updateEsc()
|
|
case tea.KeyEnter:
|
|
if msg.Alt {
|
|
m.insertInputNewline()
|
|
return m, nil
|
|
}
|
|
if m.applySlashCompletion() {
|
|
return m, nil
|
|
}
|
|
return m.handleSubmit()
|
|
case tea.KeyCtrlJ:
|
|
m.insertInputNewline()
|
|
return m, nil
|
|
case tea.KeyCtrlG:
|
|
return m, nil
|
|
case tea.KeyCtrlP:
|
|
return m.updateUpKey()
|
|
case tea.KeyCtrlN:
|
|
return m.updateDownKey()
|
|
case tea.KeyUp:
|
|
return m.updateUpKey()
|
|
case tea.KeyDown:
|
|
return m.updateDownKey()
|
|
case tea.KeyLeft:
|
|
if msg.Alt {
|
|
m.moveInputCursorWord(-1)
|
|
} else {
|
|
m.moveInputCursorHorizontal(-1)
|
|
}
|
|
case tea.KeyRight:
|
|
if msg.Alt {
|
|
m.moveInputCursorWord(1)
|
|
} else {
|
|
m.moveInputCursorHorizontal(1)
|
|
}
|
|
case tea.KeyCtrlA:
|
|
m.moveInputCursorToLineStart()
|
|
case tea.KeyCtrlE:
|
|
m.moveInputCursorToLineEnd()
|
|
case tea.KeyCtrlB:
|
|
m.moveInputCursorHorizontal(-1)
|
|
case tea.KeyCtrlF:
|
|
m.moveInputCursorHorizontal(1)
|
|
case tea.KeyCtrlLeft:
|
|
m.moveInputCursorWord(-1)
|
|
case tea.KeyCtrlRight:
|
|
m.moveInputCursorWord(1)
|
|
case tea.KeyPgUp:
|
|
m.scrollBy(max(1, m.transcriptHeight()-1))
|
|
case tea.KeyPgDown:
|
|
m.scrollBy(-max(1, m.transcriptHeight()-1))
|
|
case tea.KeyHome:
|
|
m.moveInputCursorToLineStart()
|
|
case tea.KeyEnd:
|
|
m.moveInputCursorToLineEnd()
|
|
case tea.KeyCtrlHome:
|
|
m.scroll = m.maxScroll()
|
|
case tea.KeyCtrlEnd:
|
|
m.scroll = 0
|
|
case tea.KeyBackspace, tea.KeyCtrlH:
|
|
m.resetPromptHistoryCursor()
|
|
if msg.Alt {
|
|
m.deleteInputWordBackward()
|
|
} else {
|
|
m.deleteInputBackward()
|
|
}
|
|
case tea.KeyCtrlW:
|
|
m.resetPromptHistoryCursor()
|
|
m.deleteInputWordBackward()
|
|
case tea.KeyCtrlU:
|
|
m.resetPromptHistoryCursor()
|
|
m.clearInput()
|
|
case tea.KeyCtrlK:
|
|
m.resetPromptHistoryCursor()
|
|
m.deleteInputForward()
|
|
case tea.KeyDelete:
|
|
m.resetPromptHistoryCursor()
|
|
m.deleteInputForward()
|
|
case tea.KeyTab:
|
|
m.applyCompletion()
|
|
case tea.KeySpace:
|
|
m.insertInputRunes([]rune{' '})
|
|
case tea.KeyRunes:
|
|
if !msg.Alt || !m.handleInputAltRunes(msg.Runes) {
|
|
m.insertInputRunesFromKey(msg.Runes, msg.Paste)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *chatModel) toggleInlineToolOutput() {
|
|
m.toolOutputMode = true
|
|
m.toolOutputOpen = !m.toolOutputOpen
|
|
m.applyToolOutputMode()
|
|
m.selection = chatSelection{}
|
|
m.scroll = 0
|
|
}
|
|
|
|
func (m chatModel) updateCtrlC() (tea.Model, tea.Cmd) {
|
|
if len(m.input) > 0 {
|
|
m.clearInput()
|
|
m.resetPromptHistoryCursor()
|
|
m.disarmQuit()
|
|
m.status = "ready"
|
|
return m, nil
|
|
}
|
|
if (m.running || m.compacting) && m.cancel != nil {
|
|
m.cancel()
|
|
m.disarmQuit()
|
|
m.status = "canceling"
|
|
return m, nil
|
|
}
|
|
if !m.quitArmed || m.quitArmedKey != "ctrl+c" {
|
|
m.armQuit("ctrl+c", "press ctrl+c again to quit")
|
|
return m, nil
|
|
}
|
|
m.quitting = true
|
|
return m, m.quitCmd()
|
|
}
|
|
|
|
func (m chatModel) updateCtrlD() (tea.Model, tea.Cmd) {
|
|
if len(m.input) > 0 {
|
|
return m, nil
|
|
}
|
|
if !m.quitArmed || m.quitArmedKey != "ctrl+d" {
|
|
m.armQuit("ctrl+d", "press ctrl+d again to quit")
|
|
return m, nil
|
|
}
|
|
m.quitting = true
|
|
if (m.running || m.compacting) && m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
return m, m.quitCmd()
|
|
}
|
|
|
|
func (m chatModel) quitCmd() tea.Cmd {
|
|
return tea.Quit
|
|
}
|
|
|
|
func (m *chatModel) armQuit(key, status string) {
|
|
m.quitArmed = true
|
|
m.quitArmedKey = key
|
|
m.status = status
|
|
}
|
|
|
|
func (m chatModel) updateEsc() (tea.Model, tea.Cmd) {
|
|
if (m.running || m.compacting) && m.cancel != nil {
|
|
m.cancel()
|
|
m.disarmQuit()
|
|
m.escArmed = false
|
|
m.status = "canceling"
|
|
return m, nil
|
|
}
|
|
if !m.escArmed {
|
|
m.escArmed = true
|
|
switch {
|
|
case len(m.input) > 0:
|
|
m.status = "press esc again to clear input"
|
|
default:
|
|
m.status = "ready"
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
m.escArmed = false
|
|
cleared := false
|
|
if len(m.input) > 0 {
|
|
m.clearInput()
|
|
m.resetPromptHistoryCursor()
|
|
cleared = true
|
|
}
|
|
if cleared {
|
|
m.status = "ready"
|
|
} else {
|
|
m.status = "ready"
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *chatModel) clearInput() {
|
|
m.input = nil
|
|
m.inputCursor = 0
|
|
m.inputCursorSet = false
|
|
m.inputAttachments = nil
|
|
m.inputPastedTexts = nil
|
|
m.complete = 0
|
|
}
|
|
|
|
func (m chatModel) updateUpKey() (tea.Model, tea.Cmd) {
|
|
if m.promptActive && m.movePromptHistory(-1) {
|
|
return m, nil
|
|
}
|
|
if m.moveCompletion(-1) {
|
|
return m, nil
|
|
}
|
|
if m.moveInputCursorVertical(-1) {
|
|
return m, nil
|
|
}
|
|
if slices.Contains(m.input, '\n') {
|
|
return m, nil
|
|
}
|
|
m.movePromptHistory(-1)
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatModel) updateDownKey() (tea.Model, tea.Cmd) {
|
|
if m.promptActive && m.movePromptHistory(1) {
|
|
return m, nil
|
|
}
|
|
if m.moveCompletion(1) {
|
|
return m, nil
|
|
}
|
|
if m.moveInputCursorVertical(1) {
|
|
return m, nil
|
|
}
|
|
if slices.Contains(m.input, '\n') {
|
|
return m, nil
|
|
}
|
|
m.movePromptHistory(1)
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatModel) View() string {
|
|
if m.quitting {
|
|
return ""
|
|
}
|
|
|
|
width, height := m.viewSize()
|
|
|
|
if m.promptDebug != nil {
|
|
return m.renderPromptDebug(width, height)
|
|
}
|
|
if m.modelPicker != nil && m.openModelOnInit {
|
|
return m.renderModelPicker(width)
|
|
}
|
|
if m.cloudAuthPrompt != nil {
|
|
return m.renderCloudAuthPrompt(width)
|
|
}
|
|
if m.thinkPicker != nil {
|
|
return m.renderThinkPicker(width)
|
|
}
|
|
return m.flowView(width)
|
|
}
|
|
|
|
func (m chatModel) flowView(width int) string {
|
|
allTranscriptLines := m.transcriptLines(width)
|
|
bottomLines := m.bottomLines(width, 0)
|
|
bottomGap := transcriptInputGap(0, len(bottomLines), len(allTranscriptLines))
|
|
|
|
printed := clamp(m.flowPrintedLines, 0, len(allTranscriptLines))
|
|
lines := slices.Clone(allTranscriptLines[printed:])
|
|
for range bottomGap {
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, bottomLines...)
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m chatModel) withFlowTranscriptFlush(cmd tea.Cmd) (tea.Model, tea.Cmd) {
|
|
next, printCmd := m.flowTranscriptFlushCmd()
|
|
return next, tea.Batch(printCmd, cmd)
|
|
}
|
|
|
|
func (m chatModel) withFlowTranscriptRepaint(cmd tea.Cmd) (tea.Model, tea.Cmd) {
|
|
if m.flowPrintedLines == 0 {
|
|
return m.withFlowTranscriptFlush(cmd)
|
|
}
|
|
m.flowPrintedLines = 0
|
|
next, printCmd := m.flowTranscriptFlushCmd()
|
|
return next, tea.Sequence(tea.ClearScreen, printCmd, cmd)
|
|
}
|
|
|
|
func (m chatModel) withFlowTranscriptRefreshAfter(before []string, printed int, cmd tea.Cmd) (tea.Model, tea.Cmd) {
|
|
next, printCmd := m.flowTranscriptRefreshAfterCmd(before, printed)
|
|
return next, tea.Batch(printCmd, cmd)
|
|
}
|
|
|
|
func (m chatModel) flowTranscriptRefreshAfterCmd(before []string, printed int) (chatModel, tea.Cmd) {
|
|
width := m.viewWidth()
|
|
after := m.transcriptLines(width)
|
|
start := flowTranscriptChangedPrefixStart(before, after, printed)
|
|
if start < 0 {
|
|
return m.flowTranscriptFlushCmd()
|
|
}
|
|
|
|
printed = clamp(printed, 0, len(before))
|
|
start = clamp(start, 0, len(after))
|
|
rewind := printed - start
|
|
if rewind <= 0 {
|
|
return m.flowTranscriptFlushCmd()
|
|
}
|
|
|
|
flushCount := m.flowTranscriptFlushCount(after, width)
|
|
flushCount = clamp(flushCount, start, len(after))
|
|
rewriteLines := slices.Clone(after[start:flushCount])
|
|
m.flowPrintedLines = flushCount
|
|
return m, tea.Printf("%s", flowTranscriptRewriteSequence(rewind, rewriteLines))
|
|
}
|
|
|
|
func flowTranscriptChangedPrefixStart(before, after []string, printed int) int {
|
|
printed = clamp(printed, 0, len(before))
|
|
if printed == 0 {
|
|
return -1
|
|
}
|
|
limit := min(printed, len(after))
|
|
for i := range limit {
|
|
if before[i] != after[i] {
|
|
return i
|
|
}
|
|
}
|
|
if len(after) < printed {
|
|
return len(after)
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func flowTranscriptRewriteSequence(rewind int, lines []string) string {
|
|
var b strings.Builder
|
|
if rewind > 0 {
|
|
fmt.Fprintf(&b, "\x1b[%dA", rewind)
|
|
}
|
|
b.WriteString("\r\x1b[J")
|
|
b.WriteString(strings.Join(lines, "\n"))
|
|
return b.String()
|
|
}
|
|
|
|
func (m chatModel) flowTranscriptFlushCmd() (chatModel, tea.Cmd) {
|
|
if m.promptDebug != nil || m.modelPicker != nil || m.thinkPicker != nil || m.cloudAuthPrompt != nil {
|
|
return m, nil
|
|
}
|
|
width := m.viewWidth()
|
|
lines := m.transcriptLines(width)
|
|
if len(lines) == 0 {
|
|
m.flowPrintedLines = 0
|
|
return m, nil
|
|
}
|
|
m.flowPrintedLines = clamp(m.flowPrintedLines, 0, len(lines))
|
|
flushCount := m.flowTranscriptFlushCount(lines, width)
|
|
if flushCount <= m.flowPrintedLines {
|
|
return m, nil
|
|
}
|
|
pending := strings.Join(lines[m.flowPrintedLines:flushCount], "\n")
|
|
m.flowPrintedLines = flushCount
|
|
return m, tea.Println(pending)
|
|
}
|
|
|
|
func (m chatModel) flowTranscriptFlushCount(lines []string, width int) int {
|
|
holdFrom := m.flowTranscriptHoldEntryIndex()
|
|
if holdFrom < 0 {
|
|
return len(lines)
|
|
}
|
|
clone := m
|
|
clone.entries = slices.Clone(m.entries[:holdFrom])
|
|
clone.selection = chatSelection{}
|
|
return len(clone.transcriptLines(width))
|
|
}
|
|
|
|
func (m chatModel) flowTranscriptHoldEntryIndex() int {
|
|
if !m.running && !m.compacting {
|
|
return -1
|
|
}
|
|
if len(m.entries) == 0 {
|
|
return -1
|
|
}
|
|
index := len(m.entries) - 1
|
|
entry := m.entries[index]
|
|
switch entry.role {
|
|
case "assistant":
|
|
if entry.content != "" {
|
|
return index
|
|
}
|
|
case "tool":
|
|
if isToolActiveStatus(entry.status) {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (m chatModel) emptyChatHint() string {
|
|
if len(chatEmptyPrompts) == 0 {
|
|
return "Try asking the agent to inspect files, run tools, or explain a repo."
|
|
}
|
|
h := fnv.New32a()
|
|
_, _ = h.Write([]byte(m.chatID))
|
|
prompt := chatEmptyPrompts[int(h.Sum32())%len(chatEmptyPrompts)]
|
|
return prompt
|
|
}
|
|
|
|
func (m chatModel) headerLines() []string {
|
|
return nil
|
|
}
|
|
|
|
func (m *chatModel) resetChat(status string) (tea.Model, tea.Cmd) {
|
|
m.messages = nil
|
|
m.liveMessages = nil
|
|
m.entries = nil
|
|
m.inputAttachments = nil
|
|
m.inputPastedTexts = nil
|
|
m.modelPicker = nil
|
|
m.modelPickerModels = nil
|
|
m.approvalController = nil
|
|
m.resetApprovalState()
|
|
m.nextImageID = 0
|
|
m.nextAudioID = 0
|
|
m.nextPastedTextID = 1
|
|
m.resetPromptHistoryCursor()
|
|
m.resetWorkingDir()
|
|
m.opts.AllowAllTools = m.defaultAllowAll
|
|
m.permissionNotice = ""
|
|
m.thinking = false
|
|
m.thinkingTokens = 0
|
|
m.contextTokens = 0
|
|
m.contextEstimate = true
|
|
m.scroll = 0
|
|
m.flowPrintedLines = 0
|
|
m.status = status
|
|
return *m, tea.ClearScreen
|
|
}
|
|
|
|
func (m *chatModel) resetWorkingDir() {
|
|
if m.opts.RootDir != "" {
|
|
m.workingDir = m.opts.RootDir
|
|
return
|
|
}
|
|
m.workingDir = m.opts.WorkingDir
|
|
}
|
|
|
|
func (m *chatModel) startRun(input string) (tea.Model, tea.Cmd) {
|
|
displayInput, message, err := m.userMessageFromInput(input, input)
|
|
if err != nil {
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: err.Error(), err: err.Error()}))
|
|
m.status = "error"
|
|
return *m, nil
|
|
}
|
|
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "", "")
|
|
}
|
|
|
|
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
|
|
content := m.expandPastedTextPlaceholders(userInput)
|
|
images := slices.Clone(m.opts.Images)
|
|
preloaded := len(images)
|
|
placeholderAttachments := m.activeInputAttachmentsFor(userInput)
|
|
for _, attachment := range placeholderAttachments {
|
|
images = append(images, attachment.data)
|
|
}
|
|
extracted := 0
|
|
|
|
if m.opts.MultiModal {
|
|
cleaned, files, err := filedata.ExtractWithFiles(content)
|
|
if err != nil {
|
|
return "", api.Message{}, err
|
|
}
|
|
content = cleaned
|
|
extracted = len(files)
|
|
for _, file := range files {
|
|
images = append(images, file.Data)
|
|
}
|
|
}
|
|
m.opts.Images = nil
|
|
m.inputAttachments = nil
|
|
m.inputPastedTexts = nil
|
|
|
|
if len(images) > 0 {
|
|
base := displayInput
|
|
if extracted > 0 {
|
|
base = content
|
|
}
|
|
if len(placeholderAttachments) > 0 && extracted == 0 && preloaded == 0 {
|
|
displayInput = strings.TrimSpace(content)
|
|
} else {
|
|
displayInput = chatDisplayInputWithAttachments(base, len(images))
|
|
}
|
|
}
|
|
|
|
return displayInput, api.Message{Role: "user", Content: content, Images: images}, nil
|
|
}
|
|
|
|
func chatDisplayInputWithAttachments(input string, count int) string {
|
|
note := fmt.Sprintf("[attached %d file%s]", count, pluralSuffix(count))
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return note
|
|
}
|
|
return input + "\n\n" + note
|
|
}
|
|
|
|
func pluralSuffix(count int) string {
|
|
if count == 1 {
|
|
return ""
|
|
}
|
|
return "s"
|
|
}
|
|
|
|
func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
|
|
name = strings.TrimSpace(name)
|
|
prompt = strings.TrimSpace(prompt)
|
|
// The skill instructions are delivered via the synthetic tool result that
|
|
// follows this user turn, so this message orients the model rather than
|
|
// re-requesting the skill. When a prompt is supplied it becomes the task.
|
|
content := prompt
|
|
if content == "" {
|
|
content = "The " + name + " skill is loaded; follow its instructions for this request."
|
|
}
|
|
message := api.Message{Role: "user", Content: content}
|
|
displayInput := "/" + name
|
|
if prompt != "" {
|
|
displayInput = "/" + name + " " + prompt
|
|
}
|
|
return m.startRunWithMessages(displayInput, "", []api.Message{message}, "", name)
|
|
}
|
|
|
|
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
|
|
m.addPromptHistory(historyInput)
|
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
|
|
if len(newMessages) > 1 {
|
|
m.entries = append(m.entries, entriesFromMessages(newMessages[1:])...)
|
|
}
|
|
m.running = true
|
|
m.awaitingModel = true
|
|
m.status = "running"
|
|
m.spinner = 0
|
|
m.scroll = 0
|
|
m.detectedToolCalls = nil
|
|
m.thinking = false
|
|
m.thinkingTokens = 0
|
|
m.eventErrorRendered = false
|
|
systemPrompt := m.systemPrompt(extraSystemPrompt)
|
|
m.liveMessages = append(slices.Clone(m.messages), newMessages...)
|
|
m.contextTokens = m.estimatePromptTokens(m.liveMessages, systemPrompt)
|
|
m.contextEstimate = true
|
|
|
|
runCtx, cancel := context.WithCancel(m.ctx)
|
|
m.cancel = cancel
|
|
events := make(chan tea.Msg, 128)
|
|
m.events = events
|
|
m.approvalController = newChatApprovalController(events, m.ensureApprovalState())
|
|
|
|
var newMessagesPersisted bool
|
|
eventSinks := []coreagent.EventSink{chatEventSink{ctx: runCtx, ch: events, newMessagesPersisted: &newMessagesPersisted}}
|
|
eventSinks = append(eventSinks, m.opts.EventSinks...)
|
|
|
|
session := &coreagent.Session{
|
|
Client: m.opts.Client,
|
|
EventSinks: eventSinks,
|
|
Tools: m.opts.Tools,
|
|
Skills: m.opts.Skills,
|
|
DisableTools: m.opts.ToolsDisabled,
|
|
ApprovalPrompter: m.approvalPrompterForRun(m.approvalController),
|
|
ApprovalState: m.ensureApprovalState(),
|
|
WorkingDir: m.currentWorkingDir(),
|
|
Compactor: m.opts.Compactor,
|
|
}
|
|
opts := coreagent.RunOptions{
|
|
ChatID: m.chatID,
|
|
Model: m.opts.Model,
|
|
SystemPrompt: systemPrompt,
|
|
Messages: slices.Clone(m.messages),
|
|
NewMessages: slices.Clone(newMessages),
|
|
Format: m.opts.Format,
|
|
Options: m.opts.Options,
|
|
Think: m.opts.Think,
|
|
KeepAlive: m.opts.KeepAlive,
|
|
SkillName: skillName,
|
|
}
|
|
|
|
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
|
|
persistedMessages = append(persistedMessages, slices.Clone(m.messages)...)
|
|
persistedMessages = append(persistedMessages, slices.Clone(newMessages)...)
|
|
go func() {
|
|
defer close(events)
|
|
result, err := session.Run(runCtx, opts)
|
|
select {
|
|
case events <- chatRunDoneMsg{result: result, err: err, newMessagesPersisted: newMessagesPersisted, persistedMessages: persistedMessages}:
|
|
case <-runCtx.Done():
|
|
}
|
|
}()
|
|
|
|
tickCmd := m.scheduleTick()
|
|
flushModel, flushCmd := m.flowTranscriptFlushCmd()
|
|
*m = flushModel
|
|
return *m, tea.Batch(flushCmd, waitForChatMsg(events), tickCmd)
|
|
}
|
|
|
|
func (m *chatModel) finishLiveMessagesForStoppedRun(promote bool, persistedMessages []api.Message) {
|
|
if len(m.liveMessages) == 0 {
|
|
return
|
|
}
|
|
if promote {
|
|
if len(persistedMessages) > 0 {
|
|
m.messages = slices.Clone(persistedMessages)
|
|
} else if !messagesHavePendingToolCalls(m.liveMessages) {
|
|
m.messages = slices.Clone(m.liveMessages)
|
|
}
|
|
}
|
|
m.liveMessages = nil
|
|
m.contextTokens = m.estimatePromptTokens(m.messages, "")
|
|
m.contextEstimate = true
|
|
}
|
|
|
|
func messagesHavePendingToolCalls(messages []api.Message) bool {
|
|
pending := map[string]struct{}{}
|
|
for _, msg := range messages {
|
|
if msg.Role == "assistant" {
|
|
for _, call := range msg.ToolCalls {
|
|
pending[call.ID] = struct{}{}
|
|
}
|
|
}
|
|
if msg.Role == "tool" && msg.ToolCallID != "" {
|
|
delete(pending, msg.ToolCallID)
|
|
}
|
|
}
|
|
return len(pending) > 0
|
|
}
|
|
|
|
func (m *chatModel) disarmQuit() {
|
|
if !m.quitArmed {
|
|
return
|
|
}
|
|
m.quitArmed = false
|
|
m.quitArmedKey = ""
|
|
if strings.HasPrefix(m.status, "press ctrl+") && strings.HasSuffix(m.status, "again to quit") {
|
|
m.status = "ready"
|
|
}
|
|
}
|
|
|
|
func (m *chatModel) disarmEsc() {
|
|
if !m.escArmed {
|
|
return
|
|
}
|
|
m.escArmed = false
|
|
if strings.HasPrefix(m.status, "press esc again") {
|
|
m.status = "ready"
|
|
}
|
|
}
|
|
|
|
func (m chatModel) canEditInput() bool {
|
|
return m.promptDebug == nil && m.approvalPrompt == nil && m.cloudAuthPrompt == nil && m.modelPicker == nil && m.thinkPicker == nil
|
|
}
|
|
|
|
func isChatContextCanceledError(err error) bool {
|
|
return err != nil && (errors.Is(err, context.Canceled) || strings.Contains(err.Error(), "context canceled"))
|
|
}
|