ai-agent/internal/tui/model.go
admin 8dc496b626
Some checks failed
CI / test (push) Has been cancelled
Release / release (push) Failing after 4m36s
first commit
2026-03-08 15:40:34 +07:00

2035 lines
66 KiB
Go
Raw 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 tui
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"ai-agent/internal/agent"
"ai-agent/internal/command"
"ai-agent/internal/config"
"ai-agent/internal/llm"
"ai-agent/internal/permission"
"ai-agent/internal/skill"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
"github.com/charmbracelet/log"
)
type State int
const (
StateIdle State = iota
StateWaiting
StateStreaming
)
type OverlayKind int
const (
OverlayNone OverlayKind = iota
OverlayHelp
OverlayCompletion
OverlayModelPicker
OverlayPlanForm
OverlaySessionsPicker
)
type CompletionState struct {
Kind string
CurrentPath string
Filter textinput.Model
AllItems []Completion
FilteredItems []Completion
Selected map[int]bool
SearchResults []Completion
Index int
DebounceTag int
Searching bool
}
type ToolStatus int
const (
ToolStatusRunning ToolStatus = iota
ToolStatusDone
ToolStatusError
)
type ToolEntry struct {
Name string
Args string
RawArgs map[string]any
Result string
IsError bool
Status ToolStatus
StartTime time.Time
Duration time.Duration
Collapsed bool
BeforeContent string
DiffLines []DiffLine
}
type ChatEntry struct {
Kind string
Content string
RenderedContent string
Name string
IsError bool
ToolIndex int
ThinkingContent string
ThinkingCollapsed bool
}
type startupItem struct {
ID string
Label string
Status string
Detail string
}
type Model struct {
viewport viewport.Model
input textarea.Model
spin spinner.Model
scramble ScrambleModel
styles Styles
md *MarkdownRenderer
keys KeyMap
state State
overlay OverlayKind
entries []ChatEntry
streamBuf strings.Builder
width int
height int
ready bool
isDark bool
evalCount int
promptTokens int
toolsPending int
inputLines int
userScrolledUp bool
scrollAnchor int
anchorActive bool
lastContentHeight int
initializing bool
startupItems []startupItem
initCancel context.CancelFunc
completionState *CompletionState
attachments []string
toolEntries []ToolEntry
toolsCollapsed bool
toolEntryRows map[int]int
toolCardMgr ToolCardManager
cachedEntriesRender string
cachedEntryCount int
cachedToolEntryRows map[int]int
entryCacheValid bool
thinkBuf strings.Builder
inThinking bool
thinkSearchBuf string
doneFlash bool
sessionNoteID int
sessionsPickerState *SessionsPickerState
pendingPaste string
isCompact bool
isWide bool
forceCompact bool
mode Mode
modeConfigs [3]ModeConfig
modelManager *llm.ModelManager
router *config.Router
modelPickerState *ModelPickerState
planFormState *PlanFormState
logger *log.Logger
agent *agent.Agent
cmdRegistry *command.Registry
skillMgr *skill.Manager
completer *Completer
loadedFile string
program *tea.Program
cancel context.CancelFunc
model string
modelList []string
agentProfile string
agentList []string
toolCount int
serverCount int
numCtx int
toastMgr *ToastManager
toastStyles ToastStyles
failedServers []FailedServer
iceEnabled bool
iceConversations int
iceSessionID string
sessionEvalTotal int
sessionPromptTotal int
sessionTurnCount int
fileChanges map[string]int
pendingApproval *ToolApprovalMsg
promptHistory []string
promptHistoryPath string
historyIndex int
historySaved string
welcomeModel WelcomeModel
sidePanel SidePanelModel
logoModel LogoModel
helpViewport viewport.Model
searchState *SearchState
progressTracker *ProgressTracker
resizer *PanelResizer
contextMenu *ContextMenuState
timestampConfig TimestampConfig
timestampHelper *TimestampHelper
keyHints *KeyHints
accessibility *AccessibilityHelper
tableHelper *TableHelper
lang Lang
}
func New(ag *agent.Agent, cmdReg *command.Registry, skillMgr *skill.Manager, completer *Completer, modelManager *llm.ModelManager, router *config.Router, logger *log.Logger) *Model {
initialLang := LoadLang()
loc := Locale(initialLang)
ta := textarea.New()
ta.Placeholder = loc.Placeholder
ta.Focus()
ta.CharLimit = 4096
ta.SetHeight(1)
ta.ShowLineNumbers = false
ta.Prompt = " "
styles := textarea.DefaultDarkStyles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
styles.Blurred.Base = lipgloss.NewStyle()
ta.SetStyles(styles)
styles.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0"))
ta.SetStyles(styles)
s := spinner.New(
spinner.WithSpinner(spinner.MiniDot),
spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#88c0d0"))),
)
return &Model{
input: ta,
spin: s,
scramble: NewScrambleModel(true),
welcomeModel: NewWelcomeModel(true),
sidePanel: NewSidePanelModel(true),
logoModel: NewLogoModel(true),
styles: NewStyles(true),
keys: DefaultKeyMap(),
state: StateIdle,
isDark: true,
inputLines: 1,
toolsCollapsed: true,
initializing: true,
mode: ModeAsk,
modeConfigs: DefaultModeConfigs(),
modelManager: modelManager,
router: router,
logger: logger,
agent: ag,
cmdRegistry: cmdReg,
skillMgr: skillMgr,
completer: completer,
historyIndex: -1,
promptHistory: loadPromptHistoryForModel(DefaultPromptHistoryPath()),
promptHistoryPath: DefaultPromptHistoryPath(),
toastMgr: NewToastManager(),
toastStyles: DefaultToastStyles(true),
toolCardMgr: NewToolCardManager(true),
searchState: NewSearchState(),
progressTracker: NewProgressTracker(true),
resizer: NewPanelResizer(20, 60, true),
contextMenu: &ContextMenuState{Active: false},
timestampConfig: DefaultTimestampConfig(),
timestampHelper: NewTimestampHelper(DefaultTimestampConfig(), true),
keyHints: DefaultKeyHints(initialLang, true),
accessibility: NewAccessibilityHelper(true),
tableHelper: NewTableHelper(true),
lang: initialLang,
}
}
func (m *Model) tr() L { return Locale(m.lang) }
func (m *Model) SetProgram(p *tea.Program) {
m.program = p
}
func (m *Model) SetInitCancel(cancel context.CancelFunc) {
m.initCancel = cancel
}
func (m *Model) renderStartup(b *strings.Builder) {
m.renderWelcome(b)
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(
textarea.Blink,
tea.RequestBackgroundColor,
m.spin.Tick,
func() tea.Msg {
return spinnerTickMsg{}
},
)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.isDark = msg.IsDark()
m.styles = NewStyles(m.isDark)
m.spin.Style = m.styles.StatusDot
m.scramble.SetDark(msg.IsDark())
m.toastStyles = DefaultToastStyles(m.isDark)
m.toastMgr.SetStyles(m.toastStyles)
m.toolCardMgr.SetDark(msg.IsDark())
m.progressTracker.SetDark(msg.IsDark())
m.resizer.SetDark(msg.IsDark())
m.timestampHelper.SetDark(msg.IsDark())
m.keyHints.SetDark(msg.IsDark())
m.accessibility.SetDark(msg.IsDark())
m.tableHelper.SetDark(msg.IsDark())
if m.width > 0 {
m.md = NewMarkdownRenderer(m.width-2, m.isDark)
m.invalidateRenderedCache()
}
if m.ready {
m.viewport.SetContent(m.renderEntries())
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.isCompact = msg.Width < 80 || msg.Height < 24
m.isWide = msg.Width > 120
panelWidth := 30
if msg.Width < 100 {
panelWidth = 25
} else if msg.Width > 160 {
panelWidth = 40
}
viewportWidth := msg.Width - 1
if m.sidePanel.IsVisible() {
viewportWidth = msg.Width - panelWidth - 2
}
if viewportWidth < 20 {
viewportWidth = 20
}
contentWidth := viewportWidth - 6
if contentWidth < 14 {
contentWidth = 14
}
m.md = NewMarkdownRenderer(contentWidth, m.isDark)
m.sidePanel.SetWidth(panelWidth)
m.sidePanel.SetHeight(msg.Height - 2)
contentH := msg.Height - 1 - m.footerHeight()
if contentH < 1 {
contentH = 1
}
if !m.ready {
m.viewport = viewport.New(
viewport.WithWidth(viewportWidth),
viewport.WithHeight(contentH),
)
m.viewport.KeyMap.PageDown = key.NewBinding(key.WithKeys("pgdown"))
m.viewport.KeyMap.PageUp = key.NewBinding(key.WithKeys("pgup"))
m.viewport.KeyMap.HalfPageUp = key.NewBinding(key.WithKeys("ctrl+u"))
m.viewport.KeyMap.HalfPageDown = key.NewBinding(key.WithKeys("ctrl+d"))
m.viewport.KeyMap.Up = key.NewBinding(key.WithDisabled())
m.viewport.KeyMap.Down = key.NewBinding(key.WithDisabled())
m.viewport.KeyMap.Left = key.NewBinding(key.WithDisabled())
m.viewport.KeyMap.Right = key.NewBinding(key.WithDisabled())
m.viewport.SetContent(m.renderEntries())
m.ready = true
m.scrollAnchor = 0
m.anchorActive = true
m.lastContentHeight = 0
m.toolEntryRows = make(map[int]int, 8)
} else {
m.viewport.SetWidth(viewportWidth)
m.viewport.SetHeight(contentH)
widthDelta := abs(m.width - msg.Width)
if widthDelta > 5 {
m.invalidateRenderedCache()
}
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
}
if m.overlay == OverlayHelp {
m.initHelpViewport()
}
m.input.SetWidth(viewportWidth)
m.syncInputHeight()
case tea.KeyPressMsg:
if m.initializing {
if key.Matches(msg, m.keys.Quit) {
if m.initCancel != nil {
m.initCancel()
}
return m, tea.Quit
}
return m, nil
}
if m.pendingApproval != nil {
switch msg.String() {
case "y":
m.pendingApproval.Response <- ToolApprovalResponse{Allowed: true}
m.pendingApproval = nil
case "n":
m.pendingApproval.Response <- ToolApprovalResponse{Allowed: false}
m.pendingApproval = nil
case "a":
m.pendingApproval.Response <- ToolApprovalResponse{Allowed: true, Always: true}
m.pendingApproval = nil
}
return m, nil
}
if m.pendingPaste != "" {
switch {
case msg.String() == "y":
m.input.InsertString("```\n" + m.pendingPaste + "\n```")
m.pendingPaste = ""
m.syncInputHeight()
case msg.String() == "n":
m.input.InsertString(m.pendingPaste)
m.pendingPaste = ""
m.syncInputHeight()
case key.Matches(msg, m.keys.Cancel):
m.pendingPaste = ""
}
return m, nil
}
if m.overlay != OverlayNone {
if key.Matches(msg, m.keys.Cancel) {
switch m.overlay {
case OverlayCompletion:
m.input.SetValue("")
m.closeCompletion()
case OverlayModelPicker:
m.closeModelPicker()
case OverlayPlanForm:
m.closePlanForm()
case OverlaySessionsPicker:
if m.sessionsPickerState != nil && m.sessionsPickerState.List.FilterState() == list.Filtering {
var cmd tea.Cmd
m.sessionsPickerState.List, cmd = m.sessionsPickerState.List.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
m.closeSessionsPicker()
default:
m.overlay = OverlayNone
m.input.Focus()
}
return m, nil
}
if m.overlay == OverlayHelp {
switch msg.String() {
case "?", "q":
m.overlay = OverlayNone
m.input.Focus()
case "j", "down":
m.helpViewport.ScrollDown(1)
case "k", "up":
m.helpViewport.ScrollUp(1)
case "pgdown":
m.helpViewport.PageDown()
case "pgup":
m.helpViewport.PageUp()
case "d":
m.helpViewport.HalfPageDown()
case "u":
m.helpViewport.HalfPageUp()
case "g":
m.helpViewport.GotoTop()
case "G":
m.helpViewport.GotoBottom()
}
return m, nil
}
if m.overlay == OverlayModelPicker && m.modelPickerState != nil {
if key.Matches(msg, m.keys.CompleteSelect) {
if item := m.modelPickerState.List.SelectedItem(); item != nil {
mi := item.(modelItem)
m.selectModel(mi.name)
}
} else {
var cmd tea.Cmd
m.modelPickerState.List, cmd = m.modelPickerState.List.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
if m.overlay == OverlayPlanForm && m.planFormState != nil {
submitted, cancelled := m.updatePlanForm(msg)
if cancelled {
m.closePlanForm()
return m, nil
}
if submitted {
prompt := m.planFormState.AssemblePrompt()
m.closePlanForm()
return m, m.submitPlanFormPrompt(prompt)
}
return m, nil
}
if m.overlay == OverlaySessionsPicker && m.sessionsPickerState != nil {
if key.Matches(msg, m.keys.CompleteSelect) {
if item := m.sessionsPickerState.List.SelectedItem(); item != nil {
si := item.(sessionItem)
sessionID := si.id
sessionTitle := si.title
m.closeSessionsPicker()
return m, func() tea.Msg {
note, err := loadSession(sessionID)
if err != nil {
return SessionLoadedMsg{Err: err}
}
entries := deserializeEntries(note.Content)
return SessionLoadedMsg{Entries: entries, Title: sessionTitle}
}
}
} else {
var cmd tea.Cmd
m.sessionsPickerState.List, cmd = m.sessionsPickerState.List.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
if m.overlay == OverlayCompletion && m.isCompletionActive() {
cs := m.completionState
switch {
case key.Matches(msg, m.keys.CompleteUp):
if cs.Index > 0 {
cs.Index--
}
case key.Matches(msg, m.keys.CompleteDown):
if cs.Index < len(cs.FilteredItems)-1 {
cs.Index++
}
case key.Matches(msg, m.keys.CompleteSelect):
if cs.Index < len(cs.FilteredItems) && cs.Kind == "attachments" && cs.FilteredItems[cs.Index].Category == "folder" {
m.drillIntoFolder()
} else {
m.acceptCompletion()
}
case key.Matches(msg, m.keys.CompleteToggle):
m.toggleCompletionSelection()
default:
if msg.Code == tea.KeyBackspace && cs.Filter.Value() == "" && cs.Kind == "attachments" && cs.CurrentPath != "" {
m.drillUpFolder()
return m, nil
}
oldFilter := cs.Filter.Value()
var cmd tea.Cmd
cs.Filter, cmd = cs.Filter.Update(msg)
if cs.Filter.Value() != oldFilter {
cs.FilteredItems = FilterCompletions(cs.AllItems, cs.Filter.Value())
cs.Index = 0
if cs.Kind == "attachments" && cs.Filter.Value() != "" {
cs.DebounceTag++
tag := cs.DebounceTag
query := cs.Filter.Value()
return m, tea.Batch(cmd, tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg {
return CompletionDebounceTickMsg{Tag: tag, Query: query}
}))
}
}
return m, cmd
}
return m, nil
}
return m, nil
}
switch {
case key.Matches(msg, m.keys.Quit):
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
case key.Matches(msg, m.keys.Cancel):
if (m.state == StateStreaming || m.state == StateWaiting) && m.cancel != nil {
m.cancel()
}
case key.Matches(msg, m.keys.Help):
if m.state == StateIdle && strings.TrimSpace(m.input.Value()) == "" {
m.overlay = OverlayHelp
m.initHelpViewport()
m.input.Blur()
return m, nil
}
case key.Matches(msg, m.keys.ToggleTools):
if m.state == StateIdle && strings.TrimSpace(m.input.Value()) == "" {
m.toolsCollapsed = !m.toolsCollapsed
for i := range m.toolEntries {
m.toolEntries[i].Collapsed = m.toolsCollapsed
}
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
return m, nil
}
case key.Matches(msg, m.keys.ToggleFocusedTool):
if m.state == StateIdle && strings.TrimSpace(m.input.Value()) == "" {
if len(m.toolEntries) > 0 {
last := len(m.toolEntries) - 1
m.toolEntries[last].Collapsed = !m.toolEntries[last].Collapsed
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
}
return m, nil
}
case key.Matches(msg, m.keys.CompactToggle):
if m.state == StateIdle {
m.forceCompact = !m.forceCompact
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
return m, nil
}
case key.Matches(msg, m.keys.ToggleThinking):
if m.state == StateIdle && strings.TrimSpace(m.input.Value()) == "" {
for i := len(m.entries) - 1; i >= 0; i-- {
if m.entries[i].Kind == "assistant" && m.entries[i].ThinkingContent != "" {
m.entries[i].ThinkingCollapsed = !m.entries[i].ThinkingCollapsed
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
break
}
}
return m, nil
}
case key.Matches(msg, m.keys.ExternalEditor):
if m.state == StateIdle {
return m, m.openExternalEditor()
}
case key.Matches(msg, m.keys.CopyLast):
if m.state == StateIdle && strings.TrimSpace(m.input.Value()) == "" {
if content := m.lastAssistantContent(); content != "" {
return m, m.copyToClipboard(content)
}
}
case key.Matches(msg, m.keys.ClearView):
if m.state == StateIdle {
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return m, nil
}
case key.Matches(msg, m.keys.NewConvo):
if m.state == StateIdle {
m.agent.ClearHistory()
m.entries = nil
m.toolEntries = nil
m.sessionEvalTotal = 0
m.sessionPromptTotal = 0
m.sessionTurnCount = 0
m.fileChanges = nil
m.invalidateEntryCache()
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: "New conversation started.",
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return m, nil
}
case key.Matches(msg, m.keys.CycleMode):
if m.state == StateIdle {
m.cycleMode()
return m, nil
}
case key.Matches(msg, m.keys.LanguageCycle):
if m.state == StateIdle {
m.lang = NextLang(m.lang)
_ = SaveLang(m.lang)
m.input.Placeholder = m.tr().Placeholder
m.keyHints.SetHints(defaultHintsForLang(m.lang))
if m.toastMgr != nil {
m.toastMgr.AddToast(Toast{Message: fmt.Sprintf(m.tr().LanguageSet, LangName(m.lang)), Kind: ToastKindInfo})
}
m.sidePanel.UpdateSections(m.lang, m.model, m.modelList, m.serverCount, m.toolCount, m.iceEnabled, m.iceConversations)
return m, nil
}
case key.Matches(msg, m.keys.ModelPicker):
if m.state == StateIdle {
m.openModelPicker()
return m, nil
}
case key.Matches(msg, m.keys.NewLine):
if m.state == StateIdle {
m.input.InsertString("\n")
m.syncInputHeight()
return m, nil
}
case key.Matches(msg, m.keys.Send):
if m.state == StateIdle {
return m, m.submitInput()
}
case key.Matches(msg, m.keys.Complete):
if m.state == StateIdle && m.completer != nil && !m.isCompletionActive() {
m.triggerCompletion(m.input.Value())
}
case key.Matches(msg, m.keys.HistoryUp):
if m.state == StateIdle && m.overlay == OverlayNone {
if strings.TrimSpace(m.input.Value()) == "" || m.historyIndex != -1 {
if m.navigateHistory(-1) {
return m, nil
}
}
}
case key.Matches(msg, m.keys.HistoryDown):
if m.state == StateIdle && m.overlay == OverlayNone {
if m.historyIndex != -1 {
if m.navigateHistory(1) {
return m, nil
}
}
}
case key.Matches(msg, m.keys.ToggleSidePanel):
if m.state == StateIdle {
m.sidePanel.Toggle()
panelWidth := 30
if m.width < 100 {
panelWidth = 25
}
contentWidth := m.width - 1
if m.sidePanel.IsVisible() {
m.sidePanel.SetWidth(panelWidth)
m.sidePanel.SetHeight(m.height - 2)
contentWidth = m.width - panelWidth - 2
}
if contentWidth < 20 {
contentWidth = 20
}
m.viewport.SetWidth(contentWidth)
m.input.SetWidth(contentWidth)
m.invalidateRenderedCache()
m.viewport.SetContent(m.renderEntries())
return m, nil
}
}
case StreamTextMsg:
if m.state == StateWaiting {
m.state = StateStreaming
}
mainText, thinkText, outInThinking, outSearchBuf := processStreamChunk(
msg.Text, m.inThinking, m.thinkSearchBuf,
)
m.inThinking = outInThinking
m.thinkSearchBuf = outSearchBuf
if mainText != "" {
m.streamBuf.WriteString(mainText)
}
if thinkText != "" {
m.thinkBuf.WriteString(thinkText)
}
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
case StreamDoneMsg:
m.evalCount = msg.EvalCount
m.promptTokens = msg.PromptTokens
m.sessionEvalTotal += msg.EvalCount
m.sessionPromptTotal += msg.PromptTokens
m.sessionTurnCount++
case ToolCallStartMsg:
te := ToolEntry{
Name: msg.Name,
Args: FormatToolArgs(msg.Args),
RawArgs: msg.Args,
Status: ToolStatusRunning,
StartTime: msg.StartTime,
Collapsed: m.toolsCollapsed,
}
if classifyTool(msg.Name) == ToolTypeFileWrite {
te.BeforeContent = readFileForDiff(msg.Args)
}
m.toolEntries = append(m.toolEntries, te)
m.toolsPending++
kind := ToolCardGeneric
switch classifyTool(msg.Name) {
case ToolTypeFileRead, ToolTypeFileWrite:
kind = ToolCardFile
case ToolTypeBash:
kind = ToolCardBash
default:
kind = ToolCardGeneric
}
m.toolCardMgr.AddCard(msg.Name, kind, msg.StartTime)
m.entries = append(m.entries, ChatEntry{
Kind: "tool_group",
ToolIndex: len(m.toolEntries) - 1,
})
m.flushStream()
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
case PlanFormCompletedMsg:
return m, m.submitPlanFormPrompt(msg.Prompt)
case ToolCallResultMsg:
m.invalidateEntryCache()
if m.logger != nil {
m.logger.Info("tool call", "name", msg.Name, "duration", msg.Duration, "error", msg.IsError)
}
for i := len(m.toolEntries) - 1; i >= 0; i-- {
if m.toolEntries[i].Name == msg.Name && m.toolEntries[i].Status == ToolStatusRunning {
result := msg.Result
if len(result) > 2000 {
result = result[:1997] + "..."
}
m.toolEntries[i].Result = result
m.toolEntries[i].IsError = msg.IsError
m.toolEntries[i].Duration = msg.Duration
if msg.IsError {
m.toolEntries[i].Status = ToolStatusError
} else {
m.toolEntries[i].Status = ToolStatusDone
}
if classifyTool(m.toolEntries[i].Name) == ToolTypeFileWrite && !msg.IsError {
afterContent := readFileForDiff(m.toolEntries[i].RawArgs)
m.toolEntries[i].DiffLines = computeDiff(m.toolEntries[i].BeforeContent, afterContent)
if path := toolSummary(ToolTypeFileWrite, m.toolEntries[i]); path != "" {
if m.fileChanges == nil {
m.fileChanges = make(map[string]int)
}
m.fileChanges[path]++
}
}
break
}
}
cardState := ToolCardSuccess
if msg.IsError {
cardState = ToolCardError
}
m.toolCardMgr.UpdateCard(msg.Name, cardState, msg.Result, msg.Duration)
if m.toolsPending > 0 {
m.toolsPending--
}
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
case SystemMessageMsg:
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: msg.Msg,
})
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
case ErrorMsg:
if m.logger != nil {
m.logger.Error("error", "msg", msg.Msg)
}
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: msg.Msg,
})
m.viewport.SetContent(m.renderEntries())
if m.anchorActive {
m.viewport.GotoBottom()
}
case AgentDoneMsg:
if m.logger != nil {
m.logger.Info("agent done", "eval_tokens", m.evalCount)
}
m.flushStream()
m.state = StateIdle
m.userScrolledUp = false
m.anchorActive = true
m.scrollAnchor = 0
m.input.Focus()
m.input.SetHeight(1)
m.inputLines = 1
m.recalcViewportHeight()
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
m.doneFlash = true
cmds = append(cmds, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return DoneFlashExpiredMsg{}
}))
if m.sessionNoteID > 0 {
id := m.sessionNoteID
content := serializeEntries(m.entries)
cmds = append(cmds, func() tea.Msg {
_ = updateSessionNote(id, content)
return nil
})
}
case StartupStatusMsg:
found := false
for i, item := range m.startupItems {
if item.ID == msg.ID {
m.startupItems[i].Status = msg.Status
m.startupItems[i].Detail = msg.Detail
found = true
break
}
}
if !found {
m.startupItems = append(m.startupItems, startupItem{
ID: msg.ID, Label: msg.Label, Status: msg.Status, Detail: msg.Detail,
})
}
sidePanelItems := make([]StartupItem, len(m.startupItems))
for i, item := range m.startupItems {
sidePanelItems[i] = StartupItem{
Label: item.Label,
Status: item.Status,
Detail: item.Detail,
}
}
m.sidePanel.SetStartupItems(sidePanelItems)
m.sidePanel.SetSpinnerTick()
if m.ready {
m.viewport.SetContent(m.renderEntries())
}
return m, tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg {
return spinnerTickMsg{}
})
case spinnerTickMsg:
if m.initializing {
m.sidePanel.Tick()
return m, tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg {
return spinnerTickMsg{}
})
}
if m.toolsPending > 0 {
m.toolCardMgr.Tick()
return m, tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg {
return spinnerTickMsg{}
})
}
case InitCompleteMsg:
m.model = msg.Model
m.modelList = msg.ModelList
m.agentProfile = msg.AgentProfile
m.agentList = msg.AgentList
m.toolCount = msg.ToolCount
m.serverCount = msg.ServerCount
m.numCtx = msg.NumCtx
m.failedServers = msg.FailedServers
m.iceEnabled = msg.ICEEnabled
m.iceConversations = msg.ICEConversations
m.iceSessionID = msg.ICESessionID
if m.completer != nil {
m.completer.UpdateModels(msg.ModelList)
m.completer.UpdateAgents(msg.AgentList)
}
if len(msg.FailedServers) > 0 {
var parts []string
for _, fs := range msg.FailedServers {
parts = append(parts, fs.Name+" ("+fs.Reason+")")
}
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: "Failed to connect: " + strings.Join(parts, ", "),
})
}
m.initializing = false
m.startupItems = nil
m.sidePanel.UpdateSections(
m.lang,
m.model,
m.modelList,
m.serverCount,
m.toolCount,
m.iceEnabled,
m.iceConversations,
)
m.logoModel.Start()
m.viewport.SetContent(m.renderEntries())
case CommandResultMsg:
if msg.Text != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: msg.Text,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
}
case CompletionDebounceTickMsg:
if m.isCompletionActive() && m.completionState.DebounceTag == msg.Tag {
cs := m.completionState
cs.Searching = true
query := msg.Query
tag := msg.Tag
return m, func() tea.Msg {
results := m.completer.SearchFiles(context.Background(), query)
return CompletionSearchResultMsg{Tag: tag, Results: results}
}
}
case CompletionSearchResultMsg:
if m.isCompletionActive() && m.completionState.DebounceTag == msg.Tag {
cs := m.completionState
cs.Searching = false
cs.SearchResults = msg.Results
existing := make(map[string]bool)
for _, item := range cs.AllItems {
existing[item.Insert] = true
}
for _, result := range msg.Results {
if !existing[result.Insert] {
cs.AllItems = append(cs.AllItems, result)
}
}
cs.FilteredItems = FilterCompletions(cs.AllItems, cs.Filter.Value())
}
case ToolApprovalMsg:
m.pendingApproval = &msg
case CommitResultMsg:
if msg.Err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("Commit failed: %v", msg.Err),
})
} else {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: fmt.Sprintf("Committed with message:\n%s", msg.Message),
})
}
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
case editorReturnMsg:
m.input.SetValue(msg.Content)
m.input.CursorEnd()
m.syncInputHeight()
m.input.Focus()
case DoneFlashExpiredMsg:
m.doneFlash = false
case SessionCreatedMsg:
if msg.Err == nil && msg.NoteID > 0 {
m.sessionNoteID = msg.NoteID
}
case SessionListMsg:
if msg.Err != nil {
m.entries = append(m.entries, ChatEntry{Kind: "error", Content: fmt.Sprintf("Sessions: %v", msg.Err)})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
} else if len(msg.Sessions) == 0 {
m.entries = append(m.entries, ChatEntry{Kind: "system", Content: "No saved sessions found."})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
} else {
m.sessionsPickerState = newSessionsPickerState(msg.Sessions, m.width, m.isDark)
m.overlay = OverlaySessionsPicker
m.input.Blur()
}
case SessionLoadedMsg:
m.invalidateEntryCache()
if msg.Err != nil {
m.entries = append(m.entries, ChatEntry{Kind: "error", Content: fmt.Sprintf("Load session: %v", msg.Err)})
} else {
m.entries = msg.Entries
m.entries = append([]ChatEntry{{Kind: "system", Content: fmt.Sprintf("Restored session: %s", msg.Title)}}, m.entries...)
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
case tea.MouseWheelMsg:
wasAtBottom := m.viewport.AtBottom()
m.viewport, _ = m.viewport.Update(msg)
if msg.Button == tea.MouseWheelUp && wasAtBottom {
m.anchorActive = false
m.userScrolledUp = true
m.scrollAnchor = 5
} else if m.viewport.AtBottom() {
m.anchorActive = true
m.userScrolledUp = false
m.scrollAnchor = 0
}
case tea.MouseClickMsg:
if msg.Button == tea.MouseLeft {
m.handleMouseClick(msg.X, msg.Y)
}
case tea.PasteMsg:
lines := strings.Count(msg.Content, "\n") + 1
if lines > 10 && m.state == StateIdle {
m.pendingPaste = msg.Content
} else if m.state == StateIdle {
m.input.InsertString(msg.Content)
m.syncInputHeight()
}
}
if _, ok := msg.(ScrambleTickMsg); ok {
var cmd tea.Cmd
m.scramble, cmd = m.scramble.Update(msg)
cmds = append(cmds, cmd)
}
if !m.logoModel.IsDone() {
var cmd tea.Cmd
m.logoModel, cmd = m.logoModel.Update(msg)
cmds = append(cmds, cmd)
}
{
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
cmds = append(cmds, cmd)
}
if m.state == StateIdle && m.overlay == OverlayNone && !m.initializing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.syncInputHeight()
newInput := m.input.Value()
if m.completer != nil && len(newInput) > 0 {
first := newInput[0]
if (first == '/' || first == '@' || first == '#') && !m.isCompletionActive() {
m.triggerCompletion(newInput)
}
}
if m.isCompletionActive() && (len(newInput) == 0 || (newInput[0] != '/' && newInput[0] != '@' && newInput[0] != '#')) {
m.closeCompletion()
}
}
wasAtBottom := m.viewport.AtBottom()
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
if m.state == StateStreaming && wasAtBottom && !m.viewport.AtBottom() {
m.userScrolledUp = true
}
m.checkAutoScroll()
return m, tea.Batch(cmds...)
}
func loadPromptHistoryForModel(path string) []string {
if path == "" {
return nil
}
list, err := LoadPromptHistory(path)
if err != nil || len(list) == 0 {
return nil
}
return list
}
func (m *Model) pushHistory(text string) {
if text == "" {
return
}
if len(m.promptHistory) > 0 && m.promptHistory[len(m.promptHistory)-1] == text {
return
}
m.promptHistory = append(m.promptHistory, text)
if len(m.promptHistory) > promptHistoryMax {
m.promptHistory = m.promptHistory[len(m.promptHistory)-promptHistoryMax:]
}
m.historyIndex = -1
if m.promptHistoryPath != "" {
_ = SavePromptHistory(m.promptHistoryPath, m.promptHistory)
}
}
func (m *Model) navigateHistory(dir int) bool {
if len(m.promptHistory) == 0 {
return false
}
if dir == -1 {
if m.historyIndex == -1 {
m.historySaved = m.input.Value()
m.historyIndex = len(m.promptHistory) - 1
} else if m.historyIndex > 0 {
m.historyIndex--
} else {
return false
}
m.input.SetValue(m.promptHistory[m.historyIndex])
m.input.CursorEnd()
return true
}
if dir == 1 {
if m.historyIndex == -1 {
return false
}
if m.historyIndex < len(m.promptHistory)-1 {
m.historyIndex++
m.input.SetValue(m.promptHistory[m.historyIndex])
m.input.CursorEnd()
} else {
m.historyIndex = -1
m.input.SetValue(m.historySaved)
m.input.CursorEnd()
}
return true
}
return false
}
func (m *Model) submitInput() tea.Cmd {
text := strings.TrimSpace(m.input.Value())
if text == "" {
return nil
}
m.pushHistory(text)
m.input.Reset()
m.input.SetHeight(1)
if strings.HasPrefix(text, "/") {
parts := strings.Fields(text)
name := strings.TrimPrefix(parts[0], "/")
args := parts[1:]
ctx := m.buildCommandContext()
result := m.cmdRegistry.Execute(ctx, name, args)
if result.Error != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: result.Error,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
return m.handleCommandAction(result)
}
if m.mode == ModePlan {
m.openPlanForm(text)
return nil
}
return m.sendToAgent(text)
}
func (m *Model) buildCommandContext() *command.Context {
ctx := &command.Context{
Model: m.model,
ModelList: m.modelList,
AgentProfile: m.agentProfile,
AgentList: m.agentList,
ToolCount: m.toolCount,
ServerCount: m.serverCount,
ServerNames: m.agent.ServerNames(),
LoadedFile: m.loadedFile,
ICEEnabled: m.iceEnabled,
ICEConversations: m.iceConversations,
ICESessionID: m.iceSessionID,
SessionEvalTotal: m.sessionEvalTotal,
SessionPromptTotal: m.sessionPromptTotal,
SessionTurnCount: m.sessionTurnCount,
NumCtx: m.numCtx,
CurrentModel: m.model,
FileChanges: m.fileChanges,
}
if m.skillMgr != nil {
for _, s := range m.skillMgr.All() {
ctx.Skills = append(ctx.Skills, command.SkillInfo{
Name: s.Name,
Description: s.Description,
Active: s.Active,
})
}
}
return ctx
}
func (m *Model) handleCommandAction(result command.Result) tea.Cmd {
switch result.Action {
case command.ActionShowHelp:
m.overlay = OverlayHelp
m.initHelpViewport()
return nil
case command.ActionClear:
m.agent.ClearHistory()
m.entries = nil
m.toolEntries = nil
m.invalidateEntryCache()
if result.Text != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionQuit:
if m.cancel != nil {
m.cancel()
}
return tea.Quit
case command.ActionLoadContext:
parts := strings.SplitN(result.Data, "\x00", 2)
if len(parts) == 2 {
m.loadedFile = parts[0]
m.agent.SetLoadedContext(parts[1])
}
if result.Text != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionUnloadContext:
m.loadedFile = ""
m.agent.SetLoadedContext("")
if result.Text != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionActivateSkill:
if m.skillMgr != nil {
if err := m.skillMgr.Activate(result.Data); err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: err.Error(),
})
} else {
m.agent.SetSkillContent(m.skillMgr.ActiveContent())
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
}
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionDeactivateSkill:
if m.skillMgr != nil {
if err := m.skillMgr.Deactivate(result.Data); err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: err.Error(),
})
} else {
m.agent.SetSkillContent(m.skillMgr.ActiveContent())
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
}
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionSwitchModel:
query := ""
currentInput := strings.TrimSpace(m.input.Value())
if currentInput != "" && !strings.HasPrefix(currentInput, "/") {
query = currentInput
} else {
for i := len(m.entries) - 1; i >= 0; i-- {
if m.entries[i].Kind == "user" {
query = m.entries[i].Content
break
}
}
}
if m.router != nil && query != "" {
m.router.RecordOverride(query, result.Data)
}
if m.modelManager != nil {
if err := m.modelManager.SetCurrentModel(result.Data); err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("Failed to switch model: %v", err),
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
}
if m.logger != nil {
m.logger.Info("model switched", "from", m.model, "to", result.Data)
}
m.model = result.Data
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionShowModelPicker:
m.openModelPicker()
return nil
case command.ActionSendPrompt:
if result.Text != "" {
m.entries = append(m.entries, ChatEntry{Kind: "system", Content: result.Text})
}
return m.sendToAgent(result.Data)
case command.ActionCommit:
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: "Generating commit message from staged changes...",
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return runCommit(m.agent.LLMClient(), m.model, result.Data)
case command.ActionShowSessions:
return func() tea.Msg {
sessions, err := listSessions(20)
return SessionListMsg{Sessions: sessions, Err: err}
}
case command.ActionSwitchAgent:
m.agentProfile = result.Data
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionExport:
path := result.Data
if path == "" {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: "export: no path specified",
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
content := m.formatConversationForExport()
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("export failed: %v", err),
})
} else {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: fmt.Sprintf("Exported conversation to: %s", path),
})
}
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
case command.ActionImport:
path := result.Data
if path == "" {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: "import: no path specified",
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
data, err := os.ReadFile(path)
if err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("import failed: %v", err),
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
entries, err := m.parseImportedConversation(string(data))
if err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("import parse error: %v", err),
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
}
m.entries = entries
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
return nil
default:
if result.Text != "" {
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: result.Text,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
}
return nil
}
}
func (m *Model) flushStream() {
m.invalidateEntryCache()
if m.streamBuf.Len() > 0 || m.thinkBuf.Len() > 0 {
content := m.streamBuf.String()
var rendered string
if m.md != nil && content != "" {
rendered = m.md.RenderFull(content)
}
entry := ChatEntry{
Kind: "assistant",
Content: content,
RenderedContent: rendered,
}
if m.thinkBuf.Len() > 0 {
entry.ThinkingContent = m.thinkBuf.String()
entry.ThinkingCollapsed = true
}
m.entries = append(m.entries, entry)
m.streamBuf.Reset()
m.thinkBuf.Reset()
m.inThinking = false
m.thinkSearchBuf = ""
}
}
func (m *Model) invalidateRenderedCache() {
for i := range m.entries {
if m.entries[i].Kind == "assistant" && m.entries[i].RenderedContent != "" {
if m.md != nil {
m.entries[i].RenderedContent = m.md.RenderFull(m.entries[i].Content)
}
}
}
m.invalidateEntryCache()
}
func (m *Model) footerHeight() int {
if m.state == StateIdle {
return 2 + m.inputLines
}
return 3
}
func (m *Model) syncInputHeight() {
lines := m.input.LineCount()
if lines < 1 {
lines = 1
}
if lines > 5 {
lines = 5
}
if lines != m.inputLines {
m.inputLines = lines
m.input.SetHeight(lines)
m.recalcViewportHeight()
}
}
func (m *Model) invalidateEntryCache() {
m.entryCacheValid = false
m.cachedEntriesRender = ""
m.cachedEntryCount = 0
m.cachedToolEntryRows = nil
}
func (m *Model) checkAutoScroll() {
if m.viewport.AtBottom() {
m.anchorActive = true
m.userScrolledUp = false
m.scrollAnchor = 0
}
}
func (m *Model) getVisibleEntryRange() (start, end int) {
if m.viewport.YOffset() == 0 {
end = len(m.entries)
start = max(0, end-100)
return start, end
}
avgEntryHeight := 5
viewportH := m.viewport.Height()
visibleEntries := viewportH / avgEntryHeight
buffer := visibleEntries / 2
scrollPos := m.viewport.YOffset()
estimatedStart := max(0, scrollPos/avgEntryHeight - buffer)
estimatedEnd := min(len(m.entries), estimatedStart + visibleEntries + buffer*2)
return estimatedStart, estimatedEnd
}
func (m *Model) openExternalEditor() tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
}
tmpFile, err := os.CreateTemp("", "ai-agent-*.md")
if err != nil {
return func() tea.Msg {
return ErrorMsg{Msg: fmt.Sprintf("editor: %v", err)}
}
}
tmpPath := tmpFile.Name()
if current := m.input.Value(); current != "" {
tmpFile.WriteString(current)
}
tmpFile.Close()
c := exec.Command(editor, tmpPath)
return tea.ExecProcess(c, func(err error) tea.Msg {
defer os.Remove(tmpPath)
if err != nil {
return ErrorMsg{Msg: fmt.Sprintf("editor: %v", err)}
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return ErrorMsg{Msg: fmt.Sprintf("editor: %v", err)}
}
content := strings.TrimRight(string(data), "\n")
if content == "" {
return nil
}
return editorReturnMsg{Content: content}
})
}
type editorReturnMsg struct {
Content string
}
func (m *Model) recalcViewportHeight() {
if !m.ready || m.height == 0 {
return
}
headerH := 3
contentH := m.height - headerH - m.footerHeight()
if contentH < 1 {
contentH = 1
}
m.viewport.SetHeight(contentH)
}
func FormatToolArgs(args map[string]any) string {
return agent.FormatToolArgs(args)
}
func (m *Model) isCompletionActive() bool {
return m.completionState != nil
}
func newCompletionState(kind string, items []Completion, multiSelect bool) *CompletionState {
ti := textinput.New()
ti.Placeholder = "type to filter..."
ti.Focus()
ti.CharLimit = 128
var sel map[int]bool
if multiSelect {
sel = make(map[int]bool)
}
return &CompletionState{
Kind: kind,
Filter: ti,
AllItems: items,
FilteredItems: items,
Index: 0,
Selected: sel,
}
}
func (m *Model) triggerCompletion(input string) {
var kind string
var items []Completion
var multiSelect bool
if strings.HasPrefix(input, "/") {
kind = "command"
items = m.completer.Complete(input)
} else if strings.HasPrefix(input, "@") {
kind = "attachments"
items = m.completer.Complete(input)
multiSelect = true
} else if strings.HasPrefix(input, "#") {
kind = "skills"
items = m.completer.Complete(input)
multiSelect = true
}
if len(items) == 0 {
return
}
m.completionState = newCompletionState(kind, items, multiSelect)
m.overlay = OverlayCompletion
m.input.Blur()
}
func (m *Model) acceptCompletion() {
cs := m.completionState
if cs == nil || len(cs.FilteredItems) == 0 {
return
}
isMultiSelect := cs.Kind == "attachments" || cs.Kind == "skills"
if isMultiSelect {
var selectedItems []string
for idx := range cs.Selected {
if idx < len(cs.AllItems) {
selectedItems = append(selectedItems, cs.AllItems[idx].Insert)
}
}
if len(selectedItems) == 0 && cs.Index < len(cs.FilteredItems) {
selectedItems = append(selectedItems, cs.FilteredItems[cs.Index].Insert)
}
m.input.SetValue(strings.Join(selectedItems, " "))
m.input.CursorEnd()
} else {
item := cs.FilteredItems[cs.Index]
m.input.SetValue(item.Insert)
m.input.CursorEnd()
}
m.closeCompletion()
}
func (m *Model) toggleCompletionSelection() {
cs := m.completionState
if cs == nil || cs.Selected == nil || len(cs.FilteredItems) == 0 {
return
}
filteredItem := cs.FilteredItems[cs.Index]
for i, item := range cs.AllItems {
if item.Label == filteredItem.Label && item.Insert == filteredItem.Insert {
if cs.Selected[i] {
delete(cs.Selected, i)
} else {
cs.Selected[i] = true
}
break
}
}
}
func (m *Model) drillIntoFolder() {
cs := m.completionState
if cs == nil || cs.Index >= len(cs.FilteredItems) {
return
}
item := cs.FilteredItems[cs.Index]
folderName := strings.TrimSuffix(item.Label, "/")
if cs.CurrentPath != "" {
cs.CurrentPath += "/" + folderName
} else {
cs.CurrentPath = folderName
}
fileItems := m.completer.CompleteFilePath(cs.CurrentPath)
cs.AllItems = fileItems
cs.Filter.SetValue("")
cs.FilteredItems = fileItems
cs.Index = 0
cs.SearchResults = nil
}
func (m *Model) drillUpFolder() {
cs := m.completionState
if cs == nil || cs.CurrentPath == "" {
return
}
if idx := strings.LastIndex(cs.CurrentPath, "/"); idx >= 0 {
cs.CurrentPath = cs.CurrentPath[:idx]
} else {
cs.CurrentPath = ""
}
var items []Completion
if cs.CurrentPath == "" {
items = m.completer.Complete("@")
} else {
items = m.completer.CompleteFilePath(cs.CurrentPath)
}
cs.AllItems = items
cs.Filter.SetValue("")
cs.FilteredItems = items
cs.Index = 0
cs.SearchResults = nil
}
func (m *Model) closeCompletion() {
m.completionState = nil
m.overlay = OverlayNone
m.input.Focus()
}
func (m *Model) sendToAgent(text string) tea.Cmd {
if m.logger != nil {
cfg := m.modeConfigs[m.mode]
m.logger.Info("user message", "mode", cfg.Label, "length", len(text))
}
m.input.Blur()
m.state = StateWaiting
m.recalcViewportHeight()
m.streamBuf.Reset()
m.evalCount = 0
m.promptTokens = 0
m.entries = append(m.entries, ChatEntry{
Kind: "user",
Content: text,
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
m.agent.AddUserMessage(text)
cfg := m.modeConfigs[m.mode]
m.agent.SetModeContext(cfg.SystemPromptPrefix, cfg.AllowTools)
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
p := m.program
m.agent.SetApprovalCallback(func(req permission.ApprovalRequest) {
respCh := make(chan ToolApprovalResponse, 1)
p.Send(ToolApprovalMsg{
ToolName: req.ToolName,
Args: req.Args,
Response: respCh,
})
resp := <-respCh
req.Response <- permission.ApprovalResponse{
Allowed: resp.Allowed,
Always: resp.Always,
}
})
runAgent := func() tea.Msg {
adapter := NewAdapter(p)
m.agent.Run(ctx, adapter)
return AgentDoneMsg{}
}
m.scramble.Reset()
batchCmds := []tea.Cmd{m.spin.Tick, m.scramble.Tick(), runAgent}
if m.sessionNoteID == 0 && notedAvailable() {
batchCmds = append(batchCmds, func() tea.Msg {
ts := time.Now().Format("2006-01-02 15:04")
id, err := createSessionNote(ts)
return SessionCreatedMsg{NoteID: id, Err: err}
})
}
return tea.Batch(batchCmds...)
}
func (m *Model) cycleMode() {
m.mode = (m.mode + 1) % 3
cfg := m.modeConfigs[m.mode]
if m.router != nil {
newModel := m.router.GetModelForCapability(cfg.PreferredCapability)
if newModel != "" && newModel != m.model {
if m.modelManager != nil {
if err := m.modelManager.SetCurrentModel(newModel); err == nil {
m.model = newModel
}
}
}
}
if m.logger != nil {
m.logger.Info("mode switched", "mode", cfg.Label, "model", m.model)
}
modeColors := map[Mode]string{
ModeAsk: "#81a1c1",
ModePlan: "#ebcb8b",
ModeBuild: "#a3be8c",
}
_ = modeColors
toastMsg := fmt.Sprintf("⚡ Mode: %s • Model: %s", cfg.Label, m.model)
if m.toastMgr != nil {
m.toastMgr.AddToast(Toast{
Message: toastMsg,
Kind: ToastKindInfo,
})
}
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: fmt.Sprintf("Mode switched to %s (%s)", cfg.Label, m.model),
})
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
}
func (m *Model) openModelPicker() {
if len(m.modelList) == 0 {
if m.toastMgr != nil {
m.toastMgr.AddToast(Toast{Message: m.tr().NoModelsAvailable, Kind: ToastKindWarning})
}
return
}
models := make([]config.Model, len(m.modelList))
for i, name := range m.modelList {
models[i] = config.Model{Name: name, DisplayName: name}
}
m.modelPickerState = newModelPickerState(models, m.model, m.isDark, m.tr().SelectModel)
m.overlay = OverlayModelPicker
m.input.Blur()
}
func (m *Model) selectModel(name string) {
old := m.model
if m.modelManager != nil {
if err := m.modelManager.SetCurrentModel(name); err != nil {
m.entries = append(m.entries, ChatEntry{
Kind: "error",
Content: fmt.Sprintf("Failed to switch model: %v", err),
})
m.closeModelPicker()
return
}
}
m.model = name
if m.logger != nil {
m.logger.Info("model switched", "from", old, "to", name)
}
m.entries = append(m.entries, ChatEntry{
Kind: "system",
Content: fmt.Sprintf("Model: %s", name),
})
m.closeModelPicker()
m.viewport.SetContent(m.renderEntries())
m.viewport.GotoBottom()
}
func (m *Model) closeModelPicker() {
m.modelPickerState = nil
m.overlay = OverlayNone
m.input.Focus()
}
func (m *Model) openPlanForm(task string) {
m.planFormState = NewPlanFormState(task)
m.overlay = OverlayPlanForm
m.input.Blur()
}
func (m *Model) closePlanForm() {
m.planFormState = nil
m.overlay = OverlayNone
m.input.Focus()
}
func (m *Model) submitPlanFormPrompt(prompt string) tea.Cmd {
return m.sendToAgent(prompt)
}
func (m *Model) lastAssistantContent() string {
for i := len(m.entries) - 1; i >= 0; i-- {
if m.entries[i].Kind == "assistant" {
return m.entries[i].Content
}
}
return ""
}
func (m *Model) copyToClipboard(text string) tea.Cmd {
return func() tea.Msg {
if err := clipboard.WriteAll(text); err != nil {
m.toastMgr.Error("Clipboard error: " + err.Error())
return SystemMessageMsg{Msg: "Clipboard error: " + err.Error()}
}
m.toastMgr.Success("Copied to clipboard")
return SystemMessageMsg{Msg: "Copied to clipboard."}
}
}
func (m *Model) handleMouseClick(x, y int) {
vpY := y - 3 + m.viewport.YOffset()
if m.toolEntryRows == nil {
return
}
for toolIdx, startRow := range m.toolEntryRows {
if vpY >= startRow && vpY < startRow+3 {
if toolIdx >= 0 && toolIdx < len(m.toolEntries) {
m.toolEntries[toolIdx].Collapsed = !m.toolEntries[toolIdx].Collapsed
m.invalidateEntryCache()
m.viewport.SetContent(m.renderEntries())
}
return
}
}
}
func (m *Model) formatConversationForExport() string {
var b strings.Builder
b.WriteString("# Conversation Export\n\n")
b.WriteString(fmt.Sprintf("**Date**: %s\n", time.Now().Format("2006-01-02 15:04")))
b.WriteString(fmt.Sprintf("**Model**: %s\n", m.model))
b.WriteString("---\n\n")
for _, entry := range m.entries {
switch entry.Kind {
case "user":
b.WriteString("## User\n\n")
b.WriteString(entry.Content)
b.WriteString("\n\n---\n\n")
case "assistant":
b.WriteString("## Assistant\n\n")
b.WriteString(entry.Content)
b.WriteString("\n\n---\n\n")
case "system":
b.WriteString("## System\n\n")
b.WriteString(entry.Content)
b.WriteString("\n\n---\n\n")
case "tool_group":
if entry.ToolIndex >= 0 && entry.ToolIndex < len(m.toolEntries) {
te := m.toolEntries[entry.ToolIndex]
b.WriteString(fmt.Sprintf("## Tool: %s\n\n", te.Name))
b.WriteString("```\n")
b.WriteString(te.Args)
b.WriteString("\n```\n\n")
if te.Result != "" {
b.WriteString("**Result**:\n\n")
b.WriteString("```\n")
b.WriteString(te.Result)
b.WriteString("\n```\n\n")
}
b.WriteString("---\n\n")
}
}
}
return b.String()
}
func (m *Model) parseImportedConversation(data string) ([]ChatEntry, error) {
var entries []ChatEntry
lines := strings.Split(data, "\n")
var currentSection string
var currentContent strings.Builder
flushContent := func() {
if currentContent.Len() > 0 {
content := strings.TrimSpace(currentContent.String())
if content != "" {
entry := ChatEntry{Kind: currentSection, Content: content}
entries = append(entries, entry)
}
currentContent.Reset()
}
}
for _, line := range lines {
if strings.HasPrefix(line, "## ") {
flushContent()
section := strings.TrimPrefix(line, "## ")
switch section {
case "User":
currentSection = "user"
case "Assistant":
currentSection = "assistant"
case "System":
currentSection = "system"
default:
currentSection = "system"
}
} else if strings.HasPrefix(line, "---") {
// Skip separators
} else {
currentContent.WriteString(line)
currentContent.WriteString("\n")
}
}
flushContent()
m.toolEntries = nil
return entries, nil
}
func (m *Model) Ready() bool {
return m.ready
}
func (m *Model) AnchorActive() bool {
return m.anchorActive
}