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 }