tui.go

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