Files
ollama_ollama/cmd/tui/chat/chat.go
T

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