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 return a, cmd
105 case pubsub.Event[logging.LogMessage]:
106 if msg.Payload.Persist {
107 switch msg.Payload.Level {
108 case "error":
109 a.status, cmd = a.status.Update(util.InfoMsg{
110 Type: util.InfoTypeError,
111 Msg: msg.Payload.Message,
112 TTL: msg.Payload.PersistTime,
113 })
114 case "info":
115 a.status, cmd = a.status.Update(util.InfoMsg{
116 Type: util.InfoTypeInfo,
117 Msg: msg.Payload.Message,
118 TTL: msg.Payload.PersistTime,
119 })
120 case "warn":
121 a.status, cmd = a.status.Update(util.InfoMsg{
122 Type: util.InfoTypeWarn,
123 Msg: msg.Payload.Message,
124 TTL: msg.Payload.PersistTime,
125 })
126
127 default:
128 a.status, cmd = a.status.Update(util.InfoMsg{
129 Type: util.InfoTypeInfo,
130 Msg: msg.Payload.Message,
131 TTL: msg.Payload.PersistTime,
132 })
133 }
134 cmds = append(cmds, cmd)
135 }
136 case util.ClearStatusMsg:
137 a.status, _ = a.status.Update(msg)
138
139 // Permission
140 case pubsub.Event[permission.PermissionRequest]:
141 return a, dialog.NewPermissionDialogCmd(msg.Payload)
142 case dialog.PermissionResponseMsg:
143 switch msg.Action {
144 case dialog.PermissionAllow:
145 a.app.Permissions.Grant(msg.Permission)
146 case dialog.PermissionAllowForSession:
147 a.app.Permissions.GrantPersistant(msg.Permission)
148 case dialog.PermissionDeny:
149 a.app.Permissions.Deny(msg.Permission)
150 }
151
152 // Dialog
153 case core.DialogMsg:
154 d, cmd := a.dialog.Update(msg)
155 a.dialog = d.(core.DialogCmp)
156 a.dialogVisible = true
157 return a, cmd
158 case core.DialogCloseMsg:
159 d, cmd := a.dialog.Update(msg)
160 a.dialog = d.(core.DialogCmp)
161 a.dialogVisible = false
162 return a, cmd
163
164 // Editor
165 case vimtea.EditorModeMsg:
166 a.editorMode = msg.Mode
167
168 case page.PageChangeMsg:
169 return a, a.moveToPage(msg.ID)
170 case tea.KeyMsg:
171 if a.editorMode == vimtea.ModeNormal {
172 switch {
173 case key.Matches(msg, keys.Quit):
174 return a, dialog.NewQuitDialogCmd()
175 case key.Matches(msg, keys.Back):
176 if a.previousPage != "" {
177 return a, a.moveToPage(a.previousPage)
178 }
179 case key.Matches(msg, keys.Return):
180 if a.showHelp {
181 a.ToggleHelp()
182 return a, nil
183 }
184 case key.Matches(msg, replKeyMap):
185 if a.currentPage == page.ReplPage {
186 sessions, err := a.app.Sessions.List()
187 if err != nil {
188 return a, util.CmdHandler(util.ReportError(err))
189 }
190 lastSession := sessions[0]
191 if lastSession.MessageCount == 0 {
192 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
193 }
194 s, err := a.app.Sessions.Create("New Session")
195 if err != nil {
196 return a, util.CmdHandler(util.ReportError(err))
197 }
198 return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
199 }
200 case key.Matches(msg, keys.Logs):
201 return a, a.moveToPage(page.LogsPage)
202 case key.Matches(msg, keys.Help):
203 a.ToggleHelp()
204 return a, nil
205 }
206 }
207 }
208
209 if a.dialogVisible {
210 d, cmd := a.dialog.Update(msg)
211 a.dialog = d.(core.DialogCmp)
212 cmds = append(cmds, cmd)
213 return a, tea.Batch(cmds...)
214 }
215 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
216 cmds = append(cmds, cmd)
217 return a, tea.Batch(cmds...)
218}
219
220func (a *appModel) ToggleHelp() {
221 if a.showHelp {
222 a.showHelp = false
223 a.height += a.help.Height()
224 } else {
225 a.showHelp = true
226 a.height -= a.help.Height()
227 }
228
229 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
230 sizable.SetSize(a.width, a.height)
231 }
232}
233
234func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
235 var cmd tea.Cmd
236 if _, ok := a.loadedPages[pageID]; !ok {
237 cmd = a.pages[pageID].Init()
238 a.loadedPages[pageID] = true
239 }
240 a.previousPage = a.currentPage
241 a.currentPage = pageID
242 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
243 sizable.SetSize(a.width, a.height)
244 }
245
246 return cmd
247}
248
249func (a appModel) View() string {
250 components := []string{
251 a.pages[a.currentPage].View(),
252 }
253
254 if a.showHelp {
255 bindings := layout.KeyMapToSlice(keys)
256 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
257 bindings = append(bindings, p.BindingKeys()...)
258 }
259 if a.dialogVisible {
260 bindings = append(bindings, a.dialog.BindingKeys()...)
261 }
262 if a.currentPage == page.ReplPage {
263 bindings = append(bindings, replKeyMap)
264 }
265 a.help.SetBindings(bindings)
266 components = append(components, a.help.View())
267 }
268
269 components = append(components, a.status.View())
270
271 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
272
273 if a.dialogVisible {
274 overlay := a.dialog.View()
275 row := lipgloss.Height(appView) / 2
276 row -= lipgloss.Height(overlay) / 2
277 col := lipgloss.Width(appView) / 2
278 col -= lipgloss.Width(overlay) / 2
279 appView = layout.PlaceOverlay(
280 col,
281 row,
282 overlay,
283 appView,
284 true,
285 )
286 }
287 return appView
288}
289
290func New(app *app.App) tea.Model {
291 // homedir, _ := os.UserHomeDir()
292 // configPath := filepath.Join(homedir, ".termai.yaml")
293 //
294 startPage := page.ReplPage
295 // if _, err := os.Stat(configPath); os.IsNotExist(err) {
296 // startPage = page.InitPage
297 // }
298
299 return &appModel{
300 currentPage: startPage,
301 loadedPages: make(map[page.PageID]bool),
302 status: core.NewStatusCmp(),
303 help: core.NewHelpCmp(),
304 dialog: core.NewDialogCmp(),
305 app: app,
306 pages: map[page.PageID]tea.Model{
307 page.LogsPage: page.NewLogsPage(),
308 page.InitPage: page.NewInitPage(),
309 page.ReplPage: page.NewReplPage(app),
310 },
311 }
312}