tui.go

  1package tui
  2
  3import (
  4	"github.com/charmbracelet/bubbles/key"
  5	tea "github.com/charmbracelet/bubbletea"
  6	"github.com/charmbracelet/lipgloss"
  7	"github.com/kujtimiihoxha/termai/internal/app"
  8	"github.com/kujtimiihoxha/termai/internal/logging"
  9	"github.com/kujtimiihoxha/termai/internal/permission"
 10	"github.com/kujtimiihoxha/termai/internal/pubsub"
 11	"github.com/kujtimiihoxha/termai/internal/tui/components/core"
 12	"github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
 13	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 14	"github.com/kujtimiihoxha/termai/internal/tui/page"
 15	"github.com/kujtimiihoxha/termai/internal/tui/util"
 16)
 17
 18type keyMap struct {
 19	Logs key.Binding
 20	Quit key.Binding
 21	Help key.Binding
 22}
 23
 24var keys = keyMap{
 25	Logs: key.NewBinding(
 26		key.WithKeys("ctrl+l"),
 27		key.WithHelp("ctrl+L", "logs"),
 28	),
 29
 30	Quit: key.NewBinding(
 31		key.WithKeys("ctrl+c"),
 32		key.WithHelp("ctrl+c", "quit"),
 33	),
 34	Help: key.NewBinding(
 35		key.WithKeys("ctrl+_"),
 36		key.WithHelp("ctrl+?", "toggle help"),
 37	),
 38}
 39
 40var returnKey = key.NewBinding(
 41	key.WithKeys("esc"),
 42	key.WithHelp("esc", "close"),
 43)
 44
 45var logsKeyReturnKey = key.NewBinding(
 46	key.WithKeys("backspace"),
 47	key.WithHelp("backspace", "go back"),
 48)
 49
 50type appModel struct {
 51	width, height int
 52	currentPage   page.PageID
 53	previousPage  page.PageID
 54	pages         map[page.PageID]tea.Model
 55	loadedPages   map[page.PageID]bool
 56	status        tea.Model
 57	app           *app.App
 58
 59	showPermissions bool
 60	permissions     dialog.PermissionDialogCmp
 61
 62	showHelp bool
 63	help     dialog.HelpCmp
 64
 65	showQuit bool
 66	quit     dialog.QuitDialog
 67}
 68
 69func (a appModel) Init() tea.Cmd {
 70	var cmds []tea.Cmd
 71	cmd := a.pages[a.currentPage].Init()
 72	a.loadedPages[a.currentPage] = true
 73	cmds = append(cmds, cmd)
 74	cmd = a.status.Init()
 75	cmds = append(cmds, cmd)
 76	cmd = a.quit.Init()
 77	cmds = append(cmds, cmd)
 78	cmd = a.help.Init()
 79	cmds = append(cmds, cmd)
 80	return tea.Batch(cmds...)
 81}
 82
 83func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 84	var cmds []tea.Cmd
 85	var cmd tea.Cmd
 86	switch msg := msg.(type) {
 87	case tea.WindowSizeMsg:
 88		msg.Height -= 1 // Make space for the status bar
 89		a.width, a.height = msg.Width, msg.Height
 90
 91		a.status, _ = a.status.Update(msg)
 92		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 93		cmds = append(cmds, cmd)
 94
 95		prm, permCmd := a.permissions.Update(msg)
 96		a.permissions = prm.(dialog.PermissionDialogCmp)
 97		cmds = append(cmds, permCmd)
 98
 99		help, helpCmd := a.help.Update(msg)
100		a.help = help.(dialog.HelpCmp)
101		cmds = append(cmds, helpCmd)
102
103		return a, tea.Batch(cmds...)
104
105	// Status
106	case util.InfoMsg:
107		a.status, cmd = a.status.Update(msg)
108		cmds = append(cmds, cmd)
109		return a, tea.Batch(cmds...)
110	case pubsub.Event[logging.LogMessage]:
111		if msg.Payload.Persist {
112			switch msg.Payload.Level {
113			case "error":
114				a.status, cmd = a.status.Update(util.InfoMsg{
115					Type: util.InfoTypeError,
116					Msg:  msg.Payload.Message,
117					TTL:  msg.Payload.PersistTime,
118				})
119			case "info":
120				a.status, cmd = a.status.Update(util.InfoMsg{
121					Type: util.InfoTypeInfo,
122					Msg:  msg.Payload.Message,
123					TTL:  msg.Payload.PersistTime,
124				})
125			case "warn":
126				a.status, cmd = a.status.Update(util.InfoMsg{
127					Type: util.InfoTypeWarn,
128					Msg:  msg.Payload.Message,
129					TTL:  msg.Payload.PersistTime,
130				})
131
132			default:
133				a.status, cmd = a.status.Update(util.InfoMsg{
134					Type: util.InfoTypeInfo,
135					Msg:  msg.Payload.Message,
136					TTL:  msg.Payload.PersistTime,
137				})
138			}
139			cmds = append(cmds, cmd)
140		}
141	case util.ClearStatusMsg:
142		a.status, _ = a.status.Update(msg)
143
144	// Permission
145	case pubsub.Event[permission.PermissionRequest]:
146		a.showPermissions = true
147		a.permissions.SetPermissions(msg.Payload)
148		return a, nil
149	case dialog.PermissionResponseMsg:
150		switch msg.Action {
151		case dialog.PermissionAllow:
152			a.app.Permissions.Grant(msg.Permission)
153		case dialog.PermissionAllowForSession:
154			a.app.Permissions.GrantPersistant(msg.Permission)
155		case dialog.PermissionDeny:
156			a.app.Permissions.Deny(msg.Permission)
157		}
158		a.showPermissions = false
159		return a, nil
160
161	case page.PageChangeMsg:
162		return a, a.moveToPage(msg.ID)
163
164	case dialog.CloseQuitMsg:
165		a.showQuit = false
166		return a, nil
167
168	case tea.KeyMsg:
169		switch {
170		case key.Matches(msg, keys.Quit):
171			a.showQuit = !a.showQuit
172			if a.showHelp {
173				a.showHelp = false
174			}
175			return a, nil
176		case key.Matches(msg, logsKeyReturnKey):
177			if a.currentPage == page.LogsPage {
178				return a, a.moveToPage(page.ChatPage)
179			}
180		case key.Matches(msg, returnKey):
181			if a.showQuit {
182				a.showQuit = !a.showQuit
183				return a, nil
184			}
185			if a.showHelp {
186				a.showHelp = !a.showHelp
187				return a, nil
188			}
189		case key.Matches(msg, keys.Logs):
190			return a, a.moveToPage(page.LogsPage)
191		case key.Matches(msg, keys.Help):
192			if a.showQuit {
193				return a, nil
194			}
195			a.showHelp = !a.showHelp
196			return a, nil
197		}
198	}
199
200	if a.showQuit {
201		q, quitCmd := a.quit.Update(msg)
202		a.quit = q.(dialog.QuitDialog)
203		cmds = append(cmds, quitCmd)
204		// Only block key messages send all other messages down
205		if _, ok := msg.(tea.KeyMsg); ok {
206			return a, tea.Batch(cmds...)
207		}
208	}
209	if a.showPermissions {
210		d, permissionsCmd := a.permissions.Update(msg)
211		a.permissions = d.(dialog.PermissionDialogCmp)
212		cmds = append(cmds, permissionsCmd)
213		// Only block key messages send all other messages down
214		if _, ok := msg.(tea.KeyMsg); ok {
215			return a, tea.Batch(cmds...)
216		}
217	}
218	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
219	cmds = append(cmds, cmd)
220	return a, tea.Batch(cmds...)
221}
222
223func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
224	var cmd tea.Cmd
225	if _, ok := a.loadedPages[pageID]; !ok {
226		cmd = a.pages[pageID].Init()
227		a.loadedPages[pageID] = true
228	}
229	a.previousPage = a.currentPage
230	a.currentPage = pageID
231	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
232		sizable.SetSize(a.width, a.height)
233	}
234
235	return cmd
236}
237
238func (a appModel) View() string {
239	components := []string{
240		a.pages[a.currentPage].View(),
241	}
242
243	components = append(components, a.status.View())
244
245	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
246
247	if a.showPermissions {
248		overlay := a.permissions.View()
249		row := lipgloss.Height(appView) / 2
250		row -= lipgloss.Height(overlay) / 2
251		col := lipgloss.Width(appView) / 2
252		col -= lipgloss.Width(overlay) / 2
253		appView = layout.PlaceOverlay(
254			col,
255			row,
256			overlay,
257			appView,
258			true,
259		)
260	}
261
262	if a.showHelp {
263		bindings := layout.KeyMapToSlice(keys)
264		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
265			bindings = append(bindings, p.BindingKeys()...)
266		}
267		if a.showPermissions {
268			bindings = append(bindings, a.permissions.BindingKeys()...)
269		}
270		if a.currentPage == page.LogsPage {
271			bindings = append(bindings, logsKeyReturnKey)
272		}
273
274		a.help.SetBindings(bindings)
275
276		overlay := a.help.View()
277		row := lipgloss.Height(appView) / 2
278		row -= lipgloss.Height(overlay) / 2
279		col := lipgloss.Width(appView) / 2
280		col -= lipgloss.Width(overlay) / 2
281		appView = layout.PlaceOverlay(
282			col,
283			row,
284			overlay,
285			appView,
286			true,
287		)
288	}
289
290	if a.showQuit {
291		overlay := a.quit.View()
292		row := lipgloss.Height(appView) / 2
293		row -= lipgloss.Height(overlay) / 2
294		col := lipgloss.Width(appView) / 2
295		col -= lipgloss.Width(overlay) / 2
296		appView = layout.PlaceOverlay(
297			col,
298			row,
299			overlay,
300			appView,
301			true,
302		)
303	}
304
305	return appView
306}
307
308func New(app *app.App) tea.Model {
309	startPage := page.ChatPage
310	return &appModel{
311		currentPage: startPage,
312		loadedPages: make(map[page.PageID]bool),
313		status:      core.NewStatusCmp(app.LSPClients),
314		help:        dialog.NewHelpCmp(),
315		quit:        dialog.NewQuitCmp(),
316		permissions: dialog.NewPermissionDialogCmp(),
317		app:         app,
318		pages: map[page.PageID]tea.Model{
319			page.ChatPage: page.NewChatPage(app),
320			page.LogsPage: page.NewLogsPage(),
321		},
322	}
323}