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		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}