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