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 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 if a.showFilepicker {
585 f, filepickerCmd := a.filepicker.Update(msg)
586 a.filepicker = f.(dialog.FilepickerCmp)
587 cmds = append(cmds, filepickerCmd)
588 // Only block key messages send all other messages down
589 if _, ok := msg.(tea.KeyPressMsg); ok {
590 return a, tea.Batch(cmds...)
591 }
592 }
593
594 if a.showQuit {
595 q, quitCmd := a.quit.Update(msg)
596 a.quit = q.(dialog.QuitDialog)
597 cmds = append(cmds, quitCmd)
598 // Only block key messages send all other messages down
599 if _, ok := msg.(tea.KeyPressMsg); ok {
600 return a, tea.Batch(cmds...)
601 }
602 }
603 if a.showPermissions {
604 d, permissionsCmd := a.permissions.Update(msg)
605 a.permissions = d.(dialog.PermissionDialogCmp)
606 cmds = append(cmds, permissionsCmd)
607 // Only block key messages send all other messages down
608 if _, ok := msg.(tea.KeyPressMsg); ok {
609 return a, tea.Batch(cmds...)
610 }
611 }
612
613 if a.showSessionDialog {
614 d, sessionCmd := a.sessionDialog.Update(msg)
615 a.sessionDialog = d.(dialog.SessionDialog)
616 cmds = append(cmds, sessionCmd)
617 // Only block key messages send all other messages down
618 if _, ok := msg.(tea.KeyPressMsg); ok {
619 return a, tea.Batch(cmds...)
620 }
621 }
622
623 if a.showCommandDialog {
624 d, commandCmd := a.commandDialog.Update(msg)
625 a.commandDialog = d.(dialog.CommandDialog)
626 cmds = append(cmds, commandCmd)
627 // Only block key messages send all other messages down
628 if _, ok := msg.(tea.KeyPressMsg); ok {
629 return a, tea.Batch(cmds...)
630 }
631 }
632
633 if a.showModelDialog {
634 d, modelCmd := a.modelDialog.Update(msg)
635 a.modelDialog = d.(dialog.ModelDialog)
636 cmds = append(cmds, modelCmd)
637 // Only block key messages send all other messages down
638 if _, ok := msg.(tea.KeyPressMsg); ok {
639 return a, tea.Batch(cmds...)
640 }
641 }
642
643 if a.showInitDialog {
644 d, initCmd := a.initDialog.Update(msg)
645 a.initDialog = d.(dialog.InitDialogCmp)
646 cmds = append(cmds, initCmd)
647 // Only block key messages send all other messages down
648 if _, ok := msg.(tea.KeyPressMsg); ok {
649 return a, tea.Batch(cmds...)
650 }
651 }
652
653 if a.showThemeDialog {
654 d, themeCmd := a.themeDialog.Update(msg)
655 a.themeDialog = d.(dialog.ThemeDialog)
656 cmds = append(cmds, themeCmd)
657 // Only block key messages send all other messages down
658 if _, ok := msg.(tea.KeyPressMsg); ok {
659 return a, tea.Batch(cmds...)
660 }
661 }
662
663 s, _ := a.status.Update(msg)
664 a.status = s.(core.StatusCmp)
665 updated, cmd := a.pages[a.currentPage].Update(msg)
666 a.pages[a.currentPage] = updated.(util.Model)
667 cmds = append(cmds, cmd)
668 return a, tea.Batch(cmds...)
669}
670
671// RegisterCommand adds a command to the command dialog
672func (a *appModel) RegisterCommand(cmd dialog.Command) {
673 a.commands = append(a.commands, cmd)
674}
675
676func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
677 if a.app.CoderAgent.IsBusy() {
678 // For now we don't move to any page if the agent is busy
679 return util.ReportWarn("Agent is busy, please wait...")
680 }
681
682 var cmds []tea.Cmd
683 if _, ok := a.loadedPages[pageID]; !ok {
684 cmd := a.pages[pageID].Init()
685 cmds = append(cmds, cmd)
686 a.loadedPages[pageID] = true
687 }
688 a.previousPage = a.currentPage
689 a.currentPage = pageID
690 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
691 cmd := sizable.SetSize(a.width, a.height)
692 cmds = append(cmds, cmd)
693 }
694
695 return tea.Batch(cmds...)
696}
697
698func (a appModel) View() string {
699 components := []string{
700 a.pages[a.currentPage].View(),
701 }
702 components = append(components, a.status.View())
703
704 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
705
706 if a.showPermissions {
707 overlay := a.permissions.View()
708 row := lipgloss.Height(appView) / 2
709 row -= lipgloss.Height(overlay) / 2
710 col := lipgloss.Width(appView) / 2
711 col -= lipgloss.Width(overlay) / 2
712 appView = layout.PlaceOverlay(
713 col,
714 row,
715 overlay,
716 appView,
717 true,
718 )
719 }
720
721 if a.showFilepicker {
722 overlay := a.filepicker.View()
723 row := lipgloss.Height(appView) / 2
724 row -= lipgloss.Height(overlay) / 2
725 col := lipgloss.Width(appView) / 2
726 col -= lipgloss.Width(overlay) / 2
727 appView = layout.PlaceOverlay(
728 col,
729 row,
730 overlay,
731 appView,
732 true,
733 )
734 }
735
736 // Show compacting status overlay
737 if a.isCompacting {
738 t := theme.CurrentTheme()
739 style := lipgloss.NewStyle().
740 Border(lipgloss.RoundedBorder()).
741 BorderForeground(t.BorderFocused()).
742 BorderBackground(t.Background()).
743 Padding(1, 2).
744 Background(t.Background()).
745 Foreground(t.Text())
746
747 overlay := style.Render("Summarizing\n" + a.compactingMessage)
748 row := lipgloss.Height(appView) / 2
749 row -= lipgloss.Height(overlay) / 2
750 col := lipgloss.Width(appView) / 2
751 col -= lipgloss.Width(overlay) / 2
752 appView = layout.PlaceOverlay(
753 col,
754 row,
755 overlay,
756 appView,
757 true,
758 )
759 }
760
761 if a.showHelp {
762 bindings := layout.KeyMapToSlice(keys)
763 if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
764 bindings = append(bindings, p.BindingKeys()...)
765 }
766 if a.showPermissions {
767 bindings = append(bindings, a.permissions.BindingKeys()...)
768 }
769 if a.currentPage == page.LogsPage {
770 bindings = append(bindings, logsKeyReturnKey)
771 }
772 if !a.app.CoderAgent.IsBusy() {
773 bindings = append(bindings, helpEsc)
774 }
775 a.help.SetBindings(bindings)
776
777 overlay := a.help.View()
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.showQuit {
792 overlay := a.quit.View()
793 row := lipgloss.Height(appView) / 2
794 row -= lipgloss.Height(overlay) / 2
795 col := lipgloss.Width(appView) / 2
796 col -= lipgloss.Width(overlay) / 2
797 appView = layout.PlaceOverlay(
798 col,
799 row,
800 overlay,
801 appView,
802 true,
803 )
804 }
805
806 if a.showSessionDialog {
807 overlay := a.sessionDialog.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.showModelDialog {
822 overlay := a.modelDialog.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.showCommandDialog {
837 overlay := a.commandDialog.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.showInitDialog {
852 overlay := a.initDialog.View()
853 appView = layout.PlaceOverlay(
854 a.width/2-lipgloss.Width(overlay)/2,
855 a.height/2-lipgloss.Height(overlay)/2,
856 overlay,
857 appView,
858 true,
859 )
860 }
861
862 if a.showThemeDialog {
863 overlay := a.themeDialog.View()
864 row := lipgloss.Height(appView) / 2
865 row -= lipgloss.Height(overlay) / 2
866 col := lipgloss.Width(appView) / 2
867 col -= lipgloss.Width(overlay) / 2
868 appView = layout.PlaceOverlay(
869 col,
870 row,
871 overlay,
872 appView,
873 true,
874 )
875 }
876
877 if a.showMultiArgumentsDialog {
878 overlay := a.multiArgumentsDialog.View()
879 row := lipgloss.Height(appView) / 2
880 row -= lipgloss.Height(overlay) / 2
881 col := lipgloss.Width(appView) / 2
882 col -= lipgloss.Width(overlay) / 2
883 appView = layout.PlaceOverlay(
884 col,
885 row,
886 overlay,
887 appView,
888 true,
889 )
890 }
891
892 return appView
893}
894
895func New(app *app.App) tea.Model {
896 startPage := page.ChatPage
897 model := &appModel{
898 currentPage: startPage,
899 loadedPages: make(map[page.PageID]bool),
900 status: core.NewStatusCmp(app.LSPClients),
901 help: dialog.NewHelpCmp(),
902 quit: dialog.NewQuitCmp(),
903 sessionDialog: dialog.NewSessionDialogCmp(),
904 commandDialog: dialog.NewCommandDialogCmp(),
905 modelDialog: dialog.NewModelDialogCmp(),
906 permissions: dialog.NewPermissionDialogCmp(),
907 initDialog: dialog.NewInitDialogCmp(),
908 themeDialog: dialog.NewThemeDialogCmp(),
909 app: app,
910 commands: []dialog.Command{},
911 pages: map[page.PageID]util.Model{
912 page.ChatPage: page.NewChatPage(app),
913 page.LogsPage: page.NewLogsPage(),
914 },
915 filepicker: dialog.NewFilepickerCmp(app),
916 }
917
918 model.RegisterCommand(dialog.Command{
919 ID: "init",
920 Title: "Initialize Project",
921 Description: "Create/Update the OpenCode.md memory file",
922 Handler: func(cmd dialog.Command) tea.Cmd {
923 prompt := `Please analyze this codebase and create a OpenCode.md file containing:
9241. Build/lint/test commands - especially for running a single test
9252. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
926
927The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
928If there's already a opencode.md, improve it.
929If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
930 return tea.Batch(
931 util.CmdHandler(chat.SendMsg{
932 Text: prompt,
933 }),
934 )
935 },
936 })
937
938 model.RegisterCommand(dialog.Command{
939 ID: "compact",
940 Title: "Compact Session",
941 Description: "Summarize the current session and create a new one with the summary",
942 Handler: func(cmd dialog.Command) tea.Cmd {
943 return func() tea.Msg {
944 return startCompactSessionMsg{}
945 }
946 },
947 })
948 // Load custom commands
949 customCommands, err := dialog.LoadCustomCommands()
950 if err != nil {
951 logging.Warn("Failed to load custom commands", "error", err)
952 } else {
953 for _, cmd := range customCommands {
954 model.RegisterCommand(cmd)
955 }
956 }
957
958 return model
959}