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