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