1package tui
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/charmbracelet/bubbles/key"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/opencode-ai/opencode/internal/app"
12 "github.com/opencode-ai/opencode/internal/config"
13 "github.com/opencode-ai/opencode/internal/llm/agent"
14 "github.com/opencode-ai/opencode/internal/logging"
15 "github.com/opencode-ai/opencode/internal/permission"
16 "github.com/opencode-ai/opencode/internal/pubsub"
17 "github.com/opencode-ai/opencode/internal/session"
18 "github.com/opencode-ai/opencode/internal/tui/components/chat"
19 "github.com/opencode-ai/opencode/internal/tui/components/core"
20 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
21 "github.com/opencode-ai/opencode/internal/tui/layout"
22 "github.com/opencode-ai/opencode/internal/tui/page"
23 "github.com/opencode-ai/opencode/internal/tui/theme"
24 "github.com/opencode-ai/opencode/internal/tui/util"
25)
26
27type keyMap struct {
28 Logs key.Binding
29 Quit key.Binding
30 Help key.Binding
31 SwitchSession key.Binding
32 Commands key.Binding
33 Filepicker key.Binding
34 Models key.Binding
35 SwitchTheme key.Binding
36}
37
38type startCompactSessionMsg struct{}
39
40const (
41 quitKey = "q"
42)
43
44var keys = keyMap{
45 Logs: key.NewBinding(
46 key.WithKeys("ctrl+l"),
47 key.WithHelp("ctrl+l", "logs"),
48 ),
49
50 Quit: key.NewBinding(
51 key.WithKeys("ctrl+c"),
52 key.WithHelp("ctrl+c", "quit"),
53 ),
54 Help: key.NewBinding(
55 key.WithKeys("ctrl+_"),
56 key.WithHelp("ctrl+?", "toggle help"),
57 ),
58
59 SwitchSession: key.NewBinding(
60 key.WithKeys("ctrl+s"),
61 key.WithHelp("ctrl+s", "switch session"),
62 ),
63
64 Commands: key.NewBinding(
65 key.WithKeys("ctrl+k"),
66 key.WithHelp("ctrl+k", "commands"),
67 ),
68 Filepicker: key.NewBinding(
69 key.WithKeys("ctrl+f"),
70 key.WithHelp("ctrl+f", "select files to upload"),
71 ),
72 Models: key.NewBinding(
73 key.WithKeys("ctrl+o"),
74 key.WithHelp("ctrl+o", "model selection"),
75 ),
76
77 SwitchTheme: key.NewBinding(
78 key.WithKeys("ctrl+t"),
79 key.WithHelp("ctrl+t", "switch theme"),
80 ),
81}
82
83var helpEsc = key.NewBinding(
84 key.WithKeys("?"),
85 key.WithHelp("?", "toggle help"),
86)
87
88var returnKey = key.NewBinding(
89 key.WithKeys("esc"),
90 key.WithHelp("esc", "close"),
91)
92
93var logsKeyReturnKey = key.NewBinding(
94 key.WithKeys("esc", "backspace", quitKey),
95 key.WithHelp("esc/q", "go back"),
96)
97
98type appModel struct {
99 width, height int
100 currentPage page.PageID
101 previousPage page.PageID
102 pages map[page.PageID]tea.Model
103 loadedPages map[page.PageID]bool
104 status core.StatusCmp
105 app *app.App
106 selectedSession session.Session
107
108 showPermissions bool
109 permissions dialog.PermissionDialogCmp
110
111 showHelp bool
112 help dialog.HelpCmp
113
114 showQuit bool
115 quit dialog.QuitDialog
116
117 showSessionDialog bool
118 sessionDialog dialog.SessionDialog
119
120 showCommandDialog bool
121 commandDialog dialog.CommandDialog
122 commands []dialog.Command
123
124 showModelDialog bool
125 modelDialog dialog.ModelDialog
126
127 showInitDialog bool
128 initDialog dialog.InitDialogCmp
129
130 showFilepicker bool
131 filepicker dialog.FilepickerCmp
132
133 showThemeDialog bool
134 themeDialog dialog.ThemeDialog
135
136 showMultiArgumentsDialog bool
137 multiArgumentsDialog dialog.MultiArgumentsDialogCmp
138
139 showLSPSetupDialog bool
140 lspSetupDialog *dialog.LSPSetupWizard
141
142 isCompacting bool
143 compactingMessage string
144}
145
146func (a appModel) Init() tea.Cmd {
147 var cmds []tea.Cmd
148 cmd := a.pages[a.currentPage].Init()
149 a.loadedPages[a.currentPage] = true
150 cmds = append(cmds, cmd)
151 cmd = a.status.Init()
152 cmds = append(cmds, cmd)
153 cmd = a.quit.Init()
154 cmds = append(cmds, cmd)
155 cmd = a.help.Init()
156 cmds = append(cmds, cmd)
157 cmd = a.sessionDialog.Init()
158 cmds = append(cmds, cmd)
159 cmd = a.commandDialog.Init()
160 cmds = append(cmds, cmd)
161 cmd = a.modelDialog.Init()
162 cmds = append(cmds, cmd)
163 cmd = a.initDialog.Init()
164 cmds = append(cmds, cmd)
165 cmd = a.filepicker.Init()
166 cmds = append(cmds, cmd)
167 cmd = a.themeDialog.Init()
168 cmds = append(cmds, cmd)
169
170 // Check if we should show the init dialog
171 cmds = append(cmds, func() tea.Msg {
172 shouldShow, err := config.ShouldShowInitDialog()
173 if err != nil {
174 return util.InfoMsg{
175 Type: util.InfoTypeError,
176 Msg: "Failed to check init status: " + err.Error(),
177 }
178 }
179 return dialog.ShowInitDialogMsg{Show: shouldShow}
180 })
181
182 // Check if we should show the LSP setup dialog
183 cmds = append(cmds, func() tea.Msg {
184 shouldShow := a.app.CheckAndSetupLSP(context.Background())
185 return dialog.ShowLSPSetupMsg{Show: shouldShow}
186 })
187
188 return tea.Batch(cmds...)
189}
190
191func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192 var cmds []tea.Cmd
193 var cmd tea.Cmd
194 switch msg := msg.(type) {
195 case tea.WindowSizeMsg:
196 msg.Height -= 1 // Make space for the status bar
197 a.width, a.height = msg.Width, msg.Height
198
199 s, _ := a.status.Update(msg)
200 a.status = s.(core.StatusCmp)
201 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
202 cmds = append(cmds, cmd)
203
204 prm, permCmd := a.permissions.Update(msg)
205 a.permissions = prm.(dialog.PermissionDialogCmp)
206 cmds = append(cmds, permCmd)
207
208 help, helpCmd := a.help.Update(msg)
209 a.help = help.(dialog.HelpCmp)
210 cmds = append(cmds, helpCmd)
211
212 session, sessionCmd := a.sessionDialog.Update(msg)
213 a.sessionDialog = session.(dialog.SessionDialog)
214 cmds = append(cmds, sessionCmd)
215
216 command, commandCmd := a.commandDialog.Update(msg)
217 a.commandDialog = command.(dialog.CommandDialog)
218 cmds = append(cmds, commandCmd)
219
220 filepicker, filepickerCmd := a.filepicker.Update(msg)
221 a.filepicker = filepicker.(dialog.FilepickerCmp)
222 cmds = append(cmds, filepickerCmd)
223
224 a.initDialog.SetSize(msg.Width, msg.Height)
225
226 if a.showLSPSetupDialog && a.lspSetupDialog != nil {
227 a.lspSetupDialog.SetSize(msg.Width, msg.Height)
228 lsp, lspCmd := a.lspSetupDialog.Update(msg)
229 if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
230 a.lspSetupDialog = lsp
231 }
232 cmds = append(cmds, lspCmd)
233 }
234
235 if a.showMultiArgumentsDialog {
236 a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
237 args, argsCmd := a.multiArgumentsDialog.Update(msg)
238 a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
239 cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
240 }
241
242 return a, tea.Batch(cmds...)
243 // Status
244 case util.InfoMsg:
245 s, cmd := a.status.Update(msg)
246 a.status = s.(core.StatusCmp)
247 cmds = append(cmds, cmd)
248 return a, tea.Batch(cmds...)
249 case pubsub.Event[logging.LogMessage]:
250 if msg.Payload.Persist {
251 switch msg.Payload.Level {
252 case "error":
253 s, cmd := a.status.Update(util.InfoMsg{
254 Type: util.InfoTypeError,
255 Msg: msg.Payload.Message,
256 TTL: msg.Payload.PersistTime,
257 })
258 a.status = s.(core.StatusCmp)
259 cmds = append(cmds, cmd)
260 case "info":
261 s, cmd := a.status.Update(util.InfoMsg{
262 Type: util.InfoTypeInfo,
263 Msg: msg.Payload.Message,
264 TTL: msg.Payload.PersistTime,
265 })
266 a.status = s.(core.StatusCmp)
267 cmds = append(cmds, cmd)
268
269 case "warn":
270 s, cmd := a.status.Update(util.InfoMsg{
271 Type: util.InfoTypeWarn,
272 Msg: msg.Payload.Message,
273 TTL: msg.Payload.PersistTime,
274 })
275
276 a.status = s.(core.StatusCmp)
277 cmds = append(cmds, cmd)
278 default:
279 s, cmd := a.status.Update(util.InfoMsg{
280 Type: util.InfoTypeInfo,
281 Msg: msg.Payload.Message,
282 TTL: msg.Payload.PersistTime,
283 })
284 a.status = s.(core.StatusCmp)
285 cmds = append(cmds, cmd)
286 }
287 }
288 case util.ClearStatusMsg:
289 s, _ := a.status.Update(msg)
290 a.status = s.(core.StatusCmp)
291
292 // Permission
293 case pubsub.Event[permission.PermissionRequest]:
294 a.showPermissions = true
295 return a, a.permissions.SetPermissions(msg.Payload)
296 case dialog.PermissionResponseMsg:
297 var cmd tea.Cmd
298 switch msg.Action {
299 case dialog.PermissionAllow:
300 a.app.Permissions.Grant(msg.Permission)
301 case dialog.PermissionAllowForSession:
302 a.app.Permissions.GrantPersistant(msg.Permission)
303 case dialog.PermissionDeny:
304 a.app.Permissions.Deny(msg.Permission)
305 }
306 a.showPermissions = false
307 return a, cmd
308
309 case page.PageChangeMsg:
310 return a, a.moveToPage(msg.ID)
311
312 case dialog.CloseQuitMsg:
313 a.showQuit = false
314 return a, nil
315
316 case dialog.CloseSessionDialogMsg:
317 a.showSessionDialog = false
318 return a, nil
319
320 case dialog.CloseCommandDialogMsg:
321 a.showCommandDialog = false
322 return a, nil
323
324 case startCompactSessionMsg:
325 // Start compacting the current session
326 a.isCompacting = true
327 a.compactingMessage = "Starting summarization..."
328
329 if a.selectedSession.ID == "" {
330 a.isCompacting = false
331 return a, util.ReportWarn("No active session to summarize")
332 }
333
334 // Start the summarization process
335 return a, func() tea.Msg {
336 ctx := context.Background()
337 a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
338 return nil
339 }
340
341 case pubsub.Event[agent.AgentEvent]:
342 payload := msg.Payload
343 if payload.Error != nil {
344 a.isCompacting = false
345 return a, util.ReportError(payload.Error)
346 }
347
348 a.compactingMessage = payload.Progress
349
350 if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
351 a.isCompacting = false
352 return a, util.ReportInfo("Session summarization complete")
353 } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
354 model := a.app.CoderAgent.Model()
355 contextWindow := model.ContextWindow
356 tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
357 if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
358 return a, util.CmdHandler(startCompactSessionMsg{})
359 }
360 }
361 // Continue listening for events
362 return a, nil
363
364 case dialog.CloseThemeDialogMsg:
365 a.showThemeDialog = false
366 return a, nil
367
368 case dialog.ThemeChangedMsg:
369 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
370 a.showThemeDialog = false
371 return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
372
373 case dialog.CloseModelDialogMsg:
374 a.showModelDialog = false
375 return a, nil
376
377 case dialog.ModelSelectedMsg:
378 a.showModelDialog = false
379
380 model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
381 if err != nil {
382 return a, util.ReportError(err)
383 }
384
385 return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
386
387 case dialog.ShowInitDialogMsg:
388 a.showInitDialog = msg.Show
389 return a, nil
390
391 case dialog.ShowLSPSetupMsg:
392 a.showLSPSetupDialog = msg.Show
393 if a.showLSPSetupDialog {
394 // Initialize the LSP setup wizard with the app's LSP setup service
395 a.lspSetupDialog = dialog.NewLSPSetupWizard(context.Background(), a.app.LSPSetup)
396 a.lspSetupDialog.SetSize(a.width, a.height)
397 return a, a.lspSetupDialog.Init()
398 }
399 return a, nil
400
401 case dialog.CloseLSPSetupMsg:
402 a.showLSPSetupDialog = false
403 if msg.Configure && len(msg.Servers) > 0 {
404 // Use the app's ConfigureLSP method to handle the configuration
405 err := a.app.ConfigureLSP(context.Background(), msg.Servers)
406 if err != nil {
407 logging.Error("Failed to update LSP configuration", "error", err)
408 return a, util.ReportError(err)
409 }
410
411 return a, util.ReportInfo("LSP configuration updated successfully")
412 }
413 return a, nil
414
415 case dialog.CloseInitDialogMsg:
416 a.showInitDialog = false
417 if msg.Initialize {
418 // Run the initialization command
419 for _, cmd := range a.commands {
420 if cmd.ID == "init" {
421 // Mark the project as initialized
422 if err := config.MarkProjectInitialized(); err != nil {
423 return a, util.ReportError(err)
424 }
425 return a, cmd.Handler(cmd)
426 }
427 }
428 } else {
429 // Mark the project as initialized without running the command
430 if err := config.MarkProjectInitialized(); err != nil {
431 return a, util.ReportError(err)
432 }
433 }
434 return a, nil
435
436 case chat.SessionSelectedMsg:
437 a.selectedSession = msg
438 a.sessionDialog.SetSelectedSession(msg.ID)
439
440 case pubsub.Event[session.Session]:
441 if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
442 a.selectedSession = msg.Payload
443 }
444 case dialog.SessionSelectedMsg:
445 a.showSessionDialog = false
446 if a.currentPage == page.ChatPage {
447 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
448 }
449 return a, nil
450
451 case dialog.CommandSelectedMsg:
452 a.showCommandDialog = false
453 // Execute the command handler if available
454 if msg.Command.Handler != nil {
455 return a, msg.Command.Handler(msg.Command)
456 }
457 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
458
459 case dialog.ShowMultiArgumentsDialogMsg:
460 // Show multi-arguments dialog
461 a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
462 a.showMultiArgumentsDialog = true
463 return a, a.multiArgumentsDialog.Init()
464
465 case dialog.CloseMultiArgumentsDialogMsg:
466 // Close multi-arguments dialog
467 a.showMultiArgumentsDialog = false
468
469 // If submitted, replace all named arguments and run the command
470 if msg.Submit {
471 content := msg.Content
472
473 // Replace each named argument with its value
474 for name, value := range msg.Args {
475 placeholder := "$" + name
476 content = strings.ReplaceAll(content, placeholder, value)
477 }
478
479 // Execute the command with arguments
480 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
481 Content: content,
482 Args: msg.Args,
483 })
484 }
485 return a, nil
486
487 case tea.KeyMsg:
488 // If LSP setup dialog is open, let it handle the key press
489 if a.showLSPSetupDialog && a.lspSetupDialog != nil {
490 lsp, cmd := a.lspSetupDialog.Update(msg)
491 if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
492 a.lspSetupDialog = lsp
493 }
494 return a, cmd
495 }
496
497 // If multi-arguments dialog is open, let it handle the key press first
498 if a.showMultiArgumentsDialog {
499 args, cmd := a.multiArgumentsDialog.Update(msg)
500 a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
501 return a, cmd
502 }
503
504 switch {
505
506 case key.Matches(msg, keys.Quit):
507 a.showQuit = !a.showQuit
508 if a.showHelp {
509 a.showHelp = false
510 }
511 if a.showSessionDialog {
512 a.showSessionDialog = false
513 }
514 if a.showCommandDialog {
515 a.showCommandDialog = false
516 }
517 if a.showFilepicker {
518 a.showFilepicker = false
519 a.filepicker.ToggleFilepicker(a.showFilepicker)
520 }
521 if a.showModelDialog {
522 a.showModelDialog = false
523 }
524 if a.showMultiArgumentsDialog {
525 a.showMultiArgumentsDialog = false
526 }
527 if a.showLSPSetupDialog {
528 a.showLSPSetupDialog = false
529 }
530 return a, nil
531 case key.Matches(msg, keys.SwitchSession):
532 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
533 // Load sessions and show the dialog
534 sessions, err := a.app.Sessions.List(context.Background())
535 if err != nil {
536 return a, util.ReportError(err)
537 }
538 if len(sessions) == 0 {
539 return a, util.ReportWarn("No sessions available")
540 }
541 a.sessionDialog.SetSessions(sessions)
542 a.showSessionDialog = true
543 return a, nil
544 }
545 return a, nil
546 case key.Matches(msg, keys.Commands):
547 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
548 // Show commands dialog
549 if len(a.commands) == 0 {
550 return a, util.ReportWarn("No commands available")
551 }
552 a.commandDialog.SetCommands(a.commands)
553 a.showCommandDialog = true
554 return a, nil
555 }
556 return a, nil
557 case key.Matches(msg, keys.Models):
558 if a.showModelDialog {
559 a.showModelDialog = false
560 return a, nil
561 }
562 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
563 a.showModelDialog = true
564 return a, nil
565 }
566 return a, nil
567 case key.Matches(msg, keys.SwitchTheme):
568 if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
569 // Show theme switcher dialog
570 a.showThemeDialog = true
571 // Theme list is dynamically loaded by the dialog component
572 return a, a.themeDialog.Init()
573 }
574 return a, nil
575 case key.Matches(msg, returnKey) || key.Matches(msg):
576 if msg.String() == quitKey {
577 if a.currentPage == page.LogsPage {
578 return a, a.moveToPage(page.ChatPage)
579 }
580 } else if !a.filepicker.IsCWDFocused() {
581 if a.showQuit {
582 a.showQuit = !a.showQuit
583 return a, nil
584 }
585 if a.showHelp {
586 a.showHelp = !a.showHelp
587 return a, nil
588 }
589 if a.showInitDialog {
590 a.showInitDialog = false
591 // Mark the project as initialized without running the command
592 if err := config.MarkProjectInitialized(); err != nil {
593 return a, util.ReportError(err)
594 }
595 return a, nil
596 }
597 if a.showFilepicker {
598 a.showFilepicker = false
599 a.filepicker.ToggleFilepicker(a.showFilepicker)
600 return a, nil
601 }
602 if a.currentPage == page.LogsPage {
603 return a, a.moveToPage(page.ChatPage)
604 }
605 }
606 case key.Matches(msg, keys.Logs):
607 return a, a.moveToPage(page.LogsPage)
608 case key.Matches(msg, keys.Help):
609 if a.showQuit {
610 return a, nil
611 }
612 a.showHelp = !a.showHelp
613 return a, nil
614 case key.Matches(msg, helpEsc):
615 if a.app.CoderAgent.IsBusy() {
616 if a.showQuit {
617 return a, nil
618 }
619 a.showHelp = !a.showHelp
620 return a, nil
621 }
622 case key.Matches(msg, keys.Filepicker):
623 a.showFilepicker = !a.showFilepicker
624 a.filepicker.ToggleFilepicker(a.showFilepicker)
625 return a, nil
626 }
627 default:
628 f, filepickerCmd := a.filepicker.Update(msg)
629 a.filepicker = f.(dialog.FilepickerCmp)
630 cmds = append(cmds, filepickerCmd)
631
632 }
633
634 if a.showFilepicker {
635 f, filepickerCmd := a.filepicker.Update(msg)
636 a.filepicker = f.(dialog.FilepickerCmp)
637 cmds = append(cmds, filepickerCmd)
638 // Only block key messages send all other messages down
639 if _, ok := msg.(tea.KeyMsg); ok {
640 return a, tea.Batch(cmds...)
641 }
642 }
643
644 if a.showQuit {
645 q, quitCmd := a.quit.Update(msg)
646 a.quit = q.(dialog.QuitDialog)
647 cmds = append(cmds, quitCmd)
648 // Only block key messages send all other messages down
649 if _, ok := msg.(tea.KeyMsg); ok {
650 return a, tea.Batch(cmds...)
651 }
652 }
653 if a.showPermissions {
654 d, permissionsCmd := a.permissions.Update(msg)
655 a.permissions = d.(dialog.PermissionDialogCmp)
656 cmds = append(cmds, permissionsCmd)
657 // Only block key messages send all other messages down
658 if _, ok := msg.(tea.KeyMsg); ok {
659 return a, tea.Batch(cmds...)
660 }
661 }
662
663 if a.showSessionDialog {
664 d, sessionCmd := a.sessionDialog.Update(msg)
665 a.sessionDialog = d.(dialog.SessionDialog)
666 cmds = append(cmds, sessionCmd)
667 // Only block key messages send all other messages down
668 if _, ok := msg.(tea.KeyMsg); ok {
669 return a, tea.Batch(cmds...)
670 }
671 }
672
673 if a.showCommandDialog {
674 d, commandCmd := a.commandDialog.Update(msg)
675 a.commandDialog = d.(dialog.CommandDialog)
676 cmds = append(cmds, commandCmd)
677 // Only block key messages send all other messages down
678 if _, ok := msg.(tea.KeyMsg); ok {
679 return a, tea.Batch(cmds...)
680 }
681 }
682
683 if a.showModelDialog {
684 d, modelCmd := a.modelDialog.Update(msg)
685 a.modelDialog = d.(dialog.ModelDialog)
686 cmds = append(cmds, modelCmd)
687 // Only block key messages send all other messages down
688 if _, ok := msg.(tea.KeyMsg); ok {
689 return a, tea.Batch(cmds...)
690 }
691 }
692
693 if a.showInitDialog {
694 d, initCmd := a.initDialog.Update(msg)
695 a.initDialog = d.(dialog.InitDialogCmp)
696 cmds = append(cmds, initCmd)
697 // Only block key messages send all other messages down
698 if _, ok := msg.(tea.KeyMsg); ok {
699 return a, tea.Batch(cmds...)
700 }
701 }
702
703 if a.showThemeDialog {
704 d, themeCmd := a.themeDialog.Update(msg)
705 a.themeDialog = d.(dialog.ThemeDialog)
706 cmds = append(cmds, themeCmd)
707 // Only block key messages send all other messages down
708 if _, ok := msg.(tea.KeyMsg); ok {
709 return a, tea.Batch(cmds...)
710 }
711 }
712
713 if a.showLSPSetupDialog {
714 d, lspCmd := a.lspSetupDialog.Update(msg)
715 a.lspSetupDialog = d.(*dialog.LSPSetupWizard)
716 cmds = append(cmds, lspCmd)
717 }
718
719 s, _ := a.status.Update(msg)
720 a.status = s.(core.StatusCmp)
721 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
722 cmds = append(cmds, cmd)
723 return a, tea.Batch(cmds...)
724}
725
726// RegisterCommand adds a command to the command dialog
727func (a *appModel) RegisterCommand(cmd dialog.Command) {
728 a.commands = append(a.commands, cmd)
729}
730
731func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
732 if a.app.CoderAgent.IsBusy() {
733 // For now we don't move to any page if the agent is busy
734 return util.ReportWarn("Agent is busy, please wait...")
735 }
736
737 var cmds []tea.Cmd
738 if _, ok := a.loadedPages[pageID]; !ok {
739 cmd := a.pages[pageID].Init()
740 cmds = append(cmds, cmd)
741 a.loadedPages[pageID] = true
742 }
743 a.previousPage = a.currentPage
744 a.currentPage = pageID
745 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
746 cmd := sizable.SetSize(a.width, a.height)
747 cmds = append(cmds, cmd)
748 }
749
750 return tea.Batch(cmds...)
751}
752
753func (a appModel) View() string {
754 components := []string{
755 a.pages[a.currentPage].View(),
756 }
757
758 components = append(components, a.status.View())
759
760 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
761
762 if a.showPermissions {
763 overlay := a.permissions.View()
764 row := lipgloss.Height(appView) / 2
765 row -= lipgloss.Height(overlay) / 2
766 col := lipgloss.Width(appView) / 2
767 col -= lipgloss.Width(overlay) / 2
768 appView = layout.PlaceOverlay(
769 col,
770 row,
771 overlay,
772 appView,
773 true,
774 )
775 }
776
777 if a.showFilepicker {
778 overlay := a.filepicker.View()
779 row := lipgloss.Height(appView) / 2
780 row -= lipgloss.Height(overlay) / 2
781 col := lipgloss.Width(appView) / 2
782 col -= lipgloss.Width(overlay) / 2
783 appView = layout.PlaceOverlay(
784 col,
785 row,
786 overlay,
787 appView,
788 true,
789 )
790
791 }
792
793 // Show compacting status overlay
794 if a.isCompacting {
795 t := theme.CurrentTheme()
796 style := lipgloss.NewStyle().
797 Border(lipgloss.RoundedBorder()).
798 BorderForeground(t.BorderFocused()).
799 BorderBackground(t.Background()).
800 Padding(1, 2).
801 Background(t.Background()).
802 Foreground(t.Text())
803
804 overlay := style.Render("Summarizing\n" + a.compactingMessage)
805 row := lipgloss.Height(appView) / 2
806 row -= lipgloss.Height(overlay) / 2
807 col := lipgloss.Width(appView) / 2
808 col -= lipgloss.Width(overlay) / 2
809 appView = layout.PlaceOverlay(
810 col,
811 row,
812 overlay,
813 appView,
814 true,
815 )
816 }
817
818 if a.showHelp {
819 bindings := layout.KeyMapToSlice(keys)
820 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
821 bindings = append(bindings, p.BindingKeys()...)
822 }
823 if a.showPermissions {
824 bindings = append(bindings, a.permissions.BindingKeys()...)
825 }
826 if a.currentPage == page.LogsPage {
827 bindings = append(bindings, logsKeyReturnKey)
828 }
829 if !a.app.CoderAgent.IsBusy() {
830 bindings = append(bindings, helpEsc)
831 }
832 a.help.SetBindings(bindings)
833
834 overlay := a.help.View()
835 row := lipgloss.Height(appView) / 2
836 row -= lipgloss.Height(overlay) / 2
837 col := lipgloss.Width(appView) / 2
838 col -= lipgloss.Width(overlay) / 2
839 appView = layout.PlaceOverlay(
840 col,
841 row,
842 overlay,
843 appView,
844 true,
845 )
846 }
847
848 if a.showQuit {
849 overlay := a.quit.View()
850 row := lipgloss.Height(appView) / 2
851 row -= lipgloss.Height(overlay) / 2
852 col := lipgloss.Width(appView) / 2
853 col -= lipgloss.Width(overlay) / 2
854 appView = layout.PlaceOverlay(
855 col,
856 row,
857 overlay,
858 appView,
859 true,
860 )
861 }
862
863 if a.showSessionDialog {
864 overlay := a.sessionDialog.View()
865 row := lipgloss.Height(appView) / 2
866 row -= lipgloss.Height(overlay) / 2
867 col := lipgloss.Width(appView) / 2
868 col -= lipgloss.Width(overlay) / 2
869 appView = layout.PlaceOverlay(
870 col,
871 row,
872 overlay,
873 appView,
874 true,
875 )
876 }
877
878 if a.showModelDialog {
879 overlay := a.modelDialog.View()
880 row := lipgloss.Height(appView) / 2
881 row -= lipgloss.Height(overlay) / 2
882 col := lipgloss.Width(appView) / 2
883 col -= lipgloss.Width(overlay) / 2
884 appView = layout.PlaceOverlay(
885 col,
886 row,
887 overlay,
888 appView,
889 true,
890 )
891 }
892
893 if a.showCommandDialog {
894 overlay := a.commandDialog.View()
895 row := lipgloss.Height(appView) / 2
896 row -= lipgloss.Height(overlay) / 2
897 col := lipgloss.Width(appView) / 2
898 col -= lipgloss.Width(overlay) / 2
899 appView = layout.PlaceOverlay(
900 col,
901 row,
902 overlay,
903 appView,
904 true,
905 )
906 }
907
908 if a.showInitDialog {
909 overlay := a.initDialog.View()
910 appView = layout.PlaceOverlay(
911 a.width/2-lipgloss.Width(overlay)/2,
912 a.height/2-lipgloss.Height(overlay)/2,
913 overlay,
914 appView,
915 true,
916 )
917 }
918
919 if a.showLSPSetupDialog && a.lspSetupDialog != nil {
920 overlay := a.lspSetupDialog.View()
921 appView = layout.PlaceOverlay(
922 a.width/2-lipgloss.Width(overlay)/2,
923 a.height/2-lipgloss.Height(overlay)/2,
924 overlay,
925 appView,
926 true,
927 )
928 }
929
930 if a.showThemeDialog {
931 overlay := a.themeDialog.View()
932 row := lipgloss.Height(appView) / 2
933 row -= lipgloss.Height(overlay) / 2
934 col := lipgloss.Width(appView) / 2
935 col -= lipgloss.Width(overlay) / 2
936 appView = layout.PlaceOverlay(
937 col,
938 row,
939 overlay,
940 appView,
941 true,
942 )
943 }
944
945 if a.showMultiArgumentsDialog {
946 overlay := a.multiArgumentsDialog.View()
947 row := lipgloss.Height(appView) / 2
948 row -= lipgloss.Height(overlay) / 2
949 col := lipgloss.Width(appView) / 2
950 col -= lipgloss.Width(overlay) / 2
951 appView = layout.PlaceOverlay(
952 col,
953 row,
954 overlay,
955 appView,
956 true,
957 )
958 }
959
960 return appView
961}
962
963func New(app *app.App) tea.Model {
964 startPage := page.ChatPage
965 model := &appModel{
966 currentPage: startPage,
967 loadedPages: make(map[page.PageID]bool),
968 status: core.NewStatusCmp(app.LSPClients),
969 help: dialog.NewHelpCmp(),
970 quit: dialog.NewQuitCmp(),
971 sessionDialog: dialog.NewSessionDialogCmp(),
972 commandDialog: dialog.NewCommandDialogCmp(),
973 modelDialog: dialog.NewModelDialogCmp(),
974 permissions: dialog.NewPermissionDialogCmp(),
975 initDialog: dialog.NewInitDialogCmp(),
976 themeDialog: dialog.NewThemeDialogCmp(),
977 app: app,
978 commands: []dialog.Command{},
979 pages: map[page.PageID]tea.Model{
980 page.ChatPage: page.NewChatPage(app),
981 page.LogsPage: page.NewLogsPage(),
982 },
983 filepicker: dialog.NewFilepickerCmp(app),
984 }
985
986 model.RegisterCommand(dialog.Command{
987 ID: "init",
988 Title: "Initialize Project",
989 Description: "Create/Update the OpenCode.md memory file",
990 Handler: func(cmd dialog.Command) tea.Cmd {
991 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
9921. Build/lint/test commands - especially for running a single test
9932. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
994
995The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
996If there's already a opencode.md, improve it.
997If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
998 return tea.Batch(
999 util.CmdHandler(chat.SendMsg{
1000 Text: prompt,
1001 }),
1002 )
1003 },
1004 })
1005
1006 model.RegisterCommand(dialog.Command{
1007 ID: "compact",
1008 Title: "Compact Session",
1009 Description: "Summarize the current session and create a new one with the summary",
1010 Handler: func(cmd dialog.Command) tea.Cmd {
1011 return func() tea.Msg {
1012 return startCompactSessionMsg{}
1013 }
1014 },
1015 })
1016
1017 model.RegisterCommand(dialog.Command{
1018 ID: "setup-lsp",
1019 Title: "Setup LSP",
1020 Description: "Configure Language Server Protocol integration",
1021 Handler: func(cmd dialog.Command) tea.Cmd {
1022 return func() tea.Msg {
1023 return dialog.ShowLSPSetupMsg{Show: true}
1024 }
1025 },
1026 })
1027 // Load custom commands
1028 customCommands, err := dialog.LoadCustomCommands()
1029 if err != nil {
1030 logging.Warn("Failed to load custom commands", "error", err)
1031 } else {
1032 for _, cmd := range customCommands {
1033 model.RegisterCommand(cmd)
1034 }
1035 }
1036
1037 return model
1038}