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