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}