chat.go

  1package chat
  2
  3import (
  4	"context"
  5
  6	"github.com/charmbracelet/bubbles/v2/key"
  7	tea "github.com/charmbracelet/bubbletea/v2"
  8	"github.com/charmbracelet/crush/internal/app"
  9	"github.com/charmbracelet/crush/internal/config"
 10	"github.com/charmbracelet/crush/internal/llm/models"
 11	"github.com/charmbracelet/crush/internal/logging"
 12	"github.com/charmbracelet/crush/internal/message"
 13	"github.com/charmbracelet/crush/internal/session"
 14	"github.com/charmbracelet/crush/internal/tui/components/chat"
 15	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
 16	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 17	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 18	"github.com/charmbracelet/crush/internal/tui/layout"
 19	"github.com/charmbracelet/crush/internal/tui/page"
 20	"github.com/charmbracelet/crush/internal/tui/util"
 21)
 22
 23var ChatPage page.PageID = "chat"
 24
 25type ChatFocusedMsg struct {
 26	Focused bool // True if the chat input is focused, false otherwise
 27}
 28
 29type (
 30	OpenFilePickerMsg struct{}
 31	chatPage          struct {
 32		app *app.App
 33
 34		layout layout.SplitPaneLayout
 35
 36		session session.Session
 37
 38		keyMap KeyMap
 39
 40		chatFocused bool
 41	}
 42)
 43
 44func (p *chatPage) Init() tea.Cmd {
 45	cmd := p.layout.Init()
 46	return tea.Batch(
 47		cmd,
 48		p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor)
 49	)
 50}
 51
 52func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 53	var cmds []tea.Cmd
 54	switch msg := msg.(type) {
 55	case tea.WindowSizeMsg:
 56		cmd := p.layout.SetSize(msg.Width, msg.Height)
 57		cmds = append(cmds, cmd)
 58	case chat.SendMsg:
 59		cmd := p.sendMessage(msg.Text, msg.Attachments)
 60		if cmd != nil {
 61			return p, cmd
 62		}
 63	case commands.CommandRunCustomMsg:
 64		// Check if the agent is busy before executing custom commands
 65		if p.app.CoderAgent.IsBusy() {
 66			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 67		}
 68
 69		// Handle custom command execution
 70		cmd := p.sendMessage(msg.Content, nil)
 71		if cmd != nil {
 72			return p, cmd
 73		}
 74	case chat.SessionSelectedMsg:
 75		if p.session.ID == "" {
 76			cmd := p.setMessages()
 77			if cmd != nil {
 78				cmds = append(cmds, cmd)
 79			}
 80		}
 81		p.session = msg
 82	case tea.KeyPressMsg:
 83		switch {
 84		case key.Matches(msg, p.keyMap.NewSession):
 85			p.session = session.Session{}
 86			return p, tea.Batch(
 87				p.clearMessages(),
 88				util.CmdHandler(chat.SessionClearedMsg{}),
 89			)
 90
 91		case key.Matches(msg, p.keyMap.FilePicker):
 92			cfg := config.Get()
 93			agentCfg := cfg.Agents[config.AgentCoder]
 94			selectedModelID := agentCfg.Model
 95			model := models.SupportedModels[selectedModelID]
 96			if model.SupportsAttachments {
 97				return p, util.CmdHandler(OpenFilePickerMsg{})
 98			} else {
 99				return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
100			}
101		case key.Matches(msg, p.keyMap.Tab):
102			logging.Info("Tab key pressed, toggling chat focus")
103			if p.session.ID == "" {
104				return p, nil
105			}
106			p.chatFocused = !p.chatFocused
107			if p.chatFocused {
108				cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
109				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
110			} else {
111				cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
112				cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
113			}
114			return p, tea.Batch(cmds...)
115		case key.Matches(msg, p.keyMap.Cancel):
116			if p.session.ID != "" {
117				// Cancel the current session's generation process
118				// This allows users to interrupt long-running operations
119				p.app.CoderAgent.Cancel(p.session.ID)
120				return p, nil
121			}
122		}
123	}
124	u, cmd := p.layout.Update(msg)
125	cmds = append(cmds, cmd)
126	p.layout = u.(layout.SplitPaneLayout)
127
128	return p, tea.Batch(cmds...)
129}
130
131func (p *chatPage) setMessages() tea.Cmd {
132	messagesContainer := layout.NewContainer(
133		chat.NewMessagesListCmp(p.app),
134		layout.WithPadding(1, 1, 0, 1),
135	)
136	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
137}
138
139func (p *chatPage) clearMessages() tea.Cmd {
140	return p.layout.ClearLeftPanel()
141}
142
143func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
144	var cmds []tea.Cmd
145	if p.session.ID == "" {
146		session, err := p.app.Sessions.Create(context.Background(), "New Session")
147		if err != nil {
148			return util.ReportError(err)
149		}
150
151		p.session = session
152		cmd := p.setMessages()
153		if cmd != nil {
154			cmds = append(cmds, cmd)
155		}
156		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
157	}
158
159	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
160	if err != nil {
161		return util.ReportError(err)
162	}
163	return tea.Batch(cmds...)
164}
165
166func (p *chatPage) SetSize(width, height int) tea.Cmd {
167	return p.layout.SetSize(width, height)
168}
169
170func (p *chatPage) GetSize() (int, int) {
171	return p.layout.GetSize()
172}
173
174func (p *chatPage) View() tea.View {
175	return p.layout.View()
176}
177
178func NewChatPage(app *app.App) util.Model {
179	sidebarContainer := layout.NewContainer(
180		sidebar.NewSidebarCmp(),
181		layout.WithPadding(1, 1, 1, 1),
182	)
183	editorContainer := layout.NewContainer(
184		editor.NewEditorCmp(app),
185	)
186	return &chatPage{
187		app: app,
188		layout: layout.NewSplitPane(
189			layout.WithRightPanel(sidebarContainer),
190			layout.WithBottomPanel(editorContainer),
191			layout.WithFixedBottomHeight(5),
192			layout.WithFixedRightWidth(31),
193		),
194		keyMap: DefaultKeyMap(),
195	}
196}