chat.go

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