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/config"
 10	"github.com/charmbracelet/crush/internal/llm/tools"
 11	"github.com/charmbracelet/crush/internal/logging"
 12	"github.com/charmbracelet/crush/internal/permission"
 13	"github.com/charmbracelet/crush/internal/pubsub"
 14	cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
 15	"github.com/charmbracelet/crush/internal/tui/components/completions"
 16	"github.com/charmbracelet/crush/internal/tui/components/core/status"
 17	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 18	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
 19	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 20	initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
 21	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 22	"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
 23	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 24	"github.com/charmbracelet/crush/internal/tui/components/dialogs/sessions"
 25	"github.com/charmbracelet/crush/internal/tui/layout"
 26	"github.com/charmbracelet/crush/internal/tui/page"
 27	"github.com/charmbracelet/crush/internal/tui/page/chat"
 28	"github.com/charmbracelet/crush/internal/tui/styles"
 29	"github.com/charmbracelet/crush/internal/tui/util"
 30	"github.com/charmbracelet/lipgloss/v2"
 31)
 32
 33// appModel represents the main application model that manages pages, dialogs, and UI state.
 34type appModel struct {
 35	width, height int
 36	keyMap        KeyMap
 37
 38	currentPage  page.PageID
 39	previousPage page.PageID
 40	pages        map[page.PageID]util.Model
 41	loadedPages  map[page.PageID]bool
 42
 43	status status.StatusCmp
 44
 45	app *app.App
 46
 47	dialog      dialogs.DialogCmp
 48	completions completions.Completions
 49
 50	// Session
 51	selectedSessionID string // The ID of the currently selected session
 52}
 53
 54// Init initializes the application model and returns initial commands.
 55func (a appModel) Init() tea.Cmd {
 56	var cmds []tea.Cmd
 57	cmd := a.pages[a.currentPage].Init()
 58	cmds = append(cmds, cmd)
 59	a.loadedPages[a.currentPage] = true
 60
 61	cmd = a.status.Init()
 62	cmds = append(cmds, cmd)
 63
 64	// Check if we should show the init dialog
 65	cmds = append(cmds, func() tea.Msg {
 66		shouldShow, err := config.ShouldShowInitDialog()
 67		if err != nil {
 68			return util.InfoMsg{
 69				Type: util.InfoTypeError,
 70				Msg:  "Failed to check init status: " + err.Error(),
 71			}
 72		}
 73		if shouldShow {
 74			return dialogs.OpenDialogMsg{
 75				Model: initDialog.NewInitDialogCmp(),
 76			}
 77		}
 78		return nil
 79	})
 80
 81	return tea.Batch(cmds...)
 82}
 83
 84// Update handles incoming messages and updates the application state.
 85func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 86	logging.Info("TUI Update", "msg", msg)
 87	var cmds []tea.Cmd
 88	var cmd tea.Cmd
 89
 90	switch msg := msg.(type) {
 91	case tea.WindowSizeMsg:
 92		return a, a.handleWindowResize(msg)
 93
 94	// Completions messages
 95	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
 96		u, completionCmd := a.completions.Update(msg)
 97		a.completions = u.(completions.Completions)
 98		return a, completionCmd
 99
100	// Dialog messages
101	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
102		u, dialogCmd := a.dialog.Update(msg)
103		a.dialog = u.(dialogs.DialogCmp)
104		return a, dialogCmd
105	case commands.ShowArgumentsDialogMsg:
106		return a, util.CmdHandler(
107			dialogs.OpenDialogMsg{
108				Model: commands.NewCommandArgumentsDialog(
109					msg.CommandID,
110					msg.Content,
111					msg.ArgNames,
112				),
113			},
114		)
115	// Page change messages
116	case page.PageChangeMsg:
117		return a, a.moveToPage(msg.ID)
118
119	// Status Messages
120	case util.InfoMsg, util.ClearStatusMsg:
121		s, statusCmd := a.status.Update(msg)
122		a.status = s.(status.StatusCmp)
123		cmds = append(cmds, statusCmd)
124		return a, tea.Batch(cmds...)
125
126	// Session
127	case cmpChat.SessionSelectedMsg:
128		a.selectedSessionID = msg.ID
129	case cmpChat.SessionClearedMsg:
130		a.selectedSessionID = ""
131	// Logs
132	case pubsub.Event[logging.LogMessage]:
133		// Send to the status component
134		s, statusCmd := a.status.Update(msg)
135		a.status = s.(status.StatusCmp)
136		cmds = append(cmds, statusCmd)
137
138		// If the current page is logs, update the logs view
139		if a.currentPage == page.LogsPage {
140			updated, pageCmd := a.pages[a.currentPage].Update(msg)
141			a.pages[a.currentPage] = updated.(util.Model)
142			cmds = append(cmds, pageCmd)
143		}
144		return a, tea.Batch(cmds...)
145	// Commands
146	case commands.SwitchSessionsMsg:
147		return a, func() tea.Msg {
148			allSessions, _ := a.app.Sessions.List(context.Background())
149			return dialogs.OpenDialogMsg{
150				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
151			}
152		}
153
154	case commands.SwitchModelMsg:
155		return a, util.CmdHandler(
156			dialogs.OpenDialogMsg{
157				Model: models.NewModelDialogCmp(),
158			},
159		)
160	// File Picker
161	case chat.OpenFilePickerMsg:
162		if a.dialog.ActiveDialogId() == filepicker.FilePickerID {
163			// If the commands dialog is already open, close it
164			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
165		}
166		return a, util.CmdHandler(dialogs.OpenDialogMsg{
167			Model: filepicker.NewFilePickerCmp(),
168		})
169	// Permissions
170	case pubsub.Event[permission.PermissionRequest]:
171		return a, util.CmdHandler(dialogs.OpenDialogMsg{
172			Model: permissions.NewPermissionDialogCmp(msg.Payload),
173		})
174	case permissions.PermissionResponseMsg:
175		switch msg.Action {
176		case permissions.PermissionAllow:
177			a.app.Permissions.Grant(msg.Permission)
178		case permissions.PermissionAllowForSession:
179			a.app.Permissions.GrantPersistent(msg.Permission)
180		case permissions.PermissionDeny:
181			a.app.Permissions.Deny(msg.Permission)
182		}
183		return a, nil
184	// Init Dialog
185	case initDialog.CloseInitDialogMsg:
186		if msg.Initialize {
187			// Run the initialization command
188			prompt := `Please analyze this codebase and create a Crush.md file containing:
1891. Build/lint/test commands - especially for running a single test
1902. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
191
192The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
193If there's already a crush.md, improve it.
194If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
195			
196			// Mark the project as initialized
197			if err := config.MarkProjectInitialized(); err != nil {
198				return a, util.ReportError(err)
199			}
200			
201			return a, util.CmdHandler(cmpChat.SendMsg{
202				Text: prompt,
203			})
204		} else {
205			// Mark the project as initialized without running the command
206			if err := config.MarkProjectInitialized(); err != nil {
207				return a, util.ReportError(err)
208			}
209		}
210		return a, nil
211	// Key Press Messages
212	case tea.KeyPressMsg:
213		if msg.String() == "ctrl+t" {
214			go a.app.Permissions.Request(permission.CreatePermissionRequest{
215				SessionID: "123",
216				ToolName:  "bash",
217				Action:    "execute",
218				Params: tools.BashPermissionsParams{
219					Command: "ls -la",
220				},
221			})
222		}
223		return a, a.handleKeyPressMsg(msg)
224	}
225	s, _ := a.status.Update(msg)
226	a.status = s.(status.StatusCmp)
227	updated, cmd := a.pages[a.currentPage].Update(msg)
228	a.pages[a.currentPage] = updated.(util.Model)
229	if a.dialog.HasDialogs() {
230		u, dialogCmd := a.dialog.Update(msg)
231		a.dialog = u.(dialogs.DialogCmp)
232		cmds = append(cmds, dialogCmd)
233	}
234	cmds = append(cmds, cmd)
235	return a, tea.Batch(cmds...)
236}
237
238// handleWindowResize processes window resize events and updates all components.
239func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
240	var cmds []tea.Cmd
241	msg.Height -= 1 // Make space for the status bar
242	a.width, a.height = msg.Width, msg.Height
243
244	// Update status bar
245	s, cmd := a.status.Update(msg)
246	a.status = s.(status.StatusCmp)
247	cmds = append(cmds, cmd)
248
249	// Update the current page
250	updated, cmd := a.pages[a.currentPage].Update(msg)
251	a.pages[a.currentPage] = updated.(util.Model)
252	cmds = append(cmds, cmd)
253
254	// Update the dialogs
255	dialog, cmd := a.dialog.Update(msg)
256	a.dialog = dialog.(dialogs.DialogCmp)
257	cmds = append(cmds, cmd)
258
259	return tea.Batch(cmds...)
260}
261
262// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
263func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
264	switch {
265	// completions
266	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
267		u, cmd := a.completions.Update(msg)
268		a.completions = u.(completions.Completions)
269		return cmd
270
271	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
272		u, cmd := a.completions.Update(msg)
273		a.completions = u.(completions.Completions)
274		return cmd
275	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
276		u, cmd := a.completions.Update(msg)
277		a.completions = u.(completions.Completions)
278		return cmd
279	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
280		u, cmd := a.completions.Update(msg)
281		a.completions = u.(completions.Completions)
282		return cmd
283	// dialogs
284	case key.Matches(msg, a.keyMap.Quit):
285		if a.dialog.ActiveDialogId() == quit.QuitDialogID {
286			// if the quit dialog is already open, close the app
287			return tea.Quit
288		}
289		return util.CmdHandler(dialogs.OpenDialogMsg{
290			Model: quit.NewQuitDialog(),
291		})
292
293	case key.Matches(msg, a.keyMap.Commands):
294		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
295			// If the commands dialog is already open, close it
296			return util.CmdHandler(dialogs.CloseDialogMsg{})
297		}
298		return util.CmdHandler(dialogs.OpenDialogMsg{
299			Model: commands.NewCommandDialog(),
300		})
301	case key.Matches(msg, a.keyMap.Sessions):
302		if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
303			// If the sessions dialog is already open, close it
304			return util.CmdHandler(dialogs.CloseDialogMsg{})
305		}
306		var cmds []tea.Cmd
307		if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
308			// If the commands dialog is open, close it first
309			cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
310		}
311		cmds = append(cmds,
312			func() tea.Msg {
313				allSessions, _ := a.app.Sessions.List(context.Background())
314				return dialogs.OpenDialogMsg{
315					Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
316				}
317			},
318		)
319		return tea.Sequence(cmds...)
320	// Page navigation
321	case key.Matches(msg, a.keyMap.Logs):
322		return a.moveToPage(page.LogsPage)
323
324	default:
325		if a.dialog.HasDialogs() {
326			u, dialogCmd := a.dialog.Update(msg)
327			a.dialog = u.(dialogs.DialogCmp)
328			return dialogCmd
329		} else {
330			updated, cmd := a.pages[a.currentPage].Update(msg)
331			a.pages[a.currentPage] = updated.(util.Model)
332			return cmd
333		}
334	}
335}
336
337// moveToPage handles navigation between different pages in the application.
338func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
339	if a.app.CoderAgent.IsBusy() {
340		// For now we don't move to any page if the agent is busy
341		return util.ReportWarn("Agent is busy, please wait...")
342	}
343
344	var cmds []tea.Cmd
345	if _, ok := a.loadedPages[pageID]; !ok {
346		cmd := a.pages[pageID].Init()
347		cmds = append(cmds, cmd)
348		a.loadedPages[pageID] = true
349	}
350	a.previousPage = a.currentPage
351	a.currentPage = pageID
352	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
353		cmd := sizable.SetSize(a.width, a.height)
354		cmds = append(cmds, cmd)
355	}
356
357	return tea.Batch(cmds...)
358}
359
360// View renders the complete application interface including pages, dialogs, and overlays.
361func (a *appModel) View() tea.View {
362	pageView := a.pages[a.currentPage].View()
363	components := []string{
364		pageView.String(),
365	}
366	components = append(components, a.status.View().String())
367
368	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
369	layers := []*lipgloss.Layer{
370		lipgloss.NewLayer(appView),
371	}
372	if a.dialog.HasDialogs() {
373		logging.Info("Rendering dialogs")
374		layers = append(
375			layers,
376			a.dialog.GetLayers()...,
377		)
378	}
379
380	cursor := pageView.Cursor()
381	activeView := a.dialog.ActiveView()
382	if activeView != nil {
383		cursor = activeView.Cursor()
384	}
385
386	if a.completions.Open() && cursor != nil {
387		cmp := a.completions.View().String()
388		x, y := a.completions.Position()
389		layers = append(
390			layers,
391			lipgloss.NewLayer(cmp).X(x).Y(y),
392		)
393	}
394
395	canvas := lipgloss.NewCanvas(
396		layers...,
397	)
398
399	t := styles.CurrentTheme()
400	view := tea.NewView(canvas.Render())
401	view.SetBackgroundColor(t.BgBase)
402	view.SetCursor(cursor)
403	return view
404}
405
406// New creates and initializes a new TUI application model.
407func New(app *app.App) tea.Model {
408	startPage := chat.ChatPage
409	model := &appModel{
410		currentPage: startPage,
411		app:         app,
412		status:      status.NewStatusCmp(),
413		loadedPages: make(map[page.PageID]bool),
414		keyMap:      DefaultKeyMap(),
415
416		pages: map[page.PageID]util.Model{
417			chat.ChatPage: chat.NewChatPage(app),
418			page.LogsPage: page.NewLogsPage(),
419		},
420
421		dialog:      dialogs.NewDialogCmp(),
422		completions: completions.New(),
423	}
424
425	return model
426}