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