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