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/opencode/internal/app"
8 "github.com/kujtimiihoxha/opencode/internal/logging"
9 "github.com/kujtimiihoxha/opencode/internal/permission"
10 "github.com/kujtimiihoxha/opencode/internal/pubsub"
11 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
12 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
13 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
14 "github.com/kujtimiihoxha/opencode/internal/tui/page"
15 "github.com/kujtimiihoxha/opencode/internal/tui/util"
16)
17
18type keyMap struct {
19 Logs key.Binding
20 Quit key.Binding
21 Help key.Binding
22}
23
24var keys = keyMap{
25 Logs: key.NewBinding(
26 key.WithKeys("ctrl+l"),
27 key.WithHelp("ctrl+L", "logs"),
28 ),
29
30 Quit: key.NewBinding(
31 key.WithKeys("ctrl+c"),
32 key.WithHelp("ctrl+c", "quit"),
33 ),
34 Help: key.NewBinding(
35 key.WithKeys("ctrl+_"),
36 key.WithHelp("ctrl+?", "toggle help"),
37 ),
38}
39
40var returnKey = key.NewBinding(
41 key.WithKeys("esc"),
42 key.WithHelp("esc", "close"),
43)
44
45var logsKeyReturnKey = key.NewBinding(
46 key.WithKeys("backspace"),
47 key.WithHelp("backspace", "go back"),
48)
49
50type appModel struct {
51 width, height int
52 currentPage page.PageID
53 previousPage page.PageID
54 pages map[page.PageID]tea.Model
55 loadedPages map[page.PageID]bool
56 status tea.Model
57 app *app.App
58
59 showPermissions bool
60 permissions dialog.PermissionDialogCmp
61
62 showHelp bool
63 help dialog.HelpCmp
64
65 showQuit bool
66 quit dialog.QuitDialog
67}
68
69func (a appModel) Init() tea.Cmd {
70 var cmds []tea.Cmd
71 cmd := a.pages[a.currentPage].Init()
72 a.loadedPages[a.currentPage] = true
73 cmds = append(cmds, cmd)
74 cmd = a.status.Init()
75 cmds = append(cmds, cmd)
76 cmd = a.quit.Init()
77 cmds = append(cmds, cmd)
78 cmd = a.help.Init()
79 cmds = append(cmds, cmd)
80 return tea.Batch(cmds...)
81}
82
83func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 var cmds []tea.Cmd
85 var cmd tea.Cmd
86 switch msg := msg.(type) {
87 case tea.WindowSizeMsg:
88 msg.Height -= 1 // Make space for the status bar
89 a.width, a.height = msg.Width, msg.Height
90
91 a.status, _ = a.status.Update(msg)
92 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
93 cmds = append(cmds, cmd)
94
95 prm, permCmd := a.permissions.Update(msg)
96 a.permissions = prm.(dialog.PermissionDialogCmp)
97 cmds = append(cmds, permCmd)
98
99 help, helpCmd := a.help.Update(msg)
100 a.help = help.(dialog.HelpCmp)
101 cmds = append(cmds, helpCmd)
102
103 return a, tea.Batch(cmds...)
104
105 // Status
106 case util.InfoMsg:
107 a.status, cmd = a.status.Update(msg)
108 cmds = append(cmds, cmd)
109 return a, tea.Batch(cmds...)
110 case pubsub.Event[logging.LogMessage]:
111 if msg.Payload.Persist {
112 switch msg.Payload.Level {
113 case "error":
114 a.status, cmd = a.status.Update(util.InfoMsg{
115 Type: util.InfoTypeError,
116 Msg: msg.Payload.Message,
117 TTL: msg.Payload.PersistTime,
118 })
119 case "info":
120 a.status, cmd = a.status.Update(util.InfoMsg{
121 Type: util.InfoTypeInfo,
122 Msg: msg.Payload.Message,
123 TTL: msg.Payload.PersistTime,
124 })
125 case "warn":
126 a.status, cmd = a.status.Update(util.InfoMsg{
127 Type: util.InfoTypeWarn,
128 Msg: msg.Payload.Message,
129 TTL: msg.Payload.PersistTime,
130 })
131
132 default:
133 a.status, cmd = a.status.Update(util.InfoMsg{
134 Type: util.InfoTypeInfo,
135 Msg: msg.Payload.Message,
136 TTL: msg.Payload.PersistTime,
137 })
138 }
139 cmds = append(cmds, cmd)
140 }
141 case util.ClearStatusMsg:
142 a.status, _ = a.status.Update(msg)
143
144 // Permission
145 case pubsub.Event[permission.PermissionRequest]:
146 a.showPermissions = true
147 a.permissions.SetPermissions(msg.Payload)
148 return a, nil
149 case dialog.PermissionResponseMsg:
150 switch msg.Action {
151 case dialog.PermissionAllow:
152 a.app.Permissions.Grant(msg.Permission)
153 case dialog.PermissionAllowForSession:
154 a.app.Permissions.GrantPersistant(msg.Permission)
155 case dialog.PermissionDeny:
156 a.app.Permissions.Deny(msg.Permission)
157 }
158 a.showPermissions = false
159 return a, nil
160
161 case page.PageChangeMsg:
162 return a, a.moveToPage(msg.ID)
163
164 case dialog.CloseQuitMsg:
165 a.showQuit = false
166 return a, nil
167
168 case tea.KeyMsg:
169 switch {
170 case key.Matches(msg, keys.Quit):
171 a.showQuit = !a.showQuit
172 if a.showHelp {
173 a.showHelp = false
174 }
175 return a, nil
176 case key.Matches(msg, logsKeyReturnKey):
177 if a.currentPage == page.LogsPage {
178 return a, a.moveToPage(page.ChatPage)
179 }
180 case key.Matches(msg, returnKey):
181 if a.showQuit {
182 a.showQuit = !a.showQuit
183 return a, nil
184 }
185 if a.showHelp {
186 a.showHelp = !a.showHelp
187 return a, nil
188 }
189 case key.Matches(msg, keys.Logs):
190 return a, a.moveToPage(page.LogsPage)
191 case key.Matches(msg, keys.Help):
192 if a.showQuit {
193 return a, nil
194 }
195 a.showHelp = !a.showHelp
196 return a, nil
197 }
198 }
199
200 if a.showQuit {
201 q, quitCmd := a.quit.Update(msg)
202 a.quit = q.(dialog.QuitDialog)
203 cmds = append(cmds, quitCmd)
204 // Only block key messages send all other messages down
205 if _, ok := msg.(tea.KeyMsg); ok {
206 return a, tea.Batch(cmds...)
207 }
208 }
209 if a.showPermissions {
210 d, permissionsCmd := a.permissions.Update(msg)
211 a.permissions = d.(dialog.PermissionDialogCmp)
212 cmds = append(cmds, permissionsCmd)
213 // Only block key messages send all other messages down
214 if _, ok := msg.(tea.KeyMsg); ok {
215 return a, tea.Batch(cmds...)
216 }
217 }
218
219 a.status, _ = a.status.Update(msg)
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) moveToPage(pageID page.PageID) tea.Cmd {
226 var cmd tea.Cmd
227 if _, ok := a.loadedPages[pageID]; !ok {
228 cmd = a.pages[pageID].Init()
229 a.loadedPages[pageID] = true
230 }
231 a.previousPage = a.currentPage
232 a.currentPage = pageID
233 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
234 sizable.SetSize(a.width, a.height)
235 }
236
237 return cmd
238}
239
240func (a appModel) View() string {
241 components := []string{
242 a.pages[a.currentPage].View(),
243 }
244
245 components = append(components, a.status.View())
246
247 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
248
249 if a.showPermissions {
250 overlay := a.permissions.View()
251 row := lipgloss.Height(appView) / 2
252 row -= lipgloss.Height(overlay) / 2
253 col := lipgloss.Width(appView) / 2
254 col -= lipgloss.Width(overlay) / 2
255 appView = layout.PlaceOverlay(
256 col,
257 row,
258 overlay,
259 appView,
260 true,
261 )
262 }
263
264 if a.showHelp {
265 bindings := layout.KeyMapToSlice(keys)
266 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
267 bindings = append(bindings, p.BindingKeys()...)
268 }
269 if a.showPermissions {
270 bindings = append(bindings, a.permissions.BindingKeys()...)
271 }
272 if a.currentPage == page.LogsPage {
273 bindings = append(bindings, logsKeyReturnKey)
274 }
275
276 a.help.SetBindings(bindings)
277
278 overlay := a.help.View()
279 row := lipgloss.Height(appView) / 2
280 row -= lipgloss.Height(overlay) / 2
281 col := lipgloss.Width(appView) / 2
282 col -= lipgloss.Width(overlay) / 2
283 appView = layout.PlaceOverlay(
284 col,
285 row,
286 overlay,
287 appView,
288 true,
289 )
290 }
291
292 if a.showQuit {
293 overlay := a.quit.View()
294 row := lipgloss.Height(appView) / 2
295 row -= lipgloss.Height(overlay) / 2
296 col := lipgloss.Width(appView) / 2
297 col -= lipgloss.Width(overlay) / 2
298 appView = layout.PlaceOverlay(
299 col,
300 row,
301 overlay,
302 appView,
303 true,
304 )
305 }
306
307 return appView
308}
309
310func New(app *app.App) tea.Model {
311 startPage := page.ChatPage
312 return &appModel{
313 currentPage: startPage,
314 loadedPages: make(map[page.PageID]bool),
315 status: core.NewStatusCmp(app.LSPClients),
316 help: dialog.NewHelpCmp(),
317 quit: dialog.NewQuitCmp(),
318 permissions: dialog.NewPermissionDialogCmp(),
319 app: app,
320 pages: map[page.PageID]tea.Model{
321 page.ChatPage: page.NewChatPage(app),
322 page.LogsPage: page.NewLogsPage(),
323 },
324 }
325}