tui.go

  1package tui
  2
  3import (
  4	"context"
  5
  6	"github.com/charmbracelet/bubbles/key"
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/lipgloss"
  9	"github.com/kujtimiihoxha/opencode/internal/app"
 10	"github.com/kujtimiihoxha/opencode/internal/config"
 11	"github.com/kujtimiihoxha/opencode/internal/logging"
 12	"github.com/kujtimiihoxha/opencode/internal/permission"
 13	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 14	"github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
 15	"github.com/kujtimiihoxha/opencode/internal/tui/components/core"
 16	"github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
 17	"github.com/kujtimiihoxha/opencode/internal/tui/layout"
 18	"github.com/kujtimiihoxha/opencode/internal/tui/page"
 19	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 20)
 21
 22type keyMap struct {
 23	Logs          key.Binding
 24	Quit          key.Binding
 25	Help          key.Binding
 26	SwitchSession key.Binding
 27	Commands      key.Binding
 28}
 29
 30var keys = keyMap{
 31	Logs: key.NewBinding(
 32		key.WithKeys("ctrl+l"),
 33		key.WithHelp("ctrl+L", "logs"),
 34	),
 35
 36	Quit: key.NewBinding(
 37		key.WithKeys("ctrl+c"),
 38		key.WithHelp("ctrl+c", "quit"),
 39	),
 40	Help: key.NewBinding(
 41		key.WithKeys("ctrl+_"),
 42		key.WithHelp("ctrl+?", "toggle help"),
 43	),
 44
 45	SwitchSession: key.NewBinding(
 46		key.WithKeys("ctrl+a"),
 47		key.WithHelp("ctrl+a", "switch session"),
 48	),
 49
 50	Commands: key.NewBinding(
 51		key.WithKeys("ctrl+k"),
 52		key.WithHelp("ctrl+K", "commands"),
 53	),
 54}
 55
 56var helpEsc = key.NewBinding(
 57	key.WithKeys("?"),
 58	key.WithHelp("?", "toggle help"),
 59)
 60
 61var returnKey = key.NewBinding(
 62	key.WithKeys("esc"),
 63	key.WithHelp("esc", "close"),
 64)
 65
 66var logsKeyReturnKey = key.NewBinding(
 67	key.WithKeys("backspace", "q"),
 68	key.WithHelp("backspace/q", "go back"),
 69)
 70
 71type appModel struct {
 72	width, height int
 73	currentPage   page.PageID
 74	previousPage  page.PageID
 75	pages         map[page.PageID]tea.Model
 76	loadedPages   map[page.PageID]bool
 77	status        core.StatusCmp
 78	app           *app.App
 79
 80	showPermissions bool
 81	permissions     dialog.PermissionDialogCmp
 82
 83	showHelp bool
 84	help     dialog.HelpCmp
 85
 86	showQuit bool
 87	quit     dialog.QuitDialog
 88
 89	showSessionDialog bool
 90	sessionDialog     dialog.SessionDialog
 91
 92	showCommandDialog bool
 93	commandDialog     dialog.CommandDialog
 94	commands          []dialog.Command
 95
 96	showInitDialog bool
 97	initDialog     dialog.InitDialogCmp
 98
 99	editingMode bool
100}
101
102func (a appModel) Init() tea.Cmd {
103	var cmds []tea.Cmd
104	cmd := a.pages[a.currentPage].Init()
105	a.loadedPages[a.currentPage] = true
106	cmds = append(cmds, cmd)
107	cmd = a.status.Init()
108	cmds = append(cmds, cmd)
109	cmd = a.quit.Init()
110	cmds = append(cmds, cmd)
111	cmd = a.help.Init()
112	cmds = append(cmds, cmd)
113	cmd = a.sessionDialog.Init()
114	cmds = append(cmds, cmd)
115	cmd = a.commandDialog.Init()
116	cmds = append(cmds, cmd)
117	cmd = a.initDialog.Init()
118	cmds = append(cmds, cmd)
119
120	// Check if we should show the init dialog
121	cmds = append(cmds, func() tea.Msg {
122		shouldShow, err := config.ShouldShowInitDialog()
123		if err != nil {
124			return util.InfoMsg{
125				Type: util.InfoTypeError,
126				Msg:  "Failed to check init status: " + err.Error(),
127			}
128		}
129		return dialog.ShowInitDialogMsg{Show: shouldShow}
130	})
131
132	return tea.Batch(cmds...)
133}
134
135func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136	var cmds []tea.Cmd
137	var cmd tea.Cmd
138	switch msg := msg.(type) {
139	case tea.WindowSizeMsg:
140		msg.Height -= 1 // Make space for the status bar
141		a.width, a.height = msg.Width, msg.Height
142
143		s, _ := a.status.Update(msg)
144		a.status = s.(core.StatusCmp)
145		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
146		cmds = append(cmds, cmd)
147
148		prm, permCmd := a.permissions.Update(msg)
149		a.permissions = prm.(dialog.PermissionDialogCmp)
150		cmds = append(cmds, permCmd)
151
152		help, helpCmd := a.help.Update(msg)
153		a.help = help.(dialog.HelpCmp)
154		cmds = append(cmds, helpCmd)
155
156		session, sessionCmd := a.sessionDialog.Update(msg)
157		a.sessionDialog = session.(dialog.SessionDialog)
158		cmds = append(cmds, sessionCmd)
159
160		command, commandCmd := a.commandDialog.Update(msg)
161		a.commandDialog = command.(dialog.CommandDialog)
162		cmds = append(cmds, commandCmd)
163
164		a.initDialog.SetSize(msg.Width, msg.Height)
165
166		return a, tea.Batch(cmds...)
167	case chat.EditorFocusMsg:
168		a.editingMode = bool(msg)
169	// Status
170	case util.InfoMsg:
171		s, cmd := a.status.Update(msg)
172		a.status = s.(core.StatusCmp)
173		cmds = append(cmds, cmd)
174		return a, tea.Batch(cmds...)
175	case pubsub.Event[logging.LogMessage]:
176		if msg.Payload.Persist {
177			switch msg.Payload.Level {
178			case "error":
179				s, cmd := a.status.Update(util.InfoMsg{
180					Type: util.InfoTypeError,
181					Msg:  msg.Payload.Message,
182					TTL:  msg.Payload.PersistTime,
183				})
184				a.status = s.(core.StatusCmp)
185				cmds = append(cmds, cmd)
186			case "info":
187				s, cmd := a.status.Update(util.InfoMsg{
188					Type: util.InfoTypeInfo,
189					Msg:  msg.Payload.Message,
190					TTL:  msg.Payload.PersistTime,
191				})
192				a.status = s.(core.StatusCmp)
193				cmds = append(cmds, cmd)
194
195			case "warn":
196				s, cmd := a.status.Update(util.InfoMsg{
197					Type: util.InfoTypeWarn,
198					Msg:  msg.Payload.Message,
199					TTL:  msg.Payload.PersistTime,
200				})
201
202				a.status = s.(core.StatusCmp)
203				cmds = append(cmds, cmd)
204			default:
205				s, cmd := a.status.Update(util.InfoMsg{
206					Type: util.InfoTypeInfo,
207					Msg:  msg.Payload.Message,
208					TTL:  msg.Payload.PersistTime,
209				})
210				a.status = s.(core.StatusCmp)
211				cmds = append(cmds, cmd)
212			}
213		}
214	case util.ClearStatusMsg:
215		s, _ := a.status.Update(msg)
216		a.status = s.(core.StatusCmp)
217
218	// Permission
219	case pubsub.Event[permission.PermissionRequest]:
220		a.showPermissions = true
221		return a, a.permissions.SetPermissions(msg.Payload)
222	case dialog.PermissionResponseMsg:
223		var cmd tea.Cmd
224		switch msg.Action {
225		case dialog.PermissionAllow:
226			a.app.Permissions.Grant(msg.Permission)
227		case dialog.PermissionAllowForSession:
228			a.app.Permissions.GrantPersistant(msg.Permission)
229		case dialog.PermissionDeny:
230			a.app.Permissions.Deny(msg.Permission)
231			cmd = util.CmdHandler(chat.FocusEditorMsg(true))
232		}
233		a.showPermissions = false
234		return a, cmd
235
236	case page.PageChangeMsg:
237		return a, a.moveToPage(msg.ID)
238
239	case dialog.CloseQuitMsg:
240		a.showQuit = false
241		return a, nil
242
243	case dialog.CloseSessionDialogMsg:
244		a.showSessionDialog = false
245		return a, nil
246
247	case dialog.CloseCommandDialogMsg:
248		a.showCommandDialog = false
249		return a, nil
250
251	case dialog.ShowInitDialogMsg:
252		a.showInitDialog = msg.Show
253		return a, nil
254
255	case dialog.CloseInitDialogMsg:
256		a.showInitDialog = false
257		if msg.Initialize {
258			// Run the initialization command
259			for _, cmd := range a.commands {
260				if cmd.ID == "init" {
261					// Mark the project as initialized
262					if err := config.MarkProjectInitialized(); err != nil {
263						return a, util.ReportError(err)
264					}
265					return a, cmd.Handler(cmd)
266				}
267			}
268		} else {
269			// Mark the project as initialized without running the command
270			if err := config.MarkProjectInitialized(); err != nil {
271				return a, util.ReportError(err)
272			}
273		}
274		return a, nil
275
276	case chat.SessionSelectedMsg:
277		a.sessionDialog.SetSelectedSession(msg.ID)
278	case dialog.SessionSelectedMsg:
279		a.showSessionDialog = false
280		if a.currentPage == page.ChatPage {
281			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
282		}
283		return a, nil
284
285	case dialog.CommandSelectedMsg:
286		a.showCommandDialog = false
287		// Execute the command handler if available
288		if msg.Command.Handler != nil {
289			return a, msg.Command.Handler(msg.Command)
290		}
291		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
292
293	case tea.KeyMsg:
294		switch {
295		case key.Matches(msg, keys.Quit):
296			a.showQuit = !a.showQuit
297			if a.showHelp {
298				a.showHelp = false
299			}
300			if a.showSessionDialog {
301				a.showSessionDialog = false
302			}
303			if a.showCommandDialog {
304				a.showCommandDialog = false
305			}
306			return a, nil
307		case key.Matches(msg, keys.SwitchSession):
308			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
309				// Load sessions and show the dialog
310				sessions, err := a.app.Sessions.List(context.Background())
311				if err != nil {
312					return a, util.ReportError(err)
313				}
314				if len(sessions) == 0 {
315					return a, util.ReportWarn("No sessions available")
316				}
317				a.sessionDialog.SetSessions(sessions)
318				a.showSessionDialog = true
319				return a, nil
320			}
321			return a, nil
322		case key.Matches(msg, keys.Commands):
323			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
324				// Show commands dialog
325				if len(a.commands) == 0 {
326					return a, util.ReportWarn("No commands available")
327				}
328				a.commandDialog.SetCommands(a.commands)
329				a.showCommandDialog = true
330				return a, nil
331			}
332			return a, nil
333		case key.Matches(msg, logsKeyReturnKey):
334			if a.currentPage == page.LogsPage {
335				return a, a.moveToPage(page.ChatPage)
336			}
337		case key.Matches(msg, returnKey):
338			if a.showQuit {
339				a.showQuit = !a.showQuit
340				return a, nil
341			}
342			if a.showHelp {
343				a.showHelp = !a.showHelp
344				return a, nil
345			}
346			if a.showInitDialog {
347				a.showInitDialog = false
348				// Mark the project as initialized without running the command
349				if err := config.MarkProjectInitialized(); err != nil {
350					return a, util.ReportError(err)
351				}
352				return a, nil
353			}
354		case key.Matches(msg, keys.Logs):
355			return a, a.moveToPage(page.LogsPage)
356		case key.Matches(msg, keys.Help):
357			if a.showQuit {
358				return a, nil
359			}
360			a.showHelp = !a.showHelp
361			return a, nil
362		case key.Matches(msg, helpEsc):
363			if !a.editingMode {
364				if a.showQuit {
365					return a, nil
366				}
367				a.showHelp = !a.showHelp
368				return a, nil
369			}
370		}
371
372	}
373
374	if a.showQuit {
375		q, quitCmd := a.quit.Update(msg)
376		a.quit = q.(dialog.QuitDialog)
377		cmds = append(cmds, quitCmd)
378		// Only block key messages send all other messages down
379		if _, ok := msg.(tea.KeyMsg); ok {
380			return a, tea.Batch(cmds...)
381		}
382	}
383	if a.showPermissions {
384		d, permissionsCmd := a.permissions.Update(msg)
385		a.permissions = d.(dialog.PermissionDialogCmp)
386		cmds = append(cmds, permissionsCmd)
387		// Only block key messages send all other messages down
388		if _, ok := msg.(tea.KeyMsg); ok {
389			return a, tea.Batch(cmds...)
390		}
391	}
392
393	if a.showSessionDialog {
394		d, sessionCmd := a.sessionDialog.Update(msg)
395		a.sessionDialog = d.(dialog.SessionDialog)
396		cmds = append(cmds, sessionCmd)
397		// Only block key messages send all other messages down
398		if _, ok := msg.(tea.KeyMsg); ok {
399			return a, tea.Batch(cmds...)
400		}
401	}
402
403	if a.showCommandDialog {
404		d, commandCmd := a.commandDialog.Update(msg)
405		a.commandDialog = d.(dialog.CommandDialog)
406		cmds = append(cmds, commandCmd)
407		// Only block key messages send all other messages down
408		if _, ok := msg.(tea.KeyMsg); ok {
409			return a, tea.Batch(cmds...)
410		}
411	}
412
413	if a.showInitDialog {
414		d, initCmd := a.initDialog.Update(msg)
415		a.initDialog = d.(dialog.InitDialogCmp)
416		cmds = append(cmds, initCmd)
417		// Only block key messages send all other messages down
418		if _, ok := msg.(tea.KeyMsg); ok {
419			return a, tea.Batch(cmds...)
420		}
421	}
422
423	s, _ := a.status.Update(msg)
424	a.status = s.(core.StatusCmp)
425	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
426	cmds = append(cmds, cmd)
427	return a, tea.Batch(cmds...)
428}
429
430// RegisterCommand adds a command to the command dialog
431func (a *appModel) RegisterCommand(cmd dialog.Command) {
432	a.commands = append(a.commands, cmd)
433}
434
435func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
436	if a.app.CoderAgent.IsBusy() {
437		// For now we don't move to any page if the agent is busy
438		return util.ReportWarn("Agent is busy, please wait...")
439	}
440	var cmds []tea.Cmd
441	if _, ok := a.loadedPages[pageID]; !ok {
442		cmd := a.pages[pageID].Init()
443		cmds = append(cmds, cmd)
444		a.loadedPages[pageID] = true
445	}
446	a.previousPage = a.currentPage
447	a.currentPage = pageID
448	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
449		cmd := sizable.SetSize(a.width, a.height)
450		cmds = append(cmds, cmd)
451	}
452
453	return tea.Batch(cmds...)
454}
455
456func (a appModel) View() string {
457	components := []string{
458		a.pages[a.currentPage].View(),
459	}
460
461	components = append(components, a.status.View())
462
463	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
464
465	if a.showPermissions {
466		overlay := a.permissions.View()
467		row := lipgloss.Height(appView) / 2
468		row -= lipgloss.Height(overlay) / 2
469		col := lipgloss.Width(appView) / 2
470		col -= lipgloss.Width(overlay) / 2
471		appView = layout.PlaceOverlay(
472			col,
473			row,
474			overlay,
475			appView,
476			true,
477		)
478	}
479
480	if a.editingMode {
481		a.status.SetHelpMsg("ctrl+? help")
482	} else {
483		a.status.SetHelpMsg("? help")
484	}
485
486	if a.showHelp {
487		bindings := layout.KeyMapToSlice(keys)
488		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
489			bindings = append(bindings, p.BindingKeys()...)
490		}
491		if a.showPermissions {
492			bindings = append(bindings, a.permissions.BindingKeys()...)
493		}
494		if a.currentPage == page.LogsPage {
495			bindings = append(bindings, logsKeyReturnKey)
496		}
497		if !a.editingMode {
498			bindings = append(bindings, helpEsc)
499		}
500		a.help.SetBindings(bindings)
501
502		overlay := a.help.View()
503		row := lipgloss.Height(appView) / 2
504		row -= lipgloss.Height(overlay) / 2
505		col := lipgloss.Width(appView) / 2
506		col -= lipgloss.Width(overlay) / 2
507		appView = layout.PlaceOverlay(
508			col,
509			row,
510			overlay,
511			appView,
512			true,
513		)
514	}
515
516	if a.showQuit {
517		overlay := a.quit.View()
518		row := lipgloss.Height(appView) / 2
519		row -= lipgloss.Height(overlay) / 2
520		col := lipgloss.Width(appView) / 2
521		col -= lipgloss.Width(overlay) / 2
522		appView = layout.PlaceOverlay(
523			col,
524			row,
525			overlay,
526			appView,
527			true,
528		)
529	}
530
531	if a.showSessionDialog {
532		overlay := a.sessionDialog.View()
533		row := lipgloss.Height(appView) / 2
534		row -= lipgloss.Height(overlay) / 2
535		col := lipgloss.Width(appView) / 2
536		col -= lipgloss.Width(overlay) / 2
537		appView = layout.PlaceOverlay(
538			col,
539			row,
540			overlay,
541			appView,
542			true,
543		)
544	}
545
546	if a.showCommandDialog {
547		overlay := a.commandDialog.View()
548		row := lipgloss.Height(appView) / 2
549		row -= lipgloss.Height(overlay) / 2
550		col := lipgloss.Width(appView) / 2
551		col -= lipgloss.Width(overlay) / 2
552		appView = layout.PlaceOverlay(
553			col,
554			row,
555			overlay,
556			appView,
557			true,
558		)
559	}
560
561	if a.showInitDialog {
562		overlay := a.initDialog.View()
563		appView = layout.PlaceOverlay(
564			a.width/2-lipgloss.Width(overlay)/2,
565			a.height/2-lipgloss.Height(overlay)/2,
566			overlay,
567			appView,
568			true,
569		)
570	}
571
572	return appView
573}
574
575func New(app *app.App) tea.Model {
576	startPage := page.ChatPage
577	model := &appModel{
578		currentPage:   startPage,
579		loadedPages:   make(map[page.PageID]bool),
580		status:        core.NewStatusCmp(app.LSPClients),
581		help:          dialog.NewHelpCmp(),
582		quit:          dialog.NewQuitCmp(),
583		sessionDialog: dialog.NewSessionDialogCmp(),
584		commandDialog: dialog.NewCommandDialogCmp(),
585		permissions:   dialog.NewPermissionDialogCmp(),
586		initDialog:    dialog.NewInitDialogCmp(),
587		app:           app,
588		editingMode:   true,
589		commands:      []dialog.Command{},
590		pages: map[page.PageID]tea.Model{
591			page.ChatPage: page.NewChatPage(app),
592			page.LogsPage: page.NewLogsPage(),
593		},
594	}
595
596	model.RegisterCommand(dialog.Command{
597		ID:          "init",
598		Title:       "Initialize Project",
599		Description: "Create/Update the OpenCode.md memory file",
600		Handler: func(cmd dialog.Command) tea.Cmd {
601			prompt := `Please analyze this codebase and create a OpenCode.md file containing:
6021. Build/lint/test commands - especially for running a single test
6032. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
604
605The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
606If there's already a opencode.md, improve it.
607If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
608			return tea.Batch(
609				util.CmdHandler(chat.SendMsg{
610					Text: prompt,
611				}),
612			)
613		},
614	})
615	return model
616}