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