tui.go

  1package tui
  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/logging"
 10	"github.com/charmbracelet/crush/internal/pubsub"
 11	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 12	"github.com/charmbracelet/crush/internal/tui/components/completions"
 13	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 14	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 15	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 16	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 17	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 18	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 19	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
 20	"github.com/charmbracelet/crush/internal/tui/layout"
 21	"github.com/charmbracelet/crush/internal/tui/page"
 22	"github.com/charmbracelet/crush/internal/tui/page/chat"
 23	"github.com/charmbracelet/crush/internal/tui/styles"
 24	"github.com/charmbracelet/crush/internal/tui/util"
 25	"github.com/charmbracelet/lipgloss/v2"
 26)
 27
 28// appModel represents the main application model that manages pages, dialogs, and UI state.
 29type appModel struct {
 30	width, height int
 31	keyMap        KeyMap
 32
 33	currentPage  page.PageID
 34	previousPage page.PageID
 35	pages        map[page.PageID]util.Model
 36	loadedPages  map[page.PageID]bool
 37
 38	status status.StatusCmp
 39
 40	app *app.App
 41
 42	dialog      dialogs.DialogCmp
 43	completions completions.Completions
 44
 45	// Session
 46	selectedSessionID string // The ID of the currently selected session
 47}
 48
 49// Init initializes the application model and returns initial commands.
 50func (a appModel) Init() tea.Cmd {
 51	var cmds []tea.Cmd
 52	cmd := a.pages[a.currentPage].Init()
 53	cmds = append(cmds, cmd)
 54	a.loadedPages[a.currentPage] = true
 55
 56	cmd = a.status.Init()
 57	cmds = append(cmds, cmd)
 58	return tea.Batch(cmds...)
 59}
 60
 61// Update handles incoming messages and updates the application state.
 62func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 63	var cmds []tea.Cmd
 64	var cmd tea.Cmd
 65
 66	switch msg := msg.(type) {
 67	case tea.WindowSizeMsg:
 68		return a, a.handleWindowResize(msg)
 69
 70	// Completions messages
 71	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
 72		u, completionCmd := a.completions.Update(msg)
 73		a.completions = u.(completions.Completions)
 74		return a, completionCmd
 75
 76	// Dialog messages
 77	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
 78		u, dialogCmd := a.dialog.Update(msg)
 79		a.dialog = u.(dialogs.DialogCmp)
 80		return a, dialogCmd
 81	case commands.ShowArgumentsDialogMsg:
 82		return a, util.CmdHandler(
 83			dialogs.OpenDialogMsg{
 84				Model: commands.NewCommandArgumentsDialog(
 85					msg.CommandID,
 86					msg.Content,
 87					msg.ArgNames,
 88				),
 89			},
 90		)
 91	// Page change messages
 92	case page.PageChangeMsg:
 93		return a, a.moveToPage(msg.ID)
 94
 95	// Status Messages
 96	case util.InfoMsg, util.ClearStatusMsg:
 97		s, statusCmd := a.status.Update(msg)
 98		a.status = s.(status.StatusCmp)
 99		cmds = append(cmds, statusCmd)
100		return a, tea.Batch(cmds...)
101
102	// Session
103	case cmpChat.SessionSelectedMsg:
104		a.selectedSessionID = msg.ID
105	case cmpChat.SessionClearedMsg:
106		a.selectedSessionID = ""
107	// Logs
108	case pubsub.Event[logging.LogMessage]:
109		// Send to the status component
110		s, statusCmd := a.status.Update(msg)
111		a.status = s.(status.StatusCmp)
112		cmds = append(cmds, statusCmd)
113
114		// If the current page is logs, update the logs view
115		if a.currentPage == page.LogsPage {
116			updated, pageCmd := a.pages[a.currentPage].Update(msg)
117			a.pages[a.currentPage] = updated.(util.Model)
118			cmds = append(cmds, pageCmd)
119		}
120		return a, tea.Batch(cmds...)
121	// Commands
122	case commands.SwitchSessionsMsg:
123		return a, func() tea.Msg {
124			allSessions, _ := a.app.Sessions.List(context.Background())
125			return dialogs.OpenDialogMsg{
126				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
127			}
128		}
129
130	case commands.SwitchModelMsg:
131		return a, util.CmdHandler(
132			dialogs.OpenDialogMsg{
133				Model: models.NewModelDialogCmp(),
134			},
135		)
136	// File Picker
137	case chat.OpenFilePickerMsg:
138		if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
139			// If the commands dialog is already open, close it
140			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
141		}
142		return a, util.CmdHandler(dialogs.OpenDialogMsg{
143			Model: filepicker.NewFilePickerCmp(),
144		})
145	case tea.KeyPressMsg:
146		return a, a.handleKeyPressMsg(msg)
147	}
148	s, _ := a.status.Update(msg)
149	a.status = s.(status.StatusCmp)
150	updated, cmd := a.pages[a.currentPage].Update(msg)
151	a.pages[a.currentPage] = updated.(util.Model)
152	if a.dialog.HasDialogs() {
153		u, dialogCmd := a.dialog.Update(msg)
154		a.dialog = u.(dialogs.DialogCmp)
155		cmds = append(cmds, dialogCmd)
156	}
157	cmds = append(cmds, cmd)
158	return a, tea.Batch(cmds...)
159}
160
161// handleWindowResize processes window resize events and updates all components.
162func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
163	var cmds []tea.Cmd
164	msg.Height -= 1 // Make space for the status bar
165	a.width, a.height = msg.Width, msg.Height
166
167	// Update status bar
168	s, cmd := a.status.Update(msg)
169	a.status = s.(status.StatusCmp)
170	cmds = append(cmds, cmd)
171
172	// Update the current page
173	updated, cmd := a.pages[a.currentPage].Update(msg)
174	a.pages[a.currentPage] = updated.(util.Model)
175	cmds = append(cmds, cmd)
176
177	// Update the dialogs
178	dialog, cmd := a.dialog.Update(msg)
179	a.dialog = dialog.(dialogs.DialogCmp)
180	cmds = append(cmds, cmd)
181
182	return tea.Batch(cmds...)
183}
184
185// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
186func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
187	switch {
188	// completions
189	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
190		u, cmd := a.completions.Update(msg)
191		a.completions = u.(completions.Completions)
192		return cmd
193
194	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
195		u, cmd := a.completions.Update(msg)
196		a.completions = u.(completions.Completions)
197		return cmd
198	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
199		u, cmd := a.completions.Update(msg)
200		a.completions = u.(completions.Completions)
201		return cmd
202	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
203		u, cmd := a.completions.Update(msg)
204		a.completions = u.(completions.Completions)
205		return cmd
206	// dialogs
207	case key.Matches(msg, a.keyMap.Quit):
208		if a.dialog.ActiveDialogId() == quit.QuitDialogID {
209			// if the quit dialog is already open, close the app
210			return tea.Quit
211		}
212		return util.CmdHandler(dialogs.OpenDialogMsg{
213			Model: quit.NewQuitDialog(),
214		})
215
216	case key.Matches(msg, a.keyMap.Commands):
217		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
218			// If the commands dialog is already open, close it
219			return util.CmdHandler(dialogs.CloseDialogMsg{})
220		}
221		return util.CmdHandler(dialogs.OpenDialogMsg{
222			Model: commands.NewCommandDialog(),
223		})
224	case key.Matches(msg, a.keyMap.Sessions):
225		if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
226			// If the sessions dialog is already open, close it
227			return util.CmdHandler(dialogs.CloseDialogMsg{})
228		}
229		var cmds []tea.Cmd
230		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
231			// If the commands dialog is open, close it first
232			cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
233		}
234		cmds = append(cmds,
235			func() tea.Msg {
236				allSessions, _ := a.app.Sessions.List(context.Background())
237				return dialogs.OpenDialogMsg{
238					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
239				}
240			},
241		)
242		return tea.Sequence(cmds...)
243	// Page navigation
244	case key.Matches(msg, a.keyMap.Logs):
245		return a.moveToPage(page.LogsPage)
246
247	default:
248		if a.dialog.HasDialogs() {
249			u, dialogCmd := a.dialog.Update(msg)
250			a.dialog = u.(dialogs.DialogCmp)
251			return dialogCmd
252		} else {
253			updated, cmd := a.pages[a.currentPage].Update(msg)
254			a.pages[a.currentPage] = updated.(util.Model)
255			return cmd
256		}
257	}
258}
259
260// moveToPage handles navigation between different pages in the application.
261func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
262	if a.app.CoderAgent.IsBusy() {
263		// For now we don't move to any page if the agent is busy
264		return util.ReportWarn("Agent is busy, please wait...")
265	}
266
267	var cmds []tea.Cmd
268	if _, ok := a.loadedPages[pageID]; !ok {
269		cmd := a.pages[pageID].Init()
270		cmds = append(cmds, cmd)
271		a.loadedPages[pageID] = true
272	}
273	a.previousPage = a.currentPage
274	a.currentPage = pageID
275	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
276		cmd := sizable.SetSize(a.width, a.height)
277		cmds = append(cmds, cmd)
278	}
279
280	return tea.Batch(cmds...)
281}
282
283// View renders the complete application interface including pages, dialogs, and overlays.
284func (a *appModel) View() tea.View {
285	pageView := a.pages[a.currentPage].View()
286	components := []string{
287		pageView.String(),
288	}
289	components = append(components, a.status.View().String())
290
291	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
292	layers := []*lipgloss.Layer{
293		lipgloss.NewLayer(appView),
294	}
295	if a.dialog.HasDialogs() {
296		logging.Info("Rendering dialogs")
297		layers = append(
298			layers,
299			a.dialog.GetLayers()...,
300		)
301	}
302
303	cursor := pageView.Cursor()
304	activeView := a.dialog.ActiveView()
305	if activeView != nil {
306		cursor = activeView.Cursor()
307	}
308
309	if a.completions.Open() && cursor != nil {
310		cmp := a.completions.View().String()
311		x, y := a.completions.Position()
312		layers = append(
313			layers,
314			lipgloss.NewLayer(cmp).X(x).Y(y),
315		)
316	}
317
318	canvas := lipgloss.NewCanvas(
319		layers...,
320	)
321
322	t := styles.CurrentTheme()
323	view := tea.NewView(canvas.Render())
324	view.SetBackgroundColor(t.BgBase)
325	view.SetCursor(cursor)
326	return view
327}
328
329// New creates and initializes a new TUI application model.
330func New(app *app.App) tea.Model {
331	startPage := chat.ChatPage
332	model := &appModel{
333		currentPage: startPage,
334		app:         app,
335		status:      status.NewStatusCmp(),
336		loadedPages: make(map[page.PageID]bool),
337		keyMap:      DefaultKeyMap(),
338
339		pages: map[page.PageID]util.Model{
340			chat.ChatPage: chat.NewChatPage(app),
341			page.LogsPage: page.NewLogsPage(),
342		},
343
344		dialog:      dialogs.NewDialogCmp(),
345		completions: completions.New(),
346	}
347
348	return model
349}