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/opencode-ai/opencode/internal/app"
 10	"github.com/opencode-ai/opencode/internal/config"
 11	"github.com/opencode-ai/opencode/internal/logging"
 12	"github.com/opencode-ai/opencode/internal/permission"
 13	"github.com/opencode-ai/opencode/internal/pubsub"
 14	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 15	"github.com/opencode-ai/opencode/internal/tui/components/core"
 16	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 17	"github.com/opencode-ai/opencode/internal/tui/layout"
 18	"github.com/opencode-ai/opencode/internal/tui/page"
 19	"github.com/opencode-ai/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		}
228		a.showPermissions = false
229		return a, cmd
230
231	case page.PageChangeMsg:
232		return a, a.moveToPage(msg.ID)
233
234	case dialog.CloseQuitMsg:
235		a.showQuit = false
236		return a, nil
237
238	case dialog.CloseSessionDialogMsg:
239		a.showSessionDialog = false
240		return a, nil
241
242	case dialog.CloseCommandDialogMsg:
243		a.showCommandDialog = false
244		return a, nil
245
246	case dialog.ShowInitDialogMsg:
247		a.showInitDialog = msg.Show
248		return a, nil
249
250	case dialog.CloseInitDialogMsg:
251		a.showInitDialog = false
252		if msg.Initialize {
253			// Run the initialization command
254			for _, cmd := range a.commands {
255				if cmd.ID == "init" {
256					// Mark the project as initialized
257					if err := config.MarkProjectInitialized(); err != nil {
258						return a, util.ReportError(err)
259					}
260					return a, cmd.Handler(cmd)
261				}
262			}
263		} else {
264			// Mark the project as initialized without running the command
265			if err := config.MarkProjectInitialized(); err != nil {
266				return a, util.ReportError(err)
267			}
268		}
269		return a, nil
270
271	case chat.SessionSelectedMsg:
272		a.sessionDialog.SetSelectedSession(msg.ID)
273	case dialog.SessionSelectedMsg:
274		a.showSessionDialog = false
275		if a.currentPage == page.ChatPage {
276			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
277		}
278		return a, nil
279
280	case dialog.CommandSelectedMsg:
281		a.showCommandDialog = false
282		// Execute the command handler if available
283		if msg.Command.Handler != nil {
284			return a, msg.Command.Handler(msg.Command)
285		}
286		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
287
288	case tea.KeyMsg:
289		switch {
290		case key.Matches(msg, keys.Quit):
291			a.showQuit = !a.showQuit
292			if a.showHelp {
293				a.showHelp = false
294			}
295			if a.showSessionDialog {
296				a.showSessionDialog = false
297			}
298			if a.showCommandDialog {
299				a.showCommandDialog = false
300			}
301			return a, nil
302		case key.Matches(msg, keys.SwitchSession):
303			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
304				// Load sessions and show the dialog
305				sessions, err := a.app.Sessions.List(context.Background())
306				if err != nil {
307					return a, util.ReportError(err)
308				}
309				if len(sessions) == 0 {
310					return a, util.ReportWarn("No sessions available")
311				}
312				a.sessionDialog.SetSessions(sessions)
313				a.showSessionDialog = true
314				return a, nil
315			}
316			return a, nil
317		case key.Matches(msg, keys.Commands):
318			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
319				// Show commands dialog
320				if len(a.commands) == 0 {
321					return a, util.ReportWarn("No commands available")
322				}
323				a.commandDialog.SetCommands(a.commands)
324				a.showCommandDialog = true
325				return a, nil
326			}
327			return a, nil
328		case key.Matches(msg, logsKeyReturnKey):
329			if a.currentPage == page.LogsPage {
330				return a, a.moveToPage(page.ChatPage)
331			}
332		case key.Matches(msg, returnKey):
333			if a.showQuit {
334				a.showQuit = !a.showQuit
335				return a, nil
336			}
337			if a.showHelp {
338				a.showHelp = !a.showHelp
339				return a, nil
340			}
341			if a.showInitDialog {
342				a.showInitDialog = false
343				// Mark the project as initialized without running the command
344				if err := config.MarkProjectInitialized(); err != nil {
345					return a, util.ReportError(err)
346				}
347				return a, nil
348			}
349		case key.Matches(msg, keys.Logs):
350			return a, a.moveToPage(page.LogsPage)
351		case key.Matches(msg, keys.Help):
352			if a.showQuit {
353				return a, nil
354			}
355			a.showHelp = !a.showHelp
356			return a, nil
357		case key.Matches(msg, helpEsc):
358			if a.app.CoderAgent.IsBusy() {
359				if a.showQuit {
360					return a, nil
361				}
362				a.showHelp = !a.showHelp
363				return a, nil
364			}
365		}
366
367	}
368
369	if a.showQuit {
370		q, quitCmd := a.quit.Update(msg)
371		a.quit = q.(dialog.QuitDialog)
372		cmds = append(cmds, quitCmd)
373		// Only block key messages send all other messages down
374		if _, ok := msg.(tea.KeyMsg); ok {
375			return a, tea.Batch(cmds...)
376		}
377	}
378	if a.showPermissions {
379		d, permissionsCmd := a.permissions.Update(msg)
380		a.permissions = d.(dialog.PermissionDialogCmp)
381		cmds = append(cmds, permissionsCmd)
382		// Only block key messages send all other messages down
383		if _, ok := msg.(tea.KeyMsg); ok {
384			return a, tea.Batch(cmds...)
385		}
386	}
387
388	if a.showSessionDialog {
389		d, sessionCmd := a.sessionDialog.Update(msg)
390		a.sessionDialog = d.(dialog.SessionDialog)
391		cmds = append(cmds, sessionCmd)
392		// Only block key messages send all other messages down
393		if _, ok := msg.(tea.KeyMsg); ok {
394			return a, tea.Batch(cmds...)
395		}
396	}
397
398	if a.showCommandDialog {
399		d, commandCmd := a.commandDialog.Update(msg)
400		a.commandDialog = d.(dialog.CommandDialog)
401		cmds = append(cmds, commandCmd)
402		// Only block key messages send all other messages down
403		if _, ok := msg.(tea.KeyMsg); ok {
404			return a, tea.Batch(cmds...)
405		}
406	}
407
408	if a.showInitDialog {
409		d, initCmd := a.initDialog.Update(msg)
410		a.initDialog = d.(dialog.InitDialogCmp)
411		cmds = append(cmds, initCmd)
412		// Only block key messages send all other messages down
413		if _, ok := msg.(tea.KeyMsg); ok {
414			return a, tea.Batch(cmds...)
415		}
416	}
417
418	s, _ := a.status.Update(msg)
419	a.status = s.(core.StatusCmp)
420	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
421	cmds = append(cmds, cmd)
422	return a, tea.Batch(cmds...)
423}
424
425// RegisterCommand adds a command to the command dialog
426func (a *appModel) RegisterCommand(cmd dialog.Command) {
427	a.commands = append(a.commands, cmd)
428}
429
430func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
431	if a.app.CoderAgent.IsBusy() {
432		// For now we don't move to any page if the agent is busy
433		return util.ReportWarn("Agent is busy, please wait...")
434	}
435	var cmds []tea.Cmd
436	if _, ok := a.loadedPages[pageID]; !ok {
437		cmd := a.pages[pageID].Init()
438		cmds = append(cmds, cmd)
439		a.loadedPages[pageID] = true
440	}
441	a.previousPage = a.currentPage
442	a.currentPage = pageID
443	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
444		cmd := sizable.SetSize(a.width, a.height)
445		cmds = append(cmds, cmd)
446	}
447
448	return tea.Batch(cmds...)
449}
450
451func (a appModel) View() string {
452	components := []string{
453		a.pages[a.currentPage].View(),
454	}
455
456	components = append(components, a.status.View())
457
458	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
459
460	if a.showPermissions {
461		overlay := a.permissions.View()
462		row := lipgloss.Height(appView) / 2
463		row -= lipgloss.Height(overlay) / 2
464		col := lipgloss.Width(appView) / 2
465		col -= lipgloss.Width(overlay) / 2
466		appView = layout.PlaceOverlay(
467			col,
468			row,
469			overlay,
470			appView,
471			true,
472		)
473	}
474
475	if !a.app.CoderAgent.IsBusy() {
476		a.status.SetHelpMsg("ctrl+? help")
477	} else {
478		a.status.SetHelpMsg("? help")
479	}
480
481	if a.showHelp {
482		bindings := layout.KeyMapToSlice(keys)
483		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
484			bindings = append(bindings, p.BindingKeys()...)
485		}
486		if a.showPermissions {
487			bindings = append(bindings, a.permissions.BindingKeys()...)
488		}
489		if a.currentPage == page.LogsPage {
490			bindings = append(bindings, logsKeyReturnKey)
491		}
492		if !a.app.CoderAgent.IsBusy() {
493			bindings = append(bindings, helpEsc)
494		}
495		a.help.SetBindings(bindings)
496
497		overlay := a.help.View()
498		row := lipgloss.Height(appView) / 2
499		row -= lipgloss.Height(overlay) / 2
500		col := lipgloss.Width(appView) / 2
501		col -= lipgloss.Width(overlay) / 2
502		appView = layout.PlaceOverlay(
503			col,
504			row,
505			overlay,
506			appView,
507			true,
508		)
509	}
510
511	if a.showQuit {
512		overlay := a.quit.View()
513		row := lipgloss.Height(appView) / 2
514		row -= lipgloss.Height(overlay) / 2
515		col := lipgloss.Width(appView) / 2
516		col -= lipgloss.Width(overlay) / 2
517		appView = layout.PlaceOverlay(
518			col,
519			row,
520			overlay,
521			appView,
522			true,
523		)
524	}
525
526	if a.showSessionDialog {
527		overlay := a.sessionDialog.View()
528		row := lipgloss.Height(appView) / 2
529		row -= lipgloss.Height(overlay) / 2
530		col := lipgloss.Width(appView) / 2
531		col -= lipgloss.Width(overlay) / 2
532		appView = layout.PlaceOverlay(
533			col,
534			row,
535			overlay,
536			appView,
537			true,
538		)
539	}
540
541	if a.showCommandDialog {
542		overlay := a.commandDialog.View()
543		row := lipgloss.Height(appView) / 2
544		row -= lipgloss.Height(overlay) / 2
545		col := lipgloss.Width(appView) / 2
546		col -= lipgloss.Width(overlay) / 2
547		appView = layout.PlaceOverlay(
548			col,
549			row,
550			overlay,
551			appView,
552			true,
553		)
554	}
555
556	if a.showInitDialog {
557		overlay := a.initDialog.View()
558		appView = layout.PlaceOverlay(
559			a.width/2-lipgloss.Width(overlay)/2,
560			a.height/2-lipgloss.Height(overlay)/2,
561			overlay,
562			appView,
563			true,
564		)
565	}
566
567	return appView
568}
569
570func New(app *app.App) tea.Model {
571	startPage := page.ChatPage
572	model := &appModel{
573		currentPage:   startPage,
574		loadedPages:   make(map[page.PageID]bool),
575		status:        core.NewStatusCmp(app.LSPClients),
576		help:          dialog.NewHelpCmp(),
577		quit:          dialog.NewQuitCmp(),
578		sessionDialog: dialog.NewSessionDialogCmp(),
579		commandDialog: dialog.NewCommandDialogCmp(),
580		permissions:   dialog.NewPermissionDialogCmp(),
581		initDialog:    dialog.NewInitDialogCmp(),
582		app:           app,
583		commands:      []dialog.Command{},
584		pages: map[page.PageID]tea.Model{
585			page.ChatPage: page.NewChatPage(app),
586			page.LogsPage: page.NewLogsPage(),
587		},
588	}
589
590	model.RegisterCommand(dialog.Command{
591		ID:          "init",
592		Title:       "Initialize Project",
593		Description: "Create/Update the OpenCode.md memory file",
594		Handler: func(cmd dialog.Command) tea.Cmd {
595			prompt := `Please analyze this codebase and create a OpenCode.md file containing:
5961. Build/lint/test commands - especially for running a single test
5972. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
598
599The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
600If there's already a opencode.md, improve it.
601If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
602			return tea.Batch(
603				util.CmdHandler(chat.SendMsg{
604					Text: prompt,
605				}),
606			)
607		},
608	})
609	return model
610}