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