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/logging"
 11	"github.com/kujtimiihoxha/opencode/internal/permission"
 12	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 13	"github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
 14	"github.com/kujtimiihoxha/opencode/internal/tui/components/core"
 15	"github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
 16	"github.com/kujtimiihoxha/opencode/internal/tui/layout"
 17	"github.com/kujtimiihoxha/opencode/internal/tui/page"
 18	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 19)
 20
 21type keyMap struct {
 22	Logs          key.Binding
 23	Quit          key.Binding
 24	Help          key.Binding
 25	SwitchSession key.Binding
 26}
 27
 28var keys = keyMap{
 29	Logs: key.NewBinding(
 30		key.WithKeys("ctrl+l"),
 31		key.WithHelp("ctrl+L", "logs"),
 32	),
 33
 34	Quit: key.NewBinding(
 35		key.WithKeys("ctrl+c"),
 36		key.WithHelp("ctrl+c", "quit"),
 37	),
 38	Help: key.NewBinding(
 39		key.WithKeys("ctrl+_"),
 40		key.WithHelp("ctrl+?", "toggle help"),
 41	),
 42	SwitchSession: key.NewBinding(
 43		key.WithKeys("ctrl+a"),
 44		key.WithHelp("ctrl+a", "switch session"),
 45	),
 46}
 47
 48var returnKey = key.NewBinding(
 49	key.WithKeys("esc"),
 50	key.WithHelp("esc", "close"),
 51)
 52
 53var logsKeyReturnKey = key.NewBinding(
 54	key.WithKeys("backspace"),
 55	key.WithHelp("backspace", "go back"),
 56)
 57
 58type appModel struct {
 59	width, height int
 60	currentPage   page.PageID
 61	previousPage  page.PageID
 62	pages         map[page.PageID]tea.Model
 63	loadedPages   map[page.PageID]bool
 64	status        tea.Model
 65	app           *app.App
 66
 67	showPermissions bool
 68	permissions     dialog.PermissionDialogCmp
 69
 70	showHelp bool
 71	help     dialog.HelpCmp
 72
 73	showQuit bool
 74	quit     dialog.QuitDialog
 75
 76	showSessionDialog bool
 77	sessionDialog     dialog.SessionDialog
 78}
 79
 80func (a appModel) Init() tea.Cmd {
 81	var cmds []tea.Cmd
 82	cmd := a.pages[a.currentPage].Init()
 83	a.loadedPages[a.currentPage] = true
 84	cmds = append(cmds, cmd)
 85	cmd = a.status.Init()
 86	cmds = append(cmds, cmd)
 87	cmd = a.quit.Init()
 88	cmds = append(cmds, cmd)
 89	cmd = a.help.Init()
 90	cmds = append(cmds, cmd)
 91	cmd = a.sessionDialog.Init()
 92	cmds = append(cmds, cmd)
 93	return tea.Batch(cmds...)
 94}
 95
 96func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 97	var cmds []tea.Cmd
 98	var cmd tea.Cmd
 99	switch msg := msg.(type) {
100	case tea.WindowSizeMsg:
101		msg.Height -= 1 // Make space for the status bar
102		a.width, a.height = msg.Width, msg.Height
103
104		a.status, _ = a.status.Update(msg)
105		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
106		cmds = append(cmds, cmd)
107
108		prm, permCmd := a.permissions.Update(msg)
109		a.permissions = prm.(dialog.PermissionDialogCmp)
110		cmds = append(cmds, permCmd)
111
112		help, helpCmd := a.help.Update(msg)
113		a.help = help.(dialog.HelpCmp)
114		cmds = append(cmds, helpCmd)
115
116		session, sessionCmd := a.sessionDialog.Update(msg)
117		a.sessionDialog = session.(dialog.SessionDialog)
118		cmds = append(cmds, sessionCmd)
119
120		return a, tea.Batch(cmds...)
121
122	// Status
123	case util.InfoMsg:
124		a.status, cmd = a.status.Update(msg)
125		cmds = append(cmds, cmd)
126		return a, tea.Batch(cmds...)
127	case pubsub.Event[logging.LogMessage]:
128		if msg.Payload.Persist {
129			switch msg.Payload.Level {
130			case "error":
131				a.status, cmd = a.status.Update(util.InfoMsg{
132					Type: util.InfoTypeError,
133					Msg:  msg.Payload.Message,
134					TTL:  msg.Payload.PersistTime,
135				})
136			case "info":
137				a.status, cmd = a.status.Update(util.InfoMsg{
138					Type: util.InfoTypeInfo,
139					Msg:  msg.Payload.Message,
140					TTL:  msg.Payload.PersistTime,
141				})
142			case "warn":
143				a.status, cmd = a.status.Update(util.InfoMsg{
144					Type: util.InfoTypeWarn,
145					Msg:  msg.Payload.Message,
146					TTL:  msg.Payload.PersistTime,
147				})
148
149			default:
150				a.status, cmd = a.status.Update(util.InfoMsg{
151					Type: util.InfoTypeInfo,
152					Msg:  msg.Payload.Message,
153					TTL:  msg.Payload.PersistTime,
154				})
155			}
156			cmds = append(cmds, cmd)
157		}
158	case util.ClearStatusMsg:
159		a.status, _ = a.status.Update(msg)
160
161	// Permission
162	case pubsub.Event[permission.PermissionRequest]:
163		a.showPermissions = true
164		return a, a.permissions.SetPermissions(msg.Payload)
165	case dialog.PermissionResponseMsg:
166		var cmd tea.Cmd
167		switch msg.Action {
168		case dialog.PermissionAllow:
169			a.app.Permissions.Grant(msg.Permission)
170		case dialog.PermissionAllowForSession:
171			a.app.Permissions.GrantPersistant(msg.Permission)
172		case dialog.PermissionDeny:
173			a.app.Permissions.Deny(msg.Permission)
174			cmd = util.CmdHandler(chat.FocusEditorMsg(true))
175		}
176		a.showPermissions = false
177		return a, cmd
178
179	case page.PageChangeMsg:
180		return a, a.moveToPage(msg.ID)
181
182	case dialog.CloseQuitMsg:
183		a.showQuit = false
184		return a, nil
185
186	case dialog.CloseSessionDialogMsg:
187		a.showSessionDialog = false
188		return a, nil
189
190	case chat.SessionSelectedMsg:
191		a.sessionDialog.SetSelectedSession(msg.ID)
192	case dialog.SessionSelectedMsg:
193		a.showSessionDialog = false
194		if a.currentPage == page.ChatPage {
195			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
196		}
197		return a, nil
198
199	case tea.KeyMsg:
200		switch {
201		case key.Matches(msg, keys.Quit):
202			a.showQuit = !a.showQuit
203			if a.showHelp {
204				a.showHelp = false
205			}
206			if a.showSessionDialog {
207				a.showSessionDialog = false
208			}
209			return a, nil
210		case key.Matches(msg, keys.SwitchSession):
211			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
212				// Load sessions and show the dialog
213				sessions, err := a.app.Sessions.List(context.Background())
214				if err != nil {
215					return a, util.ReportError(err)
216				}
217				if len(sessions) == 0 {
218					return a, util.ReportWarn("No sessions available")
219				}
220				a.sessionDialog.SetSessions(sessions)
221				a.showSessionDialog = true
222				return a, nil
223			}
224			return a, nil
225		case key.Matches(msg, logsKeyReturnKey):
226			if a.currentPage == page.LogsPage {
227				return a, a.moveToPage(page.ChatPage)
228			}
229		case key.Matches(msg, returnKey):
230			if a.showQuit {
231				a.showQuit = !a.showQuit
232				return a, nil
233			}
234			if a.showHelp {
235				a.showHelp = !a.showHelp
236				return a, nil
237			}
238		case key.Matches(msg, keys.Logs):
239			return a, a.moveToPage(page.LogsPage)
240		case key.Matches(msg, keys.Help):
241			if a.showQuit {
242				return a, nil
243			}
244			a.showHelp = !a.showHelp
245			return a, nil
246		}
247	}
248
249	if a.showQuit {
250		q, quitCmd := a.quit.Update(msg)
251		a.quit = q.(dialog.QuitDialog)
252		cmds = append(cmds, quitCmd)
253		// Only block key messages send all other messages down
254		if _, ok := msg.(tea.KeyMsg); ok {
255			return a, tea.Batch(cmds...)
256		}
257	}
258	if a.showPermissions {
259		d, permissionsCmd := a.permissions.Update(msg)
260		a.permissions = d.(dialog.PermissionDialogCmp)
261		cmds = append(cmds, permissionsCmd)
262		// Only block key messages send all other messages down
263		if _, ok := msg.(tea.KeyMsg); ok {
264			return a, tea.Batch(cmds...)
265		}
266	}
267
268	if a.showSessionDialog {
269		d, sessionCmd := a.sessionDialog.Update(msg)
270		a.sessionDialog = d.(dialog.SessionDialog)
271		cmds = append(cmds, sessionCmd)
272		// Only block key messages send all other messages down
273		if _, ok := msg.(tea.KeyMsg); ok {
274			return a, tea.Batch(cmds...)
275		}
276	}
277
278	a.status, _ = a.status.Update(msg)
279	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
280	cmds = append(cmds, cmd)
281	return a, tea.Batch(cmds...)
282}
283
284func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
285	if a.app.CoderAgent.IsBusy() {
286		// For now we don't move to any page if the agent is busy
287		return util.ReportWarn("Agent is busy, please wait...")
288	}
289	var cmds []tea.Cmd
290	if _, ok := a.loadedPages[pageID]; !ok {
291		cmd := a.pages[pageID].Init()
292		cmds = append(cmds, cmd)
293		a.loadedPages[pageID] = true
294	}
295	a.previousPage = a.currentPage
296	a.currentPage = pageID
297	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
298		cmd := sizable.SetSize(a.width, a.height)
299		cmds = append(cmds, cmd)
300	}
301
302	return tea.Batch(cmds...)
303}
304
305func (a appModel) View() string {
306	components := []string{
307		a.pages[a.currentPage].View(),
308	}
309
310	components = append(components, a.status.View())
311
312	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
313
314	if a.showPermissions {
315		overlay := a.permissions.View()
316		row := lipgloss.Height(appView) / 2
317		row -= lipgloss.Height(overlay) / 2
318		col := lipgloss.Width(appView) / 2
319		col -= lipgloss.Width(overlay) / 2
320		appView = layout.PlaceOverlay(
321			col,
322			row,
323			overlay,
324			appView,
325			true,
326		)
327	}
328
329	if a.showHelp {
330		bindings := layout.KeyMapToSlice(keys)
331		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
332			bindings = append(bindings, p.BindingKeys()...)
333		}
334		if a.showPermissions {
335			bindings = append(bindings, a.permissions.BindingKeys()...)
336		}
337		if a.currentPage == page.LogsPage {
338			bindings = append(bindings, logsKeyReturnKey)
339		}
340
341		a.help.SetBindings(bindings)
342
343		overlay := a.help.View()
344		row := lipgloss.Height(appView) / 2
345		row -= lipgloss.Height(overlay) / 2
346		col := lipgloss.Width(appView) / 2
347		col -= lipgloss.Width(overlay) / 2
348		appView = layout.PlaceOverlay(
349			col,
350			row,
351			overlay,
352			appView,
353			true,
354		)
355	}
356
357	if a.showQuit {
358		overlay := a.quit.View()
359		row := lipgloss.Height(appView) / 2
360		row -= lipgloss.Height(overlay) / 2
361		col := lipgloss.Width(appView) / 2
362		col -= lipgloss.Width(overlay) / 2
363		appView = layout.PlaceOverlay(
364			col,
365			row,
366			overlay,
367			appView,
368			true,
369		)
370	}
371
372	if a.showSessionDialog {
373		overlay := a.sessionDialog.View()
374		row := lipgloss.Height(appView) / 2
375		row -= lipgloss.Height(overlay) / 2
376		col := lipgloss.Width(appView) / 2
377		col -= lipgloss.Width(overlay) / 2
378		appView = layout.PlaceOverlay(
379			col,
380			row,
381			overlay,
382			appView,
383			true,
384		)
385	}
386
387	return appView
388}
389
390func New(app *app.App) tea.Model {
391	startPage := page.ChatPage
392	return &appModel{
393		currentPage:   startPage,
394		loadedPages:   make(map[page.PageID]bool),
395		status:        core.NewStatusCmp(app.LSPClients),
396		help:          dialog.NewHelpCmp(),
397		quit:          dialog.NewQuitCmp(),
398		sessionDialog: dialog.NewSessionDialogCmp(),
399		permissions:   dialog.NewPermissionDialogCmp(),
400		app:           app,
401		pages: map[page.PageID]tea.Model{
402			page.ChatPage: page.NewChatPage(app),
403			page.LogsPage: page.NewLogsPage(),
404		},
405	}
406}