chat.go

  1package page
  2
  3import (
  4	"context"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/opencode-ai/opencode/internal/app"
 11	"github.com/opencode-ai/opencode/internal/completions"
 12	"github.com/opencode-ai/opencode/internal/logging"
 13	"github.com/opencode-ai/opencode/internal/message"
 14	"github.com/opencode-ai/opencode/internal/session"
 15	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 16	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 17	"github.com/opencode-ai/opencode/internal/tui/layout"
 18	"github.com/opencode-ai/opencode/internal/tui/util"
 19)
 20
 21var ChatPage PageID = "chat"
 22
 23type chatPage struct {
 24	app                  *app.App
 25	editor               layout.Container
 26	messages             layout.Container
 27	layout               layout.SplitPaneLayout
 28	session              session.Session
 29	completionDialog     dialog.CompletionDialog
 30	showCompletionDialog bool
 31}
 32
 33type ChatKeyMap struct {
 34	ShowCompletionDialog key.Binding
 35	NewSession           key.Binding
 36	Cancel               key.Binding
 37}
 38
 39var keyMap = ChatKeyMap{
 40	ShowCompletionDialog: key.NewBinding(
 41		key.WithKeys("@"),
 42		key.WithHelp("@", "Complete"),
 43	),
 44	NewSession: key.NewBinding(
 45		key.WithKeys("ctrl+n"),
 46		key.WithHelp("ctrl+n", "new session"),
 47	),
 48	Cancel: key.NewBinding(
 49		key.WithKeys("esc"),
 50		key.WithHelp("esc", "cancel"),
 51	),
 52}
 53
 54func (p *chatPage) Init() tea.Cmd {
 55	cmds := []tea.Cmd{
 56		p.layout.Init(),
 57		p.completionDialog.Init(),
 58	}
 59	return tea.Batch(cmds...)
 60}
 61
 62func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 63	var cmds []tea.Cmd
 64	switch msg := msg.(type) {
 65	case tea.WindowSizeMsg:
 66		logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
 67		cmd := p.layout.SetSize(msg.Width, msg.Height)
 68		cmds = append(cmds, cmd)
 69	case dialog.CompletionDialogCloseMsg:
 70		p.showCompletionDialog = false
 71	case chat.SendMsg:
 72		cmd := p.sendMessage(msg.Text, msg.Attachments)
 73		if cmd != nil {
 74			return p, cmd
 75		}
 76	case dialog.CommandRunCustomMsg:
 77		// Check if the agent is busy before executing custom commands
 78		if p.app.CoderAgent.IsBusy() {
 79			return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
 80		}
 81
 82		// Process the command content with arguments if any
 83		content := msg.Content
 84		if msg.Args != nil {
 85			// Replace all named arguments with their values
 86			for name, value := range msg.Args {
 87				placeholder := "$" + name
 88				content = strings.ReplaceAll(content, placeholder, value)
 89			}
 90		}
 91
 92		// Handle custom command execution
 93		cmd := p.sendMessage(content, nil)
 94		if cmd != nil {
 95			return p, cmd
 96		}
 97	case chat.SessionSelectedMsg:
 98		if p.session.ID == "" {
 99			cmd := p.setSidebar()
100			if cmd != nil {
101				cmds = append(cmds, cmd)
102			}
103		}
104		p.session = msg
105	case tea.KeyMsg:
106		switch {
107		case key.Matches(msg, keyMap.ShowCompletionDialog):
108			p.showCompletionDialog = true
109			// Continue sending keys to layout->chat
110		case key.Matches(msg, keyMap.NewSession):
111			p.session = session.Session{}
112			return p, tea.Batch(
113				p.clearSidebar(),
114				util.CmdHandler(chat.SessionClearedMsg{}),
115			)
116		case key.Matches(msg, keyMap.Cancel):
117			if p.session.ID != "" {
118				// Cancel the current session's generation process
119				// This allows users to interrupt long-running operations
120				p.app.CoderAgent.Cancel(p.session.ID)
121				return p, nil
122			}
123		}
124	}
125	if p.showCompletionDialog {
126		context, contextCmd := p.completionDialog.Update(msg)
127		p.completionDialog = context.(dialog.CompletionDialog)
128		cmds = append(cmds, contextCmd)
129
130		// Doesn't forward event if enter key is pressed
131		if keyMsg, ok := msg.(tea.KeyMsg); ok {
132			if keyMsg.String() == "enter" {
133				return p, tea.Batch(cmds...)
134			}
135		}
136	}
137
138	u, cmd := p.layout.Update(msg)
139	cmds = append(cmds, cmd)
140	p.layout = u.(layout.SplitPaneLayout)
141
142	return p, tea.Batch(cmds...)
143}
144
145func (p *chatPage) setSidebar() tea.Cmd {
146	sidebarContainer := layout.NewContainer(
147		chat.NewSidebarCmp(p.session, p.app.History),
148		layout.WithPadding(1, 1, 1, 1),
149	)
150	return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
151}
152
153func (p *chatPage) clearSidebar() tea.Cmd {
154	return p.layout.ClearRightPanel()
155}
156
157func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
158	var cmds []tea.Cmd
159	if p.session.ID == "" {
160		session, err := p.app.Sessions.Create(context.Background(), "New Session")
161		if err != nil {
162			return util.ReportError(err)
163		}
164
165		p.session = session
166		cmd := p.setSidebar()
167		if cmd != nil {
168			cmds = append(cmds, cmd)
169		}
170		cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
171	}
172
173	_, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
174	if err != nil {
175		return util.ReportError(err)
176	}
177	return tea.Batch(cmds...)
178}
179
180func (p *chatPage) SetSize(width, height int) tea.Cmd {
181	return p.layout.SetSize(width, height)
182}
183
184func (p *chatPage) GetSize() (int, int) {
185	return p.layout.GetSize()
186}
187
188func (p *chatPage) View() string {
189	layoutView := p.layout.View()
190
191	if p.showCompletionDialog {
192		_, layoutHeight := p.layout.GetSize()
193		editorWidth, editorHeight := p.editor.GetSize()
194
195		p.completionDialog.SetWidth(editorWidth)
196		overlay := p.completionDialog.View()
197
198		layoutView = layout.PlaceOverlay(
199			0,
200			layoutHeight-editorHeight-lipgloss.Height(overlay),
201			overlay,
202			layoutView,
203			false,
204		)
205	}
206
207	return layoutView
208}
209
210func (p *chatPage) BindingKeys() []key.Binding {
211	bindings := layout.KeyMapToSlice(keyMap)
212	bindings = append(bindings, p.messages.BindingKeys()...)
213	bindings = append(bindings, p.editor.BindingKeys()...)
214	return bindings
215}
216
217func NewChatPage(app *app.App) util.Model {
218	cg := completions.NewFileAndFolderContextGroup()
219	completionDialog := dialog.NewCompletionDialogCmp(cg)
220
221	messagesContainer := layout.NewContainer(
222		chat.NewMessagesListCmp(app),
223		layout.WithPadding(1, 1, 0, 1),
224	)
225	editorContainer := layout.NewContainer(
226		chat.NewEditorCmp(app),
227		layout.WithBorder(true, false, false, false),
228	)
229	return &chatPage{
230		app:              app,
231		editor:           editorContainer,
232		messages:         messagesContainer,
233		completionDialog: completionDialog,
234		layout: layout.NewSplitPane(
235			layout.WithLeftPanel(messagesContainer),
236			layout.WithBottomPanel(editorContainer),
237		),
238	}
239}