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/opencode/internal/app"
10 "github.com/kujtimiihoxha/opencode/internal/config"
11 "github.com/kujtimiihoxha/opencode/internal/logging"
12 "github.com/kujtimiihoxha/opencode/internal/permission"
13 "github.com/kujtimiihoxha/opencode/internal/pubsub"
14 "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
15 "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
16 "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
17 "github.com/kujtimiihoxha/opencode/internal/tui/layout"
18 "github.com/kujtimiihoxha/opencode/internal/tui/page"
19 "github.com/kujtimiihoxha/opencode/internal/tui/util"
20)
21
22type keyMap struct {
23 Logs key.Binding
24 Quit key.Binding
25 Help key.Binding
26 SwitchSession key.Binding
27 Commands key.Binding
28}
29
30var keys = keyMap{
31 Logs: key.NewBinding(
32 key.WithKeys("ctrl+l"),
33 key.WithHelp("ctrl+l", "logs"),
34 ),
35
36 Quit: key.NewBinding(
37 key.WithKeys("ctrl+c"),
38 key.WithHelp("ctrl+c", "quit"),
39 ),
40 Help: key.NewBinding(
41 key.WithKeys("ctrl+_"),
42 key.WithHelp("ctrl+?", "toggle help"),
43 ),
44
45 SwitchSession: key.NewBinding(
46 key.WithKeys("ctrl+a"),
47 key.WithHelp("ctrl+a", "switch session"),
48 ),
49
50 Commands: key.NewBinding(
51 key.WithKeys("ctrl+k"),
52 key.WithHelp("ctrl+k", "commands"),
53 ),
54}
55
56var helpEsc = key.NewBinding(
57 key.WithKeys("?"),
58 key.WithHelp("?", "toggle help"),
59)
60
61var returnKey = key.NewBinding(
62 key.WithKeys("esc"),
63 key.WithHelp("esc", "close"),
64)
65
66var logsKeyReturnKey = key.NewBinding(
67 key.WithKeys("backspace", "q"),
68 key.WithHelp("backspace/q", "go back"),
69)
70
71type appModel struct {
72 width, height int
73 currentPage page.PageID
74 previousPage page.PageID
75 pages map[page.PageID]tea.Model
76 loadedPages map[page.PageID]bool
77 status core.StatusCmp
78 app *app.App
79
80 showPermissions bool
81 permissions dialog.PermissionDialogCmp
82
83 showHelp bool
84 help dialog.HelpCmp
85
86 showQuit bool
87 quit dialog.QuitDialog
88
89 showSessionDialog bool
90 sessionDialog dialog.SessionDialog
91
92 showCommandDialog bool
93 commandDialog dialog.CommandDialog
94 commands []dialog.Command
95
96 showInitDialog bool
97 initDialog dialog.InitDialogCmp
98}
99
100func (a appModel) Init() tea.Cmd {
101 var cmds []tea.Cmd
102 cmd := a.pages[a.currentPage].Init()
103 a.loadedPages[a.currentPage] = true
104 cmds = append(cmds, cmd)
105 cmd = a.status.Init()
106 cmds = append(cmds, cmd)
107 cmd = a.quit.Init()
108 cmds = append(cmds, cmd)
109 cmd = a.help.Init()
110 cmds = append(cmds, cmd)
111 cmd = a.sessionDialog.Init()
112 cmds = append(cmds, cmd)
113 cmd = a.commandDialog.Init()
114 cmds = append(cmds, cmd)
115 cmd = a.initDialog.Init()
116 cmds = append(cmds, cmd)
117
118 // Check if we should show the init dialog
119 cmds = append(cmds, func() tea.Msg {
120 shouldShow, err := config.ShouldShowInitDialog()
121 if err != nil {
122 return util.InfoMsg{
123 Type: util.InfoTypeError,
124 Msg: "Failed to check init status: " + err.Error(),
125 }
126 }
127 return dialog.ShowInitDialogMsg{Show: shouldShow}
128 })
129
130 return tea.Batch(cmds...)
131}
132
133func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134 var cmds []tea.Cmd
135 var cmd tea.Cmd
136 switch msg := msg.(type) {
137 case tea.WindowSizeMsg:
138 msg.Height -= 1 // Make space for the status bar
139 a.width, a.height = msg.Width, msg.Height
140
141 s, _ := a.status.Update(msg)
142 a.status = s.(core.StatusCmp)
143 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
144 cmds = append(cmds, cmd)
145
146 prm, permCmd := a.permissions.Update(msg)
147 a.permissions = prm.(dialog.PermissionDialogCmp)
148 cmds = append(cmds, permCmd)
149
150 help, helpCmd := a.help.Update(msg)
151 a.help = help.(dialog.HelpCmp)
152 cmds = append(cmds, helpCmd)
153
154 session, sessionCmd := a.sessionDialog.Update(msg)
155 a.sessionDialog = session.(dialog.SessionDialog)
156 cmds = append(cmds, sessionCmd)
157
158 command, commandCmd := a.commandDialog.Update(msg)
159 a.commandDialog = command.(dialog.CommandDialog)
160 cmds = append(cmds, commandCmd)
161
162 a.initDialog.SetSize(msg.Width, msg.Height)
163
164 return a, tea.Batch(cmds...)
165 // Status
166 case util.InfoMsg:
167 s, cmd := a.status.Update(msg)
168 a.status = s.(core.StatusCmp)
169 cmds = append(cmds, cmd)
170 return a, tea.Batch(cmds...)
171 case pubsub.Event[logging.LogMessage]:
172 if msg.Payload.Persist {
173 switch msg.Payload.Level {
174 case "error":
175 s, cmd := a.status.Update(util.InfoMsg{
176 Type: util.InfoTypeError,
177 Msg: msg.Payload.Message,
178 TTL: msg.Payload.PersistTime,
179 })
180 a.status = s.(core.StatusCmp)
181 cmds = append(cmds, cmd)
182 case "info":
183 s, cmd := a.status.Update(util.InfoMsg{
184 Type: util.InfoTypeInfo,
185 Msg: msg.Payload.Message,
186 TTL: msg.Payload.PersistTime,
187 })
188 a.status = s.(core.StatusCmp)
189 cmds = append(cmds, cmd)
190
191 case "warn":
192 s, cmd := a.status.Update(util.InfoMsg{
193 Type: util.InfoTypeWarn,
194 Msg: msg.Payload.Message,
195 TTL: msg.Payload.PersistTime,
196 })
197
198 a.status = s.(core.StatusCmp)
199 cmds = append(cmds, cmd)
200 default:
201 s, cmd := a.status.Update(util.InfoMsg{
202 Type: util.InfoTypeInfo,
203 Msg: msg.Payload.Message,
204 TTL: msg.Payload.PersistTime,
205 })
206 a.status = s.(core.StatusCmp)
207 cmds = append(cmds, cmd)
208 }
209 }
210 case util.ClearStatusMsg:
211 s, _ := a.status.Update(msg)
212 a.status = s.(core.StatusCmp)
213
214 // Permission
215 case pubsub.Event[permission.PermissionRequest]:
216 a.showPermissions = true
217 return a, a.permissions.SetPermissions(msg.Payload)
218 case dialog.PermissionResponseMsg:
219 var cmd tea.Cmd
220 switch msg.Action {
221 case dialog.PermissionAllow:
222 a.app.Permissions.Grant(msg.Permission)
223 case dialog.PermissionAllowForSession:
224 a.app.Permissions.GrantPersistant(msg.Permission)
225 case dialog.PermissionDeny:
226 a.app.Permissions.Deny(msg.Permission)
227 cmd = util.CmdHandler(chat.FocusEditorMsg(true))
228 }
229 a.showPermissions = false
230 return a, cmd
231
232 case page.PageChangeMsg:
233 return a, a.moveToPage(msg.ID)
234
235 case dialog.CloseQuitMsg:
236 a.showQuit = false
237 return a, nil
238
239 case dialog.CloseSessionDialogMsg:
240 a.showSessionDialog = false
241 return a, nil
242
243 case dialog.CloseCommandDialogMsg:
244 a.showCommandDialog = false
245 return a, nil
246
247 case dialog.ShowInitDialogMsg:
248 a.showInitDialog = msg.Show
249 return a, nil
250
251 case dialog.CloseInitDialogMsg:
252 a.showInitDialog = false
253 if msg.Initialize {
254 // Run the initialization command
255 for _, cmd := range a.commands {
256 if cmd.ID == "init" {
257 // Mark the project as initialized
258 if err := config.MarkProjectInitialized(); err != nil {
259 return a, util.ReportError(err)
260 }
261 return a, cmd.Handler(cmd)
262 }
263 }
264 } else {
265 // Mark the project as initialized without running the command
266 if err := config.MarkProjectInitialized(); err != nil {
267 return a, util.ReportError(err)
268 }
269 }
270 return a, nil
271
272 case chat.SessionSelectedMsg:
273 a.sessionDialog.SetSelectedSession(msg.ID)
274 case dialog.SessionSelectedMsg:
275 a.showSessionDialog = false
276 if a.currentPage == page.ChatPage {
277 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
278 }
279 return a, nil
280
281 case dialog.CommandSelectedMsg:
282 a.showCommandDialog = false
283 // Execute the command handler if available
284 if msg.Command.Handler != nil {
285 return a, msg.Command.Handler(msg.Command)
286 }
287 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
288
289 case tea.KeyMsg:
290 switch {
291 case key.Matches(msg, keys.Quit):
292 a.showQuit = !a.showQuit
293 if a.showHelp {
294 a.showHelp = false
295 }
296 if a.showSessionDialog {
297 a.showSessionDialog = false
298 }
299 if a.showCommandDialog {
300 a.showCommandDialog = false
301 }
302 return a, nil
303 case key.Matches(msg, keys.SwitchSession):
304 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
305 // Load sessions and show the dialog
306 sessions, err := a.app.Sessions.List(context.Background())
307 if err != nil {
308 return a, util.ReportError(err)
309 }
310 if len(sessions) == 0 {
311 return a, util.ReportWarn("No sessions available")
312 }
313 a.sessionDialog.SetSessions(sessions)
314 a.showSessionDialog = true
315 return a, nil
316 }
317 return a, nil
318 case key.Matches(msg, keys.Commands):
319 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
320 // Show commands dialog
321 if len(a.commands) == 0 {
322 return a, util.ReportWarn("No commands available")
323 }
324 a.commandDialog.SetCommands(a.commands)
325 a.showCommandDialog = true
326 return a, nil
327 }
328 return a, nil
329 case key.Matches(msg, logsKeyReturnKey):
330 if a.currentPage == page.LogsPage {
331 return a, a.moveToPage(page.ChatPage)
332 }
333 case key.Matches(msg, returnKey):
334 if a.showQuit {
335 a.showQuit = !a.showQuit
336 return a, nil
337 }
338 if a.showHelp {
339 a.showHelp = !a.showHelp
340 return a, nil
341 }
342 if a.showInitDialog {
343 a.showInitDialog = false
344 // Mark the project as initialized without running the command
345 if err := config.MarkProjectInitialized(); err != nil {
346 return a, util.ReportError(err)
347 }
348 return a, nil
349 }
350 case key.Matches(msg, keys.Logs):
351 return a, a.moveToPage(page.LogsPage)
352 case key.Matches(msg, keys.Help):
353 if a.showQuit {
354 return a, nil
355 }
356 a.showHelp = !a.showHelp
357 return a, nil
358 case key.Matches(msg, helpEsc):
359 if a.app.CoderAgent.IsBusy() {
360 if a.showQuit {
361 return a, nil
362 }
363 a.showHelp = !a.showHelp
364 return a, nil
365 }
366 }
367
368 }
369
370 if a.showQuit {
371 q, quitCmd := a.quit.Update(msg)
372 a.quit = q.(dialog.QuitDialog)
373 cmds = append(cmds, quitCmd)
374 // Only block key messages send all other messages down
375 if _, ok := msg.(tea.KeyMsg); ok {
376 return a, tea.Batch(cmds...)
377 }
378 }
379 if a.showPermissions {
380 d, permissionsCmd := a.permissions.Update(msg)
381 a.permissions = d.(dialog.PermissionDialogCmp)
382 cmds = append(cmds, permissionsCmd)
383 // Only block key messages send all other messages down
384 if _, ok := msg.(tea.KeyMsg); ok {
385 return a, tea.Batch(cmds...)
386 }
387 }
388
389 if a.showSessionDialog {
390 d, sessionCmd := a.sessionDialog.Update(msg)
391 a.sessionDialog = d.(dialog.SessionDialog)
392 cmds = append(cmds, sessionCmd)
393 // Only block key messages send all other messages down
394 if _, ok := msg.(tea.KeyMsg); ok {
395 return a, tea.Batch(cmds...)
396 }
397 }
398
399 if a.showCommandDialog {
400 d, commandCmd := a.commandDialog.Update(msg)
401 a.commandDialog = d.(dialog.CommandDialog)
402 cmds = append(cmds, commandCmd)
403 // Only block key messages send all other messages down
404 if _, ok := msg.(tea.KeyMsg); ok {
405 return a, tea.Batch(cmds...)
406 }
407 }
408
409 if a.showInitDialog {
410 d, initCmd := a.initDialog.Update(msg)
411 a.initDialog = d.(dialog.InitDialogCmp)
412 cmds = append(cmds, initCmd)
413 // Only block key messages send all other messages down
414 if _, ok := msg.(tea.KeyMsg); ok {
415 return a, tea.Batch(cmds...)
416 }
417 }
418
419 s, _ := a.status.Update(msg)
420 a.status = s.(core.StatusCmp)
421 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
422 cmds = append(cmds, cmd)
423 return a, tea.Batch(cmds...)
424}
425
426// RegisterCommand adds a command to the command dialog
427func (a *appModel) RegisterCommand(cmd dialog.Command) {
428 a.commands = append(a.commands, cmd)
429}
430
431func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
432 if a.app.CoderAgent.IsBusy() {
433 // For now we don't move to any page if the agent is busy
434 return util.ReportWarn("Agent is busy, please wait...")
435 }
436 var cmds []tea.Cmd
437 if _, ok := a.loadedPages[pageID]; !ok {
438 cmd := a.pages[pageID].Init()
439 cmds = append(cmds, cmd)
440 a.loadedPages[pageID] = true
441 }
442 a.previousPage = a.currentPage
443 a.currentPage = pageID
444 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
445 cmd := sizable.SetSize(a.width, a.height)
446 cmds = append(cmds, cmd)
447 }
448
449 return tea.Batch(cmds...)
450}
451
452func (a appModel) View() string {
453 components := []string{
454 a.pages[a.currentPage].View(),
455 }
456
457 components = append(components, a.status.View())
458
459 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
460
461 if a.showPermissions {
462 overlay := a.permissions.View()
463 row := lipgloss.Height(appView) / 2
464 row -= lipgloss.Height(overlay) / 2
465 col := lipgloss.Width(appView) / 2
466 col -= lipgloss.Width(overlay) / 2
467 appView = layout.PlaceOverlay(
468 col,
469 row,
470 overlay,
471 appView,
472 true,
473 )
474 }
475
476 if !a.app.CoderAgent.IsBusy() {
477 a.status.SetHelpMsg("ctrl+? help")
478 } else {
479 a.status.SetHelpMsg("? help")
480 }
481
482 if a.showHelp {
483 bindings := layout.KeyMapToSlice(keys)
484 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
485 bindings = append(bindings, p.BindingKeys()...)
486 }
487 if a.showPermissions {
488 bindings = append(bindings, a.permissions.BindingKeys()...)
489 }
490 if a.currentPage == page.LogsPage {
491 bindings = append(bindings, logsKeyReturnKey)
492 }
493 if !a.app.CoderAgent.IsBusy() {
494 bindings = append(bindings, helpEsc)
495 }
496 a.help.SetBindings(bindings)
497
498 overlay := a.help.View()
499 row := lipgloss.Height(appView) / 2
500 row -= lipgloss.Height(overlay) / 2
501 col := lipgloss.Width(appView) / 2
502 col -= lipgloss.Width(overlay) / 2
503 appView = layout.PlaceOverlay(
504 col,
505 row,
506 overlay,
507 appView,
508 true,
509 )
510 }
511
512 if a.showQuit {
513 overlay := a.quit.View()
514 row := lipgloss.Height(appView) / 2
515 row -= lipgloss.Height(overlay) / 2
516 col := lipgloss.Width(appView) / 2
517 col -= lipgloss.Width(overlay) / 2
518 appView = layout.PlaceOverlay(
519 col,
520 row,
521 overlay,
522 appView,
523 true,
524 )
525 }
526
527 if a.showSessionDialog {
528 overlay := a.sessionDialog.View()
529 row := lipgloss.Height(appView) / 2
530 row -= lipgloss.Height(overlay) / 2
531 col := lipgloss.Width(appView) / 2
532 col -= lipgloss.Width(overlay) / 2
533 appView = layout.PlaceOverlay(
534 col,
535 row,
536 overlay,
537 appView,
538 true,
539 )
540 }
541
542 if a.showCommandDialog {
543 overlay := a.commandDialog.View()
544 row := lipgloss.Height(appView) / 2
545 row -= lipgloss.Height(overlay) / 2
546 col := lipgloss.Width(appView) / 2
547 col -= lipgloss.Width(overlay) / 2
548 appView = layout.PlaceOverlay(
549 col,
550 row,
551 overlay,
552 appView,
553 true,
554 )
555 }
556
557 if a.showInitDialog {
558 overlay := a.initDialog.View()
559 appView = layout.PlaceOverlay(
560 a.width/2-lipgloss.Width(overlay)/2,
561 a.height/2-lipgloss.Height(overlay)/2,
562 overlay,
563 appView,
564 true,
565 )
566 }
567
568 return appView
569}
570
571func New(app *app.App) tea.Model {
572 startPage := page.ChatPage
573 model := &appModel{
574 currentPage: startPage,
575 loadedPages: make(map[page.PageID]bool),
576 status: core.NewStatusCmp(app.LSPClients),
577 help: dialog.NewHelpCmp(),
578 quit: dialog.NewQuitCmp(),
579 sessionDialog: dialog.NewSessionDialogCmp(),
580 commandDialog: dialog.NewCommandDialogCmp(),
581 permissions: dialog.NewPermissionDialogCmp(),
582 initDialog: dialog.NewInitDialogCmp(),
583 app: app,
584 commands: []dialog.Command{},
585 pages: map[page.PageID]tea.Model{
586 page.ChatPage: page.NewChatPage(app),
587 page.LogsPage: page.NewLogsPage(),
588 },
589 }
590
591 model.RegisterCommand(dialog.Command{
592 ID: "init",
593 Title: "Initialize Project",
594 Description: "Create/Update the OpenCode.md memory file",
595 Handler: func(cmd dialog.Command) tea.Cmd {
596 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
5971. Build/lint/test commands - especially for running a single test
5982. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
599
600The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
601If there's already a opencode.md, improve it.
602If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
603 return tea.Batch(
604 util.CmdHandler(chat.SendMsg{
605 Text: prompt,
606 }),
607 )
608 },
609 })
610 return model
611}