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}