tui.go

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