tui.go

  1package tui
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/key"
  5	tea "github.com/charmbracelet/bubbletea/v2"
  6	"github.com/charmbracelet/lipgloss/v2"
  7	"github.com/opencode-ai/opencode/internal/app"
  8	"github.com/opencode-ai/opencode/internal/logging"
  9	"github.com/opencode-ai/opencode/internal/pubsub"
 10	"github.com/opencode-ai/opencode/internal/tui/components/core"
 11	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 12	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
 13	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
 14	"github.com/opencode-ai/opencode/internal/tui/layout"
 15	"github.com/opencode-ai/opencode/internal/tui/page"
 16	"github.com/opencode-ai/opencode/internal/tui/theme"
 17	"github.com/opencode-ai/opencode/internal/tui/util"
 18)
 19
 20type appModel struct {
 21	width, height int
 22	keyMap        KeyMap
 23
 24	currentPage  page.PageID
 25	previousPage page.PageID
 26	pages        map[page.PageID]util.Model
 27	loadedPages  map[page.PageID]bool
 28
 29	status core.StatusCmp
 30
 31	app *app.App
 32
 33	dialog dialogs.DialogCmp
 34}
 35
 36func (a appModel) Init() tea.Cmd {
 37	var cmds []tea.Cmd
 38	cmd := a.pages[a.currentPage].Init()
 39	cmds = append(cmds, cmd)
 40	a.loadedPages[a.currentPage] = true
 41
 42	cmd = a.status.Init()
 43	cmds = append(cmds, cmd)
 44	return tea.Batch(cmds...)
 45}
 46
 47func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 48	var cmds []tea.Cmd
 49	var cmd tea.Cmd
 50
 51	switch msg := msg.(type) {
 52	case tea.WindowSizeMsg:
 53		return a, a.handleWindowResize(msg)
 54
 55	// Dialog messages
 56	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
 57		u, dialogCmd := a.dialog.Update(msg)
 58		a.dialog = u.(dialogs.DialogCmp)
 59		return a, dialogCmd
 60	case commands.ShowArgumentsDialogMsg:
 61		return a, util.CmdHandler(
 62			dialogs.OpenDialogMsg{
 63				Model: commands.NewCommandArgumentsDialog(
 64					msg.CommandID,
 65					msg.Content,
 66					msg.ArgNames,
 67				),
 68			},
 69		)
 70	// Page change messages
 71	case page.PageChangeMsg:
 72		return a, a.moveToPage(msg.ID)
 73
 74	// Status Messages
 75	case util.InfoMsg, util.ClearStatusMsg:
 76		s, statusCmd := a.status.Update(msg)
 77		a.status = s.(core.StatusCmp)
 78		cmds = append(cmds, statusCmd)
 79		return a, tea.Batch(cmds...)
 80
 81	// Logs
 82	case pubsub.Event[logging.LogMessage]:
 83		// Send to the status component
 84		s, statusCmd := a.status.Update(msg)
 85		a.status = s.(core.StatusCmp)
 86		cmds = append(cmds, statusCmd)
 87
 88		// If the current page is logs, update the logs view
 89		if a.currentPage == page.LogsPage {
 90			updated, pageCmd := a.pages[a.currentPage].Update(msg)
 91			a.pages[a.currentPage] = updated.(util.Model)
 92			cmds = append(cmds, pageCmd)
 93		}
 94		return a, tea.Batch(cmds...)
 95	case tea.KeyPressMsg:
 96		return a, a.handleKeyPressMsg(msg)
 97	}
 98	s, _ := a.status.Update(msg)
 99	a.status = s.(core.StatusCmp)
100	updated, cmd := a.pages[a.currentPage].Update(msg)
101	a.pages[a.currentPage] = updated.(util.Model)
102	cmds = append(cmds, cmd)
103	return a, tea.Batch(cmds...)
104}
105
106func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
107	var cmds []tea.Cmd
108	msg.Height -= 1 // Make space for the status bar
109	a.width, a.height = msg.Width, msg.Height
110
111	// Update status bar
112	s, cmd := a.status.Update(msg)
113	a.status = s.(core.StatusCmp)
114	cmds = append(cmds, cmd)
115
116	// Update the current page
117	updated, cmd := a.pages[a.currentPage].Update(msg)
118	a.pages[a.currentPage] = updated.(util.Model)
119	cmds = append(cmds, cmd)
120
121	// Update the dialogs
122	dialog, cmd := a.dialog.Update(msg)
123	a.dialog = dialog.(dialogs.DialogCmp)
124	cmds = append(cmds, cmd)
125
126	return tea.Batch(cmds...)
127}
128
129func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
130	switch {
131	// dialogs
132	case key.Matches(msg, a.keyMap.Quit):
133		return util.CmdHandler(dialogs.OpenDialogMsg{
134			Model: quit.NewQuitDialog(),
135		})
136
137	case key.Matches(msg, a.keyMap.Commands):
138		return util.CmdHandler(dialogs.OpenDialogMsg{
139			Model: commands.NewCommandDialog(),
140		})
141
142	// Page navigation
143	case key.Matches(msg, a.keyMap.Logs):
144		return a.moveToPage(page.LogsPage)
145
146	default:
147		if a.dialog.HasDialogs() {
148			u, dialogCmd := a.dialog.Update(msg)
149			a.dialog = u.(dialogs.DialogCmp)
150			return dialogCmd
151		} else {
152			updated, cmd := a.pages[a.currentPage].Update(msg)
153			a.pages[a.currentPage] = updated.(util.Model)
154			return cmd
155		}
156	}
157}
158
159// RegisterCommand adds a command to the command dialog
160// func (a *appModel) RegisterCommand(cmd dialog.Command) {
161// 	a.commands = append(a.commands, cmd)
162// }
163
164func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
165	if a.app.CoderAgent.IsBusy() {
166		// For now we don't move to any page if the agent is busy
167		return util.ReportWarn("Agent is busy, please wait...")
168	}
169
170	var cmds []tea.Cmd
171	if _, ok := a.loadedPages[pageID]; !ok {
172		cmd := a.pages[pageID].Init()
173		cmds = append(cmds, cmd)
174		a.loadedPages[pageID] = true
175	}
176	a.previousPage = a.currentPage
177	a.currentPage = pageID
178	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
179		cmd := sizable.SetSize(a.width, a.height)
180		cmds = append(cmds, cmd)
181	}
182
183	return tea.Batch(cmds...)
184}
185
186func (a *appModel) View() tea.View {
187	pageView := a.pages[a.currentPage].View()
188	components := []string{
189		pageView.String(),
190	}
191	components = append(components, a.status.View().String())
192
193	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
194
195	t := theme.CurrentTheme()
196	if a.dialog.HasDialogs() {
197		layers := append(
198			[]*lipgloss.Layer{
199				lipgloss.NewLayer(appView),
200			},
201			a.dialog.GetLayers()...,
202		)
203		canvas := lipgloss.NewCanvas(
204			layers...,
205		)
206		view := tea.NewView(canvas.Render())
207		activeView := a.dialog.ActiveView()
208		view.SetBackgroundColor(t.Background())
209		view.SetCursor(activeView.Cursor())
210		return view
211	}
212
213	view := tea.NewView(appView)
214	view.SetCursor(pageView.Cursor())
215	view.SetBackgroundColor(t.Background())
216	return view
217}
218
219func New(app *app.App) tea.Model {
220	startPage := page.ChatPage
221	model := &appModel{
222		currentPage: startPage,
223		app:         app,
224		status:      core.NewStatusCmp(app.LSPClients),
225		loadedPages: make(map[page.PageID]bool),
226		keyMap:      DefaultKeyMap(),
227
228		pages: map[page.PageID]util.Model{
229			page.ChatPage: page.NewChatPage(app),
230			page.LogsPage: page.NewLogsPage(),
231		},
232
233		dialog: dialogs.NewDialogCmp(),
234	}
235
236	return model
237}