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