tui.go

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