tui.go

  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}