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 showArgumentsDialog bool
137 argumentsDialog dialog.ArgumentsDialogCmp
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.showArgumentsDialog {
218 a.argumentsDialog.SetSize(msg.Width, msg.Height)
219 args, argsCmd := a.argumentsDialog.Update(msg)
220 a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
221 cmds = append(cmds, argsCmd, a.argumentsDialog.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.ShowArgumentsDialogMsg:
442 // Show arguments dialog
443 a.argumentsDialog = dialog.NewArgumentsDialogCmp(msg.CommandID, msg.Content)
444 a.showArgumentsDialog = true
445 return a, a.argumentsDialog.Init()
446
447 case dialog.CloseArgumentsDialogMsg:
448 // Close arguments dialog
449 a.showArgumentsDialog = false
450
451 // If submitted, replace $ARGUMENTS and run the command
452 if msg.Submit {
453 // Replace $ARGUMENTS with the provided arguments
454 content := strings.ReplaceAll(msg.Content, "$ARGUMENTS", msg.Arguments)
455
456 // Execute the command with arguments
457 return a, util.CmdHandler(dialog.CommandRunCustomMsg{
458 Content: content,
459 })
460 }
461 return a, nil
462
463 case tea.KeyMsg:
464 // If arguments dialog is open, let it handle the key press first
465 if a.showArgumentsDialog {
466 args, cmd := a.argumentsDialog.Update(msg)
467 a.argumentsDialog = args.(dialog.ArgumentsDialogCmp)
468 return a, cmd
469 }
470
471 switch {
472
473 case key.Matches(msg, keys.Quit):
474 a.showQuit = !a.showQuit
475 if a.showHelp {
476 a.showHelp = false
477 }
478 if a.showSessionDialog {
479 a.showSessionDialog = false
480 }
481 if a.showCommandDialog {
482 a.showCommandDialog = false
483 }
484 if a.showFilepicker {
485 a.showFilepicker = false
486 a.filepicker.ToggleFilepicker(a.showFilepicker)
487 }
488 if a.showModelDialog {
489 a.showModelDialog = false
490 }
491 if a.showArgumentsDialog {
492 a.showArgumentsDialog = false
493 }
494 return a, nil
495 case key.Matches(msg, keys.SwitchSession):
496 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
497 // Load sessions and show the dialog
498 sessions, err := a.app.Sessions.List(context.Background())
499 if err != nil {
500 return a, util.ReportError(err)
501 }
502 if len(sessions) == 0 {
503 return a, util.ReportWarn("No sessions available")
504 }
505 a.sessionDialog.SetSessions(sessions)
506 a.showSessionDialog = true
507 return a, nil
508 }
509 return a, nil
510 case key.Matches(msg, keys.Commands):
511 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
512 // Show commands dialog
513 if len(a.commands) == 0 {
514 return a, util.ReportWarn("No commands available")
515 }
516 a.commandDialog.SetCommands(a.commands)
517 a.showCommandDialog = true
518 return a, nil
519 }
520 return a, nil
521 case key.Matches(msg, keys.Models):
522 if a.showModelDialog {
523 a.showModelDialog = false
524 return a, nil
525 }
526 if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
527 a.showModelDialog = true
528 return a, nil
529 }
530 return a, nil
531 case key.Matches(msg, keys.SwitchTheme):
532 if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
533 // Show theme switcher dialog
534 a.showThemeDialog = true
535 // Theme list is dynamically loaded by the dialog component
536 return a, a.themeDialog.Init()
537 }
538 return a, nil
539 case key.Matches(msg, returnKey) || key.Matches(msg):
540 if msg.String() == quitKey {
541 if a.currentPage == page.LogsPage {
542 return a, a.moveToPage(page.ChatPage)
543 }
544 } else if !a.filepicker.IsCWDFocused() {
545 if a.showQuit {
546 a.showQuit = !a.showQuit
547 return a, nil
548 }
549 if a.showHelp {
550 a.showHelp = !a.showHelp
551 return a, nil
552 }
553 if a.showInitDialog {
554 a.showInitDialog = false
555 // Mark the project as initialized without running the command
556 if err := config.MarkProjectInitialized(); err != nil {
557 return a, util.ReportError(err)
558 }
559 return a, nil
560 }
561 if a.showFilepicker {
562 a.showFilepicker = false
563 a.filepicker.ToggleFilepicker(a.showFilepicker)
564 return a, nil
565 }
566 if a.currentPage == page.LogsPage {
567 return a, a.moveToPage(page.ChatPage)
568 }
569 }
570 case key.Matches(msg, keys.Logs):
571 return a, a.moveToPage(page.LogsPage)
572 case key.Matches(msg, keys.Help):
573 if a.showQuit {
574 return a, nil
575 }
576 a.showHelp = !a.showHelp
577 return a, nil
578 case key.Matches(msg, helpEsc):
579 if a.app.CoderAgent.IsBusy() {
580 if a.showQuit {
581 return a, nil
582 }
583 a.showHelp = !a.showHelp
584 return a, nil
585 }
586 case key.Matches(msg, keys.Filepicker):
587 a.showFilepicker = !a.showFilepicker
588 a.filepicker.ToggleFilepicker(a.showFilepicker)
589 return a, nil
590 }
591 default:
592 f, filepickerCmd := a.filepicker.Update(msg)
593 a.filepicker = f.(dialog.FilepickerCmp)
594 cmds = append(cmds, filepickerCmd)
595
596 }
597
598 if a.showFilepicker {
599 f, filepickerCmd := a.filepicker.Update(msg)
600 a.filepicker = f.(dialog.FilepickerCmp)
601 cmds = append(cmds, filepickerCmd)
602 // Only block key messages send all other messages down
603 if _, ok := msg.(tea.KeyMsg); ok {
604 return a, tea.Batch(cmds...)
605 }
606 }
607
608 if a.showQuit {
609 q, quitCmd := a.quit.Update(msg)
610 a.quit = q.(dialog.QuitDialog)
611 cmds = append(cmds, quitCmd)
612 // Only block key messages send all other messages down
613 if _, ok := msg.(tea.KeyMsg); ok {
614 return a, tea.Batch(cmds...)
615 }
616 }
617 if a.showPermissions {
618 d, permissionsCmd := a.permissions.Update(msg)
619 a.permissions = d.(dialog.PermissionDialogCmp)
620 cmds = append(cmds, permissionsCmd)
621 // Only block key messages send all other messages down
622 if _, ok := msg.(tea.KeyMsg); ok {
623 return a, tea.Batch(cmds...)
624 }
625 }
626
627 if a.showSessionDialog {
628 d, sessionCmd := a.sessionDialog.Update(msg)
629 a.sessionDialog = d.(dialog.SessionDialog)
630 cmds = append(cmds, sessionCmd)
631 // Only block key messages send all other messages down
632 if _, ok := msg.(tea.KeyMsg); ok {
633 return a, tea.Batch(cmds...)
634 }
635 }
636
637 if a.showCommandDialog {
638 d, commandCmd := a.commandDialog.Update(msg)
639 a.commandDialog = d.(dialog.CommandDialog)
640 cmds = append(cmds, commandCmd)
641 // Only block key messages send all other messages down
642 if _, ok := msg.(tea.KeyMsg); ok {
643 return a, tea.Batch(cmds...)
644 }
645 }
646
647 if a.showModelDialog {
648 d, modelCmd := a.modelDialog.Update(msg)
649 a.modelDialog = d.(dialog.ModelDialog)
650 cmds = append(cmds, modelCmd)
651 // Only block key messages send all other messages down
652 if _, ok := msg.(tea.KeyMsg); ok {
653 return a, tea.Batch(cmds...)
654 }
655 }
656
657 if a.showInitDialog {
658 d, initCmd := a.initDialog.Update(msg)
659 a.initDialog = d.(dialog.InitDialogCmp)
660 cmds = append(cmds, initCmd)
661 // Only block key messages send all other messages down
662 if _, ok := msg.(tea.KeyMsg); ok {
663 return a, tea.Batch(cmds...)
664 }
665 }
666
667 if a.showThemeDialog {
668 d, themeCmd := a.themeDialog.Update(msg)
669 a.themeDialog = d.(dialog.ThemeDialog)
670 cmds = append(cmds, themeCmd)
671 // Only block key messages send all other messages down
672 if _, ok := msg.(tea.KeyMsg); ok {
673 return a, tea.Batch(cmds...)
674 }
675 }
676
677 s, _ := a.status.Update(msg)
678 a.status = s.(core.StatusCmp)
679 a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
680 cmds = append(cmds, cmd)
681 return a, tea.Batch(cmds...)
682}
683
684// RegisterCommand adds a command to the command dialog
685func (a *appModel) RegisterCommand(cmd dialog.Command) {
686 a.commands = append(a.commands, cmd)
687}
688
689func (a *appModel) findCommand(id string) (dialog.Command, bool) {
690 for _, cmd := range a.commands {
691 if cmd.ID == id {
692 return cmd, true
693 }
694 }
695 return dialog.Command{}, false
696}
697
698func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
699 if a.app.CoderAgent.IsBusy() {
700 // For now we don't move to any page if the agent is busy
701 return util.ReportWarn("Agent is busy, please wait...")
702 }
703
704 var cmds []tea.Cmd
705 if _, ok := a.loadedPages[pageID]; !ok {
706 cmd := a.pages[pageID].Init()
707 cmds = append(cmds, cmd)
708 a.loadedPages[pageID] = true
709 }
710 a.previousPage = a.currentPage
711 a.currentPage = pageID
712 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
713 cmd := sizable.SetSize(a.width, a.height)
714 cmds = append(cmds, cmd)
715 }
716
717 return tea.Batch(cmds...)
718}
719
720func (a appModel) View() string {
721 components := []string{
722 a.pages[a.currentPage].View(),
723 }
724
725 components = append(components, a.status.View())
726
727 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
728
729 if a.showPermissions {
730 overlay := a.permissions.View()
731 row := lipgloss.Height(appView) / 2
732 row -= lipgloss.Height(overlay) / 2
733 col := lipgloss.Width(appView) / 2
734 col -= lipgloss.Width(overlay) / 2
735 appView = layout.PlaceOverlay(
736 col,
737 row,
738 overlay,
739 appView,
740 true,
741 )
742 }
743
744 if a.showFilepicker {
745 overlay := a.filepicker.View()
746 row := lipgloss.Height(appView) / 2
747 row -= lipgloss.Height(overlay) / 2
748 col := lipgloss.Width(appView) / 2
749 col -= lipgloss.Width(overlay) / 2
750 appView = layout.PlaceOverlay(
751 col,
752 row,
753 overlay,
754 appView,
755 true,
756 )
757
758 }
759
760 // Show compacting status overlay
761 if a.isCompacting {
762 t := theme.CurrentTheme()
763 style := lipgloss.NewStyle().
764 Border(lipgloss.RoundedBorder()).
765 BorderForeground(t.BorderFocused()).
766 BorderBackground(t.Background()).
767 Padding(1, 2).
768 Background(t.Background()).
769 Foreground(t.Text())
770
771 overlay := style.Render("Summarizing\n" + a.compactingMessage)
772 row := lipgloss.Height(appView) / 2
773 row -= lipgloss.Height(overlay) / 2
774 col := lipgloss.Width(appView) / 2
775 col -= lipgloss.Width(overlay) / 2
776 appView = layout.PlaceOverlay(
777 col,
778 row,
779 overlay,
780 appView,
781 true,
782 )
783 }
784
785 if a.showHelp {
786 bindings := layout.KeyMapToSlice(keys)
787 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
788 bindings = append(bindings, p.BindingKeys()...)
789 }
790 if a.showPermissions {
791 bindings = append(bindings, a.permissions.BindingKeys()...)
792 }
793 if a.currentPage == page.LogsPage {
794 bindings = append(bindings, logsKeyReturnKey)
795 }
796 if !a.app.CoderAgent.IsBusy() {
797 bindings = append(bindings, helpEsc)
798 }
799 a.help.SetBindings(bindings)
800
801 overlay := a.help.View()
802 row := lipgloss.Height(appView) / 2
803 row -= lipgloss.Height(overlay) / 2
804 col := lipgloss.Width(appView) / 2
805 col -= lipgloss.Width(overlay) / 2
806 appView = layout.PlaceOverlay(
807 col,
808 row,
809 overlay,
810 appView,
811 true,
812 )
813 }
814
815 if a.showQuit {
816 overlay := a.quit.View()
817 row := lipgloss.Height(appView) / 2
818 row -= lipgloss.Height(overlay) / 2
819 col := lipgloss.Width(appView) / 2
820 col -= lipgloss.Width(overlay) / 2
821 appView = layout.PlaceOverlay(
822 col,
823 row,
824 overlay,
825 appView,
826 true,
827 )
828 }
829
830 if a.showSessionDialog {
831 overlay := a.sessionDialog.View()
832 row := lipgloss.Height(appView) / 2
833 row -= lipgloss.Height(overlay) / 2
834 col := lipgloss.Width(appView) / 2
835 col -= lipgloss.Width(overlay) / 2
836 appView = layout.PlaceOverlay(
837 col,
838 row,
839 overlay,
840 appView,
841 true,
842 )
843 }
844
845 if a.showModelDialog {
846 overlay := a.modelDialog.View()
847 row := lipgloss.Height(appView) / 2
848 row -= lipgloss.Height(overlay) / 2
849 col := lipgloss.Width(appView) / 2
850 col -= lipgloss.Width(overlay) / 2
851 appView = layout.PlaceOverlay(
852 col,
853 row,
854 overlay,
855 appView,
856 true,
857 )
858 }
859
860 if a.showCommandDialog {
861 overlay := a.commandDialog.View()
862 row := lipgloss.Height(appView) / 2
863 row -= lipgloss.Height(overlay) / 2
864 col := lipgloss.Width(appView) / 2
865 col -= lipgloss.Width(overlay) / 2
866 appView = layout.PlaceOverlay(
867 col,
868 row,
869 overlay,
870 appView,
871 true,
872 )
873 }
874
875 if a.showInitDialog {
876 overlay := a.initDialog.View()
877 appView = layout.PlaceOverlay(
878 a.width/2-lipgloss.Width(overlay)/2,
879 a.height/2-lipgloss.Height(overlay)/2,
880 overlay,
881 appView,
882 true,
883 )
884 }
885
886 if a.showThemeDialog {
887 overlay := a.themeDialog.View()
888 row := lipgloss.Height(appView) / 2
889 row -= lipgloss.Height(overlay) / 2
890 col := lipgloss.Width(appView) / 2
891 col -= lipgloss.Width(overlay) / 2
892 appView = layout.PlaceOverlay(
893 col,
894 row,
895 overlay,
896 appView,
897 true,
898 )
899 }
900
901 if a.showArgumentsDialog {
902 overlay := a.argumentsDialog.View()
903 row := lipgloss.Height(appView) / 2
904 row -= lipgloss.Height(overlay) / 2
905 col := lipgloss.Width(appView) / 2
906 col -= lipgloss.Width(overlay) / 2
907 appView = layout.PlaceOverlay(
908 col,
909 row,
910 overlay,
911 appView,
912 true,
913 )
914 }
915
916 return appView
917}
918
919func New(app *app.App) tea.Model {
920 startPage := page.ChatPage
921 model := &appModel{
922 currentPage: startPage,
923 loadedPages: make(map[page.PageID]bool),
924 status: core.NewStatusCmp(app.LSPClients),
925 help: dialog.NewHelpCmp(),
926 quit: dialog.NewQuitCmp(),
927 sessionDialog: dialog.NewSessionDialogCmp(),
928 commandDialog: dialog.NewCommandDialogCmp(),
929 modelDialog: dialog.NewModelDialogCmp(),
930 permissions: dialog.NewPermissionDialogCmp(),
931 initDialog: dialog.NewInitDialogCmp(),
932 themeDialog: dialog.NewThemeDialogCmp(),
933 app: app,
934 commands: []dialog.Command{},
935 pages: map[page.PageID]tea.Model{
936 page.ChatPage: page.NewChatPage(app),
937 page.LogsPage: page.NewLogsPage(),
938 },
939 filepicker: dialog.NewFilepickerCmp(app),
940 }
941
942 model.RegisterCommand(dialog.Command{
943 ID: "init",
944 Title: "Initialize Project",
945 Description: "Create/Update the OpenCode.md memory file",
946 Handler: func(cmd dialog.Command) tea.Cmd {
947 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
9481. Build/lint/test commands - especially for running a single test
9492. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
950
951The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
952If there's already a opencode.md, improve it.
953If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
954 return tea.Batch(
955 util.CmdHandler(chat.SendMsg{
956 Text: prompt,
957 }),
958 )
959 },
960 })
961
962 model.RegisterCommand(dialog.Command{
963 ID: "compact",
964 Title: "Compact Session",
965 Description: "Summarize the current session and create a new one with the summary",
966 Handler: func(cmd dialog.Command) tea.Cmd {
967 return func() tea.Msg {
968 return startCompactSessionMsg{}
969 }
970 },
971 })
972 // Load custom commands
973 customCommands, err := dialog.LoadCustomCommands()
974 if err != nil {
975 logging.Warn("Failed to load custom commands", "error", err)
976 } else {
977 for _, cmd := range customCommands {
978 model.RegisterCommand(cmd)
979 }
980 }
981
982 return model
983}