tui.go

  1package tui
  2
  3import (
  4	"log"
  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/llm"
 11	"github.com/kujtimiihoxha/termai/internal/pubsub"
 12	"github.com/kujtimiihoxha/termai/internal/tui/components/core"
 13	"github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
 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
 51type appModel struct {
 52	width, height int
 53	currentPage   page.PageID
 54	previousPage  page.PageID
 55	pages         map[page.PageID]tea.Model
 56	loadedPages   map[page.PageID]bool
 57	status        tea.Model
 58	help          core.HelpCmp
 59	dialog        core.DialogCmp
 60	dialogVisible bool
 61	editorMode    vimtea.EditorMode
 62	showHelp      bool
 63}
 64
 65func (a appModel) Init() tea.Cmd {
 66	cmd := a.pages[a.currentPage].Init()
 67	a.loadedPages[a.currentPage] = true
 68	return cmd
 69}
 70
 71func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 72	switch msg := msg.(type) {
 73	case pubsub.Event[llm.AgentEvent]:
 74		log.Println("Event received")
 75		log.Println(msg)
 76	case vimtea.EditorModeMsg:
 77		a.editorMode = msg.Mode
 78	case tea.WindowSizeMsg:
 79		msg.Height -= 1 // Make space for the status bar
 80		a.width, a.height = msg.Width, msg.Height
 81
 82		a.status, _ = a.status.Update(msg)
 83
 84		uh, _ := a.help.Update(msg)
 85		a.help = uh.(core.HelpCmp)
 86
 87		p, cmd := a.pages[a.currentPage].Update(msg)
 88		a.pages[a.currentPage] = p
 89		return a, cmd
 90	case core.DialogMsg:
 91		d, cmd := a.dialog.Update(msg)
 92		a.dialog = d.(core.DialogCmp)
 93		a.dialogVisible = true
 94		return a, cmd
 95	case core.DialogCloseMsg:
 96		d, cmd := a.dialog.Update(msg)
 97		a.dialog = d.(core.DialogCmp)
 98		a.dialogVisible = false
 99		return a, cmd
100	case util.InfoMsg:
101		a.status, _ = a.status.Update(msg)
102	case util.ErrorMsg:
103		a.status, _ = a.status.Update(msg)
104	case tea.KeyMsg:
105		if a.editorMode == vimtea.ModeNormal {
106			switch {
107			case key.Matches(msg, keys.Quit):
108				return a, dialog.NewQuitDialogCmd()
109			case key.Matches(msg, keys.Back):
110				if a.previousPage != "" {
111					return a, a.moveToPage(a.previousPage)
112				}
113			case key.Matches(msg, keys.Return):
114				if a.showHelp {
115					a.ToggleHelp()
116					return a, nil
117				}
118			case key.Matches(msg, keys.Logs):
119				return a, a.moveToPage(page.LogsPage)
120			case key.Matches(msg, keys.Help):
121				a.ToggleHelp()
122				return a, nil
123			}
124		}
125	}
126	if a.dialogVisible {
127		d, cmd := a.dialog.Update(msg)
128		a.dialog = d.(core.DialogCmp)
129		return a, cmd
130	}
131	p, cmd := a.pages[a.currentPage].Update(msg)
132	a.pages[a.currentPage] = p
133	return a, cmd
134}
135
136func (a *appModel) ToggleHelp() {
137	if a.showHelp {
138		a.showHelp = false
139		a.height += a.help.Height()
140	} else {
141		a.showHelp = true
142		a.height -= a.help.Height()
143	}
144
145	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
146		sizable.SetSize(a.width, a.height)
147	}
148}
149
150func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
151	var cmd tea.Cmd
152	if _, ok := a.loadedPages[pageID]; !ok {
153		cmd = a.pages[pageID].Init()
154		a.loadedPages[pageID] = true
155	}
156	a.previousPage = a.currentPage
157	a.currentPage = pageID
158	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
159		sizable.SetSize(a.width, a.height)
160	}
161
162	return cmd
163}
164
165func (a appModel) View() string {
166	components := []string{
167		a.pages[a.currentPage].View(),
168	}
169
170	if a.showHelp {
171		bindings := layout.KeyMapToSlice(keys)
172		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
173			bindings = append(bindings, p.BindingKeys()...)
174		}
175		if a.dialogVisible {
176			bindings = append(bindings, a.dialog.BindingKeys()...)
177		}
178		a.help.SetBindings(bindings)
179		components = append(components, a.help.View())
180	}
181
182	components = append(components, a.status.View())
183
184	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
185
186	if a.dialogVisible {
187		overlay := a.dialog.View()
188		row := lipgloss.Height(appView) / 2
189		row -= lipgloss.Height(overlay) / 2
190		col := lipgloss.Width(appView) / 2
191		col -= lipgloss.Width(overlay) / 2
192		appView = layout.PlaceOverlay(
193			col,
194			row,
195			overlay,
196			appView,
197			true,
198		)
199	}
200	return appView
201}
202
203func New(app *app.App) tea.Model {
204	return &appModel{
205		currentPage: page.ReplPage,
206		loadedPages: make(map[page.PageID]bool),
207		status:      core.NewStatusCmp(),
208		help:        core.NewHelpCmp(),
209		dialog:      core.NewDialogCmp(),
210		pages: map[page.PageID]tea.Model{
211			page.LogsPage: page.NewLogsPage(),
212			page.InitPage: page.NewInitPage(),
213			page.ReplPage: page.NewReplPage(app),
214		},
215	}
216}