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}