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