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