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