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		switch msg.Action {
167		case dialog.PermissionAllow:
168			a.app.Permissions.Grant(msg.Permission)
169		case dialog.PermissionAllowForSession:
170			a.app.Permissions.GrantPersistant(msg.Permission)
171		case dialog.PermissionDeny:
172			a.app.Permissions.Deny(msg.Permission)
173		}
174		a.showPermissions = false
175		return a, nil
176
177	case page.PageChangeMsg:
178		return a, a.moveToPage(msg.ID)
179
180	case dialog.CloseQuitMsg:
181		a.showQuit = false
182		return a, nil
183
184	case dialog.CloseSessionDialogMsg:
185		a.showSessionDialog = false
186		return a, nil
187
188	case chat.SessionSelectedMsg:
189		a.sessionDialog.SetSelectedSession(msg.ID)
190	case dialog.SessionSelectedMsg:
191		a.showSessionDialog = false
192		if a.currentPage == page.ChatPage {
193			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
194		}
195		return a, nil
196
197	case tea.KeyMsg:
198		switch {
199		case key.Matches(msg, keys.Quit):
200			a.showQuit = !a.showQuit
201			if a.showHelp {
202				a.showHelp = false
203			}
204			if a.showSessionDialog {
205				a.showSessionDialog = false
206			}
207			return a, nil
208		case key.Matches(msg, keys.SwitchSession):
209			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions {
210				// Load sessions and show the dialog
211				sessions, err := a.app.Sessions.List(context.Background())
212				if err != nil {
213					return a, util.ReportError(err)
214				}
215				if len(sessions) == 0 {
216					return a, util.ReportWarn("No sessions available")
217				}
218				a.sessionDialog.SetSessions(sessions)
219				a.showSessionDialog = true
220				return a, nil
221			}
222			return a, nil
223		case key.Matches(msg, logsKeyReturnKey):
224			if a.currentPage == page.LogsPage {
225				return a, a.moveToPage(page.ChatPage)
226			}
227		case key.Matches(msg, returnKey):
228			if a.showQuit {
229				a.showQuit = !a.showQuit
230				return a, nil
231			}
232			if a.showHelp {
233				a.showHelp = !a.showHelp
234				return a, nil
235			}
236		case key.Matches(msg, keys.Logs):
237			return a, a.moveToPage(page.LogsPage)
238		case key.Matches(msg, keys.Help):
239			if a.showQuit {
240				return a, nil
241			}
242			a.showHelp = !a.showHelp
243			return a, nil
244		}
245	}
246
247	if a.showQuit {
248		q, quitCmd := a.quit.Update(msg)
249		a.quit = q.(dialog.QuitDialog)
250		cmds = append(cmds, quitCmd)
251		// Only block key messages send all other messages down
252		if _, ok := msg.(tea.KeyMsg); ok {
253			return a, tea.Batch(cmds...)
254		}
255	}
256	if a.showPermissions {
257		d, permissionsCmd := a.permissions.Update(msg)
258		a.permissions = d.(dialog.PermissionDialogCmp)
259		cmds = append(cmds, permissionsCmd)
260		// Only block key messages send all other messages down
261		if _, ok := msg.(tea.KeyMsg); ok {
262			return a, tea.Batch(cmds...)
263		}
264	}
265
266	if a.showSessionDialog {
267		d, sessionCmd := a.sessionDialog.Update(msg)
268		a.sessionDialog = d.(dialog.SessionDialog)
269		cmds = append(cmds, sessionCmd)
270		// Only block key messages send all other messages down
271		if _, ok := msg.(tea.KeyMsg); ok {
272			return a, tea.Batch(cmds...)
273		}
274	}
275
276	a.status, _ = a.status.Update(msg)
277	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
278	cmds = append(cmds, cmd)
279	return a, tea.Batch(cmds...)
280}
281
282func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
283	if a.app.CoderAgent.IsBusy() {
284		// For now we don't move to any page if the agent is busy
285		return util.ReportWarn("Agent is busy, please wait...")
286	}
287	var cmds []tea.Cmd
288	if _, ok := a.loadedPages[pageID]; !ok {
289		cmd := a.pages[pageID].Init()
290		cmds = append(cmds, cmd)
291		a.loadedPages[pageID] = true
292	}
293	a.previousPage = a.currentPage
294	a.currentPage = pageID
295	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
296		cmd := sizable.SetSize(a.width, a.height)
297		cmds = append(cmds, cmd)
298	}
299
300	return tea.Batch(cmds...)
301}
302
303func (a appModel) View() string {
304	components := []string{
305		a.pages[a.currentPage].View(),
306	}
307
308	components = append(components, a.status.View())
309
310	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
311
312	if a.showPermissions {
313		overlay := a.permissions.View()
314		row := lipgloss.Height(appView) / 2
315		row -= lipgloss.Height(overlay) / 2
316		col := lipgloss.Width(appView) / 2
317		col -= lipgloss.Width(overlay) / 2
318		appView = layout.PlaceOverlay(
319			col,
320			row,
321			overlay,
322			appView,
323			true,
324		)
325	}
326
327	if a.showHelp {
328		bindings := layout.KeyMapToSlice(keys)
329		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
330			bindings = append(bindings, p.BindingKeys()...)
331		}
332		if a.showPermissions {
333			bindings = append(bindings, a.permissions.BindingKeys()...)
334		}
335		if a.currentPage == page.LogsPage {
336			bindings = append(bindings, logsKeyReturnKey)
337		}
338
339		a.help.SetBindings(bindings)
340
341		overlay := a.help.View()
342		row := lipgloss.Height(appView) / 2
343		row -= lipgloss.Height(overlay) / 2
344		col := lipgloss.Width(appView) / 2
345		col -= lipgloss.Width(overlay) / 2
346		appView = layout.PlaceOverlay(
347			col,
348			row,
349			overlay,
350			appView,
351			true,
352		)
353	}
354
355	if a.showQuit {
356		overlay := a.quit.View()
357		row := lipgloss.Height(appView) / 2
358		row -= lipgloss.Height(overlay) / 2
359		col := lipgloss.Width(appView) / 2
360		col -= lipgloss.Width(overlay) / 2
361		appView = layout.PlaceOverlay(
362			col,
363			row,
364			overlay,
365			appView,
366			true,
367		)
368	}
369
370	if a.showSessionDialog {
371		overlay := a.sessionDialog.View()
372		row := lipgloss.Height(appView) / 2
373		row -= lipgloss.Height(overlay) / 2
374		col := lipgloss.Width(appView) / 2
375		col -= lipgloss.Width(overlay) / 2
376		appView = layout.PlaceOverlay(
377			col,
378			row,
379			overlay,
380			appView,
381			true,
382		)
383	}
384
385	return appView
386}
387
388func New(app *app.App) tea.Model {
389	startPage := page.ChatPage
390	return &appModel{
391		currentPage:   startPage,
392		loadedPages:   make(map[page.PageID]bool),
393		status:        core.NewStatusCmp(app.LSPClients),
394		help:          dialog.NewHelpCmp(),
395		quit:          dialog.NewQuitCmp(),
396		sessionDialog: dialog.NewSessionDialogCmp(),
397		permissions:   dialog.NewPermissionDialogCmp(),
398		app:           app,
399		pages: map[page.PageID]tea.Model{
400			page.ChatPage: page.NewChatPage(app),
401			page.LogsPage: page.NewLogsPage(),
402		},
403	}
404}