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/opencode-ai/opencode/internal/app"
10 "github.com/opencode-ai/opencode/internal/config"
11 "github.com/opencode-ai/opencode/internal/logging"
12 "github.com/opencode-ai/opencode/internal/permission"
13 "github.com/opencode-ai/opencode/internal/pubsub"
14 "github.com/opencode-ai/opencode/internal/tui/components/chat"
15 "github.com/opencode-ai/opencode/internal/tui/components/core"
16 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
17 "github.com/opencode-ai/opencode/internal/tui/layout"
18 "github.com/opencode-ai/opencode/internal/tui/page"
19 "github.com/opencode-ai/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 }
228 a.showPermissions = false
229 return a, cmd
230
231 case page.PageChangeMsg:
232 return a, a.moveToPage(msg.ID)
233
234 case dialog.CloseQuitMsg:
235 a.showQuit = false
236 return a, nil
237
238 case dialog.CloseSessionDialogMsg:
239 a.showSessionDialog = false
240 return a, nil
241
242 case dialog.CloseCommandDialogMsg:
243 a.showCommandDialog = false
244 return a, nil
245
246 case dialog.ShowInitDialogMsg:
247 a.showInitDialog = msg.Show
248 return a, nil
249
250 case dialog.CloseInitDialogMsg:
251 a.showInitDialog = false
252 if msg.Initialize {
253 // Run the initialization command
254 for _, cmd := range a.commands {
255 if cmd.ID == "init" {
256 // Mark the project as initialized
257 if err := config.MarkProjectInitialized(); err != nil {
258 return a, util.ReportError(err)
259 }
260 return a, cmd.Handler(cmd)
261 }
262 }
263 } else {
264 // Mark the project as initialized without running the command
265 if err := config.MarkProjectInitialized(); err != nil {
266 return a, util.ReportError(err)
267 }
268 }
269 return a, nil
270
271 case chat.SessionSelectedMsg:
272 a.sessionDialog.SetSelectedSession(msg.ID)
273 case dialog.SessionSelectedMsg:
274 a.showSessionDialog = false
275 if a.currentPage == page.ChatPage {
276 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
277 }
278 return a, nil
279
280 case dialog.CommandSelectedMsg:
281 a.showCommandDialog = false
282 // Execute the command handler if available
283 if msg.Command.Handler != nil {
284 return a, msg.Command.Handler(msg.Command)
285 }
286 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
287
288 case tea.KeyMsg:
289 switch {
290 case key.Matches(msg, keys.Quit):
291 a.showQuit = !a.showQuit
292 if a.showHelp {
293 a.showHelp = false
294 }
295 if a.showSessionDialog {
296 a.showSessionDialog = false
297 }
298 if a.showCommandDialog {
299 a.showCommandDialog = false
300 }
301 return a, nil
302 case key.Matches(msg, keys.SwitchSession):
303 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
304 // Load sessions and show the dialog
305 sessions, err := a.app.Sessions.List(context.Background())
306 if err != nil {
307 return a, util.ReportError(err)
308 }
309 if len(sessions) == 0 {
310 return a, util.ReportWarn("No sessions available")
311 }
312 a.sessionDialog.SetSessions(sessions)
313 a.showSessionDialog = true
314 return a, nil
315 }
316 return a, nil
317 case key.Matches(msg, keys.Commands):
318 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
319 // Show commands dialog
320 if len(a.commands) == 0 {
321 return a, util.ReportWarn("No commands available")
322 }
323 a.commandDialog.SetCommands(a.commands)
324 a.showCommandDialog = true
325 return a, nil
326 }
327 return a, nil
328 case key.Matches(msg, logsKeyReturnKey):
329 if a.currentPage == page.LogsPage {
330 return a, a.moveToPage(page.ChatPage)
331 }
332 case key.Matches(msg, returnKey):
333 if a.showQuit {
334 a.showQuit = !a.showQuit
335 return a, nil
336 }
337 if a.showHelp {
338 a.showHelp = !a.showHelp
339 return a, nil
340 }
341 if a.showInitDialog {
342 a.showInitDialog = false
343 // Mark the project as initialized without running the command
344 if err := config.MarkProjectInitialized(); err != nil {
345 return a, util.ReportError(err)
346 }
347 return a, nil
348 }
349 case key.Matches(msg, keys.Logs):
350 return a, a.moveToPage(page.LogsPage)
351 case key.Matches(msg, keys.Help):
352 if a.showQuit {
353 return a, nil
354 }
355 a.showHelp = !a.showHelp
356 return a, nil
357 case key.Matches(msg, helpEsc):
358 if a.app.CoderAgent.IsBusy() {
359 if a.showQuit {
360 return a, nil
361 }
362 a.showHelp = !a.showHelp
363 return a, nil
364 }
365 }
366
367 }
368
369 if a.showQuit {
370 q, quitCmd := a.quit.Update(msg)
371 a.quit = q.(dialog.QuitDialog)
372 cmds = append(cmds, quitCmd)
373 // Only block key messages send all other messages down
374 if _, ok := msg.(tea.KeyMsg); ok {
375 return a, tea.Batch(cmds...)
376 }
377 }
378 if a.showPermissions {
379 d, permissionsCmd := a.permissions.Update(msg)
380 a.permissions = d.(dialog.PermissionDialogCmp)
381 cmds = append(cmds, permissionsCmd)
382 // Only block key messages send all other messages down
383 if _, ok := msg.(tea.KeyMsg); ok {
384 return a, tea.Batch(cmds...)
385 }
386 }
387
388 if a.showSessionDialog {
389 d, sessionCmd := a.sessionDialog.Update(msg)
390 a.sessionDialog = d.(dialog.SessionDialog)
391 cmds = append(cmds, sessionCmd)
392 // Only block key messages send all other messages down
393 if _, ok := msg.(tea.KeyMsg); ok {
394 return a, tea.Batch(cmds...)
395 }
396 }
397
398 if a.showCommandDialog {
399 d, commandCmd := a.commandDialog.Update(msg)
400 a.commandDialog = d.(dialog.CommandDialog)
401 cmds = append(cmds, commandCmd)
402 // Only block key messages send all other messages down
403 if _, ok := msg.(tea.KeyMsg); ok {
404 return a, tea.Batch(cmds...)
405 }
406 }
407
408 if a.showInitDialog {
409 d, initCmd := a.initDialog.Update(msg)
410 a.initDialog = d.(dialog.InitDialogCmp)
411 cmds = append(cmds, initCmd)
412 // Only block key messages send all other messages down
413 if _, ok := msg.(tea.KeyMsg); ok {
414 return a, tea.Batch(cmds...)
415 }
416 }
417
418 s, _ := a.status.Update(msg)
419 a.status = s.(core.StatusCmp)
420 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
421 cmds = append(cmds, cmd)
422 return a, tea.Batch(cmds...)
423}
424
425// RegisterCommand adds a command to the command dialog
426func (a *appModel) RegisterCommand(cmd dialog.Command) {
427 a.commands = append(a.commands, cmd)
428}
429
430func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
431 if a.app.CoderAgent.IsBusy() {
432 // For now we don't move to any page if the agent is busy
433 return util.ReportWarn("Agent is busy, please wait...")
434 }
435 var cmds []tea.Cmd
436 if _, ok := a.loadedPages[pageID]; !ok {
437 cmd := a.pages[pageID].Init()
438 cmds = append(cmds, cmd)
439 a.loadedPages[pageID] = true
440 }
441 a.previousPage = a.currentPage
442 a.currentPage = pageID
443 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
444 cmd := sizable.SetSize(a.width, a.height)
445 cmds = append(cmds, cmd)
446 }
447
448 return tea.Batch(cmds...)
449}
450
451func (a appModel) View() string {
452 components := []string{
453 a.pages[a.currentPage].View(),
454 }
455
456 components = append(components, a.status.View())
457
458 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
459
460 if a.showPermissions {
461 overlay := a.permissions.View()
462 row := lipgloss.Height(appView) / 2
463 row -= lipgloss.Height(overlay) / 2
464 col := lipgloss.Width(appView) / 2
465 col -= lipgloss.Width(overlay) / 2
466 appView = layout.PlaceOverlay(
467 col,
468 row,
469 overlay,
470 appView,
471 true,
472 )
473 }
474
475 if !a.app.CoderAgent.IsBusy() {
476 a.status.SetHelpMsg("ctrl+? help")
477 } else {
478 a.status.SetHelpMsg("? help")
479 }
480
481 if a.showHelp {
482 bindings := layout.KeyMapToSlice(keys)
483 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
484 bindings = append(bindings, p.BindingKeys()...)
485 }
486 if a.showPermissions {
487 bindings = append(bindings, a.permissions.BindingKeys()...)
488 }
489 if a.currentPage == page.LogsPage {
490 bindings = append(bindings, logsKeyReturnKey)
491 }
492 if !a.app.CoderAgent.IsBusy() {
493 bindings = append(bindings, helpEsc)
494 }
495 a.help.SetBindings(bindings)
496
497 overlay := a.help.View()
498 row := lipgloss.Height(appView) / 2
499 row -= lipgloss.Height(overlay) / 2
500 col := lipgloss.Width(appView) / 2
501 col -= lipgloss.Width(overlay) / 2
502 appView = layout.PlaceOverlay(
503 col,
504 row,
505 overlay,
506 appView,
507 true,
508 )
509 }
510
511 if a.showQuit {
512 overlay := a.quit.View()
513 row := lipgloss.Height(appView) / 2
514 row -= lipgloss.Height(overlay) / 2
515 col := lipgloss.Width(appView) / 2
516 col -= lipgloss.Width(overlay) / 2
517 appView = layout.PlaceOverlay(
518 col,
519 row,
520 overlay,
521 appView,
522 true,
523 )
524 }
525
526 if a.showSessionDialog {
527 overlay := a.sessionDialog.View()
528 row := lipgloss.Height(appView) / 2
529 row -= lipgloss.Height(overlay) / 2
530 col := lipgloss.Width(appView) / 2
531 col -= lipgloss.Width(overlay) / 2
532 appView = layout.PlaceOverlay(
533 col,
534 row,
535 overlay,
536 appView,
537 true,
538 )
539 }
540
541 if a.showCommandDialog {
542 overlay := a.commandDialog.View()
543 row := lipgloss.Height(appView) / 2
544 row -= lipgloss.Height(overlay) / 2
545 col := lipgloss.Width(appView) / 2
546 col -= lipgloss.Width(overlay) / 2
547 appView = layout.PlaceOverlay(
548 col,
549 row,
550 overlay,
551 appView,
552 true,
553 )
554 }
555
556 if a.showInitDialog {
557 overlay := a.initDialog.View()
558 appView = layout.PlaceOverlay(
559 a.width/2-lipgloss.Width(overlay)/2,
560 a.height/2-lipgloss.Height(overlay)/2,
561 overlay,
562 appView,
563 true,
564 )
565 }
566
567 return appView
568}
569
570func New(app *app.App) tea.Model {
571 startPage := page.ChatPage
572 model := &appModel{
573 currentPage: startPage,
574 loadedPages: make(map[page.PageID]bool),
575 status: core.NewStatusCmp(app.LSPClients),
576 help: dialog.NewHelpCmp(),
577 quit: dialog.NewQuitCmp(),
578 sessionDialog: dialog.NewSessionDialogCmp(),
579 commandDialog: dialog.NewCommandDialogCmp(),
580 permissions: dialog.NewPermissionDialogCmp(),
581 initDialog: dialog.NewInitDialogCmp(),
582 app: app,
583 commands: []dialog.Command{},
584 pages: map[page.PageID]tea.Model{
585 page.ChatPage: page.NewChatPage(app),
586 page.LogsPage: page.NewLogsPage(),
587 },
588 }
589
590 model.RegisterCommand(dialog.Command{
591 ID: "init",
592 Title: "Initialize Project",
593 Description: "Create/Update the OpenCode.md memory file",
594 Handler: func(cmd dialog.Command) tea.Cmd {
595 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
5961. Build/lint/test commands - especially for running a single test
5972. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
598
599The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
600If there's already a opencode.md, improve it.
601If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
602 return tea.Batch(
603 util.CmdHandler(chat.SendMsg{
604 Text: prompt,
605 }),
606 )
607 },
608 })
609 return model
610}