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
335 if payload.SessionID != "" {
336 // Switch to the new session
337 return a, func() tea.Msg {
338 sessions, err := a.app.Sessions.List(context.Background())
339 if err != nil {
340 return util.InfoMsg{
341 Type: util.InfoTypeError,
342 Msg: "Failed to list sessions: " + err.Error(),
343 }
344 }
345
346 for _, s := range sessions {
347 if s.ID == payload.SessionID {
348 return dialog.SessionSelectedMsg{Session: s}
349 }
350 }
351
352 return util.InfoMsg{
353 Type: util.InfoTypeError,
354 Msg: "Failed to find new session",
355 }
356 }
357 }
358 return a, util.ReportInfo("Session summarization complete")
359 } else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
360 model := a.app.CoderAgent.Model()
361 contextWindow := model.ContextWindow
362 tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
363 if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
364 return a, util.CmdHandler(startCompactSessionMsg{})
365 }
366 }
367 // Continue listening for events
368 return a, nil
369
370 case dialog.CloseThemeDialogMsg:
371 a.showThemeDialog = false
372 return a, nil
373
374 case dialog.ThemeChangedMsg:
375 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
376 a.showThemeDialog = false
377 return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
378
379 case dialog.CloseModelDialogMsg:
380 a.showModelDialog = false
381 return a, nil
382
383 case dialog.ModelSelectedMsg:
384 a.showModelDialog = false
385
386 model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
387 if err != nil {
388 return a, util.ReportError(err)
389 }
390
391 return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
392
393 case dialog.ShowInitDialogMsg:
394 a.showInitDialog = msg.Show
395 return a, nil
396
397 case dialog.CloseInitDialogMsg:
398 a.showInitDialog = false
399 if msg.Initialize {
400 // Run the initialization command
401 for _, cmd := range a.commands {
402 if cmd.ID == "init" {
403 // Mark the project as initialized
404 if err := config.MarkProjectInitialized(); err != nil {
405 return a, util.ReportError(err)
406 }
407 return a, cmd.Handler(cmd)
408 }
409 }
410 } else {
411 // Mark the project as initialized without running the command
412 if err := config.MarkProjectInitialized(); err != nil {
413 return a, util.ReportError(err)
414 }
415 }
416 return a, nil
417
418 case chat.SessionSelectedMsg:
419 a.selectedSession = msg
420 a.sessionDialog.SetSelectedSession(msg.ID)
421
422 case pubsub.Event[session.Session]:
423 if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
424 a.selectedSession = msg.Payload
425 }
426 case dialog.SessionSelectedMsg:
427 a.showSessionDialog = false
428 if a.currentPage == page.ChatPage {
429 return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
430 }
431 return a, nil
432
433 case dialog.CommandSelectedMsg:
434 a.showCommandDialog = false
435 // Execute the command handler if available
436 if msg.Command.Handler != nil {
437 return a, msg.Command.Handler(msg.Command)
438 }
439 return a, util.ReportInfo("Command selected: " + msg.Command.Title)
440
441 case dialog.ShowMultiArgumentsDialogMsg:
442 // Show multi-arguments dialog
443 a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
444 a.showMultiArgumentsDialog = true
445 return a, a.multiArgumentsDialog.Init()
446
447 case dialog.CloseMultiArgumentsDialogMsg:
448 // Close multi-arguments dialog
449 a.showMultiArgumentsDialog = false
450
451 // If submitted, replace all named arguments and run the command
452 if msg.Submit {
453 content := msg.Content
454
455 // Replace each named argument with its value
456 for name, value := range msg.Args {
457 placeholder := "$" + name
458 content = strings.ReplaceAll(content, placeholder, value)
459 }
460
461 // Execute the command with arguments
462 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
463 Content: content,
464 Args: msg.Args,
465 })
466 }
467 return a, nil
468
469 case tea.KeyMsg:
470 // If multi-arguments dialog is open, let it handle the key press first
471 if a.showMultiArgumentsDialog {
472 args, cmd := a.multiArgumentsDialog.Update(msg)
473 a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
474 return a, cmd
475 }
476
477 switch {
478
479 case key.Matches(msg, keys.Quit):
480 a.showQuit = !a.showQuit
481 if a.showHelp {
482 a.showHelp = false
483 }
484 if a.showSessionDialog {
485 a.showSessionDialog = false
486 }
487 if a.showCommandDialog {
488 a.showCommandDialog = false
489 }
490 if a.showFilepicker {
491 a.showFilepicker = false
492 a.filepicker.ToggleFilepicker(a.showFilepicker)
493 }
494 if a.showModelDialog {
495 a.showModelDialog = false
496 }
497 if a.showMultiArgumentsDialog {
498 a.showMultiArgumentsDialog = false
499 }
500 return a, nil
501 case key.Matches(msg, keys.SwitchSession):
502 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
503 // Load sessions and show the dialog
504 sessions, err := a.app.Sessions.List(context.Background())
505 if err != nil {
506 return a, util.ReportError(err)
507 }
508 if len(sessions) == 0 {
509 return a, util.ReportWarn("No sessions available")
510 }
511 a.sessionDialog.SetSessions(sessions)
512 a.showSessionDialog = true
513 return a, nil
514 }
515 return a, nil
516 case key.Matches(msg, keys.Commands):
517 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
518 // Show commands dialog
519 if len(a.commands) == 0 {
520 return a, util.ReportWarn("No commands available")
521 }
522 a.commandDialog.SetCommands(a.commands)
523 a.showCommandDialog = true
524 return a, nil
525 }
526 return a, nil
527 case key.Matches(msg, keys.Models):
528 if a.showModelDialog {
529 a.showModelDialog = false
530 return a, nil
531 }
532 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
533 a.showModelDialog = true
534 return a, nil
535 }
536 return a, nil
537 case key.Matches(msg, keys.SwitchTheme):
538 if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
539 // Show theme switcher dialog
540 a.showThemeDialog = true
541 // Theme list is dynamically loaded by the dialog component
542 return a, a.themeDialog.Init()
543 }
544 return a, nil
545 case key.Matches(msg, returnKey) || key.Matches(msg):
546 if msg.String() == quitKey {
547 if a.currentPage == page.LogsPage {
548 return a, a.moveToPage(page.ChatPage)
549 }
550 } else if !a.filepicker.IsCWDFocused() {
551 if a.showQuit {
552 a.showQuit = !a.showQuit
553 return a, nil
554 }
555 if a.showHelp {
556 a.showHelp = !a.showHelp
557 return a, nil
558 }
559 if a.showInitDialog {
560 a.showInitDialog = false
561 // Mark the project as initialized without running the command
562 if err := config.MarkProjectInitialized(); err != nil {
563 return a, util.ReportError(err)
564 }
565 return a, nil
566 }
567 if a.showFilepicker {
568 a.showFilepicker = false
569 a.filepicker.ToggleFilepicker(a.showFilepicker)
570 return a, nil
571 }
572 if a.currentPage == page.LogsPage {
573 return a, a.moveToPage(page.ChatPage)
574 }
575 }
576 case key.Matches(msg, keys.Logs):
577 return a, a.moveToPage(page.LogsPage)
578 case key.Matches(msg, keys.Help):
579 if a.showQuit {
580 return a, nil
581 }
582 a.showHelp = !a.showHelp
583 return a, nil
584 case key.Matches(msg, helpEsc):
585 if a.app.CoderAgent.IsBusy() {
586 if a.showQuit {
587 return a, nil
588 }
589 a.showHelp = !a.showHelp
590 return a, nil
591 }
592 case key.Matches(msg, keys.Filepicker):
593 a.showFilepicker = !a.showFilepicker
594 a.filepicker.ToggleFilepicker(a.showFilepicker)
595 return a, nil
596 }
597 default:
598 f, filepickerCmd := a.filepicker.Update(msg)
599 a.filepicker = f.(dialog.FilepickerCmp)
600 cmds = append(cmds, filepickerCmd)
601
602 }
603
604 if a.showFilepicker {
605 f, filepickerCmd := a.filepicker.Update(msg)
606 a.filepicker = f.(dialog.FilepickerCmp)
607 cmds = append(cmds, filepickerCmd)
608 // Only block key messages send all other messages down
609 if _, ok := msg.(tea.KeyMsg); ok {
610 return a, tea.Batch(cmds...)
611 }
612 }
613
614 if a.showQuit {
615 q, quitCmd := a.quit.Update(msg)
616 a.quit = q.(dialog.QuitDialog)
617 cmds = append(cmds, quitCmd)
618 // Only block key messages send all other messages down
619 if _, ok := msg.(tea.KeyMsg); ok {
620 return a, tea.Batch(cmds...)
621 }
622 }
623 if a.showPermissions {
624 d, permissionsCmd := a.permissions.Update(msg)
625 a.permissions = d.(dialog.PermissionDialogCmp)
626 cmds = append(cmds, permissionsCmd)
627 // Only block key messages send all other messages down
628 if _, ok := msg.(tea.KeyMsg); ok {
629 return a, tea.Batch(cmds...)
630 }
631 }
632
633 if a.showSessionDialog {
634 d, sessionCmd := a.sessionDialog.Update(msg)
635 a.sessionDialog = d.(dialog.SessionDialog)
636 cmds = append(cmds, sessionCmd)
637 // Only block key messages send all other messages down
638 if _, ok := msg.(tea.KeyMsg); ok {
639 return a, tea.Batch(cmds...)
640 }
641 }
642
643 if a.showCommandDialog {
644 d, commandCmd := a.commandDialog.Update(msg)
645 a.commandDialog = d.(dialog.CommandDialog)
646 cmds = append(cmds, commandCmd)
647 // Only block key messages send all other messages down
648 if _, ok := msg.(tea.KeyMsg); ok {
649 return a, tea.Batch(cmds...)
650 }
651 }
652
653 if a.showModelDialog {
654 d, modelCmd := a.modelDialog.Update(msg)
655 a.modelDialog = d.(dialog.ModelDialog)
656 cmds = append(cmds, modelCmd)
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.showInitDialog {
664 d, initCmd := a.initDialog.Update(msg)
665 a.initDialog = d.(dialog.InitDialogCmp)
666 cmds = append(cmds, initCmd)
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.showThemeDialog {
674 d, themeCmd := a.themeDialog.Update(msg)
675 a.themeDialog = d.(dialog.ThemeDialog)
676 cmds = append(cmds, themeCmd)
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 s, _ := a.status.Update(msg)
684 a.status = s.(core.StatusCmp)
685 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
686 cmds = append(cmds, cmd)
687 return a, tea.Batch(cmds...)
688}
689
690// RegisterCommand adds a command to the command dialog
691func (a *appModel) RegisterCommand(cmd dialog.Command) {
692 a.commands = append(a.commands, cmd)
693}
694
695func (a *appModel) findCommand(id string) (dialog.Command, bool) {
696 for _, cmd := range a.commands {
697 if cmd.ID == id {
698 return cmd, true
699 }
700 }
701 return dialog.Command{}, false
702}
703
704func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
705 if a.app.CoderAgent.IsBusy() {
706 // For now we don't move to any page if the agent is busy
707 return util.ReportWarn("Agent is busy, please wait...")
708 }
709
710 var cmds []tea.Cmd
711 if _, ok := a.loadedPages[pageID]; !ok {
712 cmd := a.pages[pageID].Init()
713 cmds = append(cmds, cmd)
714 a.loadedPages[pageID] = true
715 }
716 a.previousPage = a.currentPage
717 a.currentPage = pageID
718 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
719 cmd := sizable.SetSize(a.width, a.height)
720 cmds = append(cmds, cmd)
721 }
722
723 return tea.Batch(cmds...)
724}
725
726func (a appModel) View() string {
727 components := []string{
728 a.pages[a.currentPage].View(),
729 }
730
731 components = append(components, a.status.View())
732
733 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
734
735 if a.showPermissions {
736 overlay := a.permissions.View()
737 row := lipgloss.Height(appView) / 2
738 row -= lipgloss.Height(overlay) / 2
739 col := lipgloss.Width(appView) / 2
740 col -= lipgloss.Width(overlay) / 2
741 appView = layout.PlaceOverlay(
742 col,
743 row,
744 overlay,
745 appView,
746 true,
747 )
748 }
749
750 if a.showFilepicker {
751 overlay := a.filepicker.View()
752 row := lipgloss.Height(appView) / 2
753 row -= lipgloss.Height(overlay) / 2
754 col := lipgloss.Width(appView) / 2
755 col -= lipgloss.Width(overlay) / 2
756 appView = layout.PlaceOverlay(
757 col,
758 row,
759 overlay,
760 appView,
761 true,
762 )
763
764 }
765
766 // Show compacting status overlay
767 if a.isCompacting {
768 t := theme.CurrentTheme()
769 style := lipgloss.NewStyle().
770 Border(lipgloss.RoundedBorder()).
771 BorderForeground(t.BorderFocused()).
772 BorderBackground(t.Background()).
773 Padding(1, 2).
774 Background(t.Background()).
775 Foreground(t.Text())
776
777 overlay := style.Render("Summarizing\n" + a.compactingMessage)
778 row := lipgloss.Height(appView) / 2
779 row -= lipgloss.Height(overlay) / 2
780 col := lipgloss.Width(appView) / 2
781 col -= lipgloss.Width(overlay) / 2
782 appView = layout.PlaceOverlay(
783 col,
784 row,
785 overlay,
786 appView,
787 true,
788 )
789 }
790
791 if a.showHelp {
792 bindings := layout.KeyMapToSlice(keys)
793 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
794 bindings = append(bindings, p.BindingKeys()...)
795 }
796 if a.showPermissions {
797 bindings = append(bindings, a.permissions.BindingKeys()...)
798 }
799 if a.currentPage == page.LogsPage {
800 bindings = append(bindings, logsKeyReturnKey)
801 }
802 if !a.app.CoderAgent.IsBusy() {
803 bindings = append(bindings, helpEsc)
804 }
805 a.help.SetBindings(bindings)
806
807 overlay := a.help.View()
808 row := lipgloss.Height(appView) / 2
809 row -= lipgloss.Height(overlay) / 2
810 col := lipgloss.Width(appView) / 2
811 col -= lipgloss.Width(overlay) / 2
812 appView = layout.PlaceOverlay(
813 col,
814 row,
815 overlay,
816 appView,
817 true,
818 )
819 }
820
821 if a.showQuit {
822 overlay := a.quit.View()
823 row := lipgloss.Height(appView) / 2
824 row -= lipgloss.Height(overlay) / 2
825 col := lipgloss.Width(appView) / 2
826 col -= lipgloss.Width(overlay) / 2
827 appView = layout.PlaceOverlay(
828 col,
829 row,
830 overlay,
831 appView,
832 true,
833 )
834 }
835
836 if a.showSessionDialog {
837 overlay := a.sessionDialog.View()
838 row := lipgloss.Height(appView) / 2
839 row -= lipgloss.Height(overlay) / 2
840 col := lipgloss.Width(appView) / 2
841 col -= lipgloss.Width(overlay) / 2
842 appView = layout.PlaceOverlay(
843 col,
844 row,
845 overlay,
846 appView,
847 true,
848 )
849 }
850
851 if a.showModelDialog {
852 overlay := a.modelDialog.View()
853 row := lipgloss.Height(appView) / 2
854 row -= lipgloss.Height(overlay) / 2
855 col := lipgloss.Width(appView) / 2
856 col -= lipgloss.Width(overlay) / 2
857 appView = layout.PlaceOverlay(
858 col,
859 row,
860 overlay,
861 appView,
862 true,
863 )
864 }
865
866 if a.showCommandDialog {
867 overlay := a.commandDialog.View()
868 row := lipgloss.Height(appView) / 2
869 row -= lipgloss.Height(overlay) / 2
870 col := lipgloss.Width(appView) / 2
871 col -= lipgloss.Width(overlay) / 2
872 appView = layout.PlaceOverlay(
873 col,
874 row,
875 overlay,
876 appView,
877 true,
878 )
879 }
880
881 if a.showInitDialog {
882 overlay := a.initDialog.View()
883 appView = layout.PlaceOverlay(
884 a.width/2-lipgloss.Width(overlay)/2,
885 a.height/2-lipgloss.Height(overlay)/2,
886 overlay,
887 appView,
888 true,
889 )
890 }
891
892 if a.showThemeDialog {
893 overlay := a.themeDialog.View()
894 row := lipgloss.Height(appView) / 2
895 row -= lipgloss.Height(overlay) / 2
896 col := lipgloss.Width(appView) / 2
897 col -= lipgloss.Width(overlay) / 2
898 appView = layout.PlaceOverlay(
899 col,
900 row,
901 overlay,
902 appView,
903 true,
904 )
905 }
906
907 if a.showMultiArgumentsDialog {
908 overlay := a.multiArgumentsDialog.View()
909 row := lipgloss.Height(appView) / 2
910 row -= lipgloss.Height(overlay) / 2
911 col := lipgloss.Width(appView) / 2
912 col -= lipgloss.Width(overlay) / 2
913 appView = layout.PlaceOverlay(
914 col,
915 row,
916 overlay,
917 appView,
918 true,
919 )
920 }
921
922 return appView
923}
924
925func New(app *app.App) tea.Model {
926 startPage := page.ChatPage
927 model := &appModel{
928 currentPage: startPage,
929 loadedPages: make(map[page.PageID]bool),
930 status: core.NewStatusCmp(app.LSPClients),
931 help: dialog.NewHelpCmp(),
932 quit: dialog.NewQuitCmp(),
933 sessionDialog: dialog.NewSessionDialogCmp(),
934 commandDialog: dialog.NewCommandDialogCmp(),
935 modelDialog: dialog.NewModelDialogCmp(),
936 permissions: dialog.NewPermissionDialogCmp(),
937 initDialog: dialog.NewInitDialogCmp(),
938 themeDialog: dialog.NewThemeDialogCmp(),
939 app: app,
940 commands: []dialog.Command{},
941 pages: map[page.PageID]tea.Model{
942 page.ChatPage: page.NewChatPage(app),
943 page.LogsPage: page.NewLogsPage(),
944 },
945 filepicker: dialog.NewFilepickerCmp(app),
946 }
947
948 model.RegisterCommand(dialog.Command{
949 ID: "init",
950 Title: "Initialize Project",
951 Description: "Create/Update the OpenCode.md memory file",
952 Handler: func(cmd dialog.Command) tea.Cmd {
953 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
9541. Build/lint/test commands - especially for running a single test
9552. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
956
957The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
958If there's already a opencode.md, improve it.
959If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
960 return tea.Batch(
961 util.CmdHandler(chat.SendMsg{
962 Text: prompt,
963 }),
964 )
965 },
966 })
967
968 model.RegisterCommand(dialog.Command{
969 ID: "compact",
970 Title: "Compact Session",
971 Description: "Summarize the current session and create a new one with the summary",
972 Handler: func(cmd dialog.Command) tea.Cmd {
973 return func() tea.Msg {
974 return startCompactSessionMsg{}
975 }
976 },
977 })
978 // Load custom commands
979 customCommands, err := dialog.LoadCustomCommands()
980 if err != nil {
981 logging.Warn("Failed to load custom commands", "error", err)
982 } else {
983 for _, cmd := range customCommands {
984 model.RegisterCommand(cmd)
985 }
986 }
987
988 return model
989}