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