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	showMultiArgumentsDialog bool
 137	multiArgumentsDialog     dialog.MultiArgumentsDialogCmp
 138
 139	showLSPSetupDialog bool
 140	lspSetupDialog     *dialog.LSPSetupWizard
 141
 142	isCompacting      bool
 143	compactingMessage string
 144}
 145
 146func (a appModel) Init() tea.Cmd {
 147	var cmds []tea.Cmd
 148	cmd := a.pages[a.currentPage].Init()
 149	a.loadedPages[a.currentPage] = true
 150	cmds = append(cmds, cmd)
 151	cmd = a.status.Init()
 152	cmds = append(cmds, cmd)
 153	cmd = a.quit.Init()
 154	cmds = append(cmds, cmd)
 155	cmd = a.help.Init()
 156	cmds = append(cmds, cmd)
 157	cmd = a.sessionDialog.Init()
 158	cmds = append(cmds, cmd)
 159	cmd = a.commandDialog.Init()
 160	cmds = append(cmds, cmd)
 161	cmd = a.modelDialog.Init()
 162	cmds = append(cmds, cmd)
 163	cmd = a.initDialog.Init()
 164	cmds = append(cmds, cmd)
 165	cmd = a.filepicker.Init()
 166	cmds = append(cmds, cmd)
 167	cmd = a.themeDialog.Init()
 168	cmds = append(cmds, cmd)
 169
 170	// Check if we should show the init dialog
 171	cmds = append(cmds, func() tea.Msg {
 172		shouldShow, err := config.ShouldShowInitDialog()
 173		if err != nil {
 174			return util.InfoMsg{
 175				Type: util.InfoTypeError,
 176				Msg:  "Failed to check init status: " + err.Error(),
 177			}
 178		}
 179		return dialog.ShowInitDialogMsg{Show: shouldShow}
 180	})
 181
 182	// Check if we should show the LSP setup dialog
 183	cmds = append(cmds, func() tea.Msg {
 184		shouldShow := a.app.CheckAndSetupLSP(context.Background())
 185		return dialog.ShowLSPSetupMsg{Show: shouldShow}
 186	})
 187
 188	return tea.Batch(cmds...)
 189}
 190
 191func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 192	var cmds []tea.Cmd
 193	var cmd tea.Cmd
 194	switch msg := msg.(type) {
 195	case tea.WindowSizeMsg:
 196		msg.Height -= 1 // Make space for the status bar
 197		a.width, a.height = msg.Width, msg.Height
 198
 199		s, _ := a.status.Update(msg)
 200		a.status = s.(core.StatusCmp)
 201		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 202		cmds = append(cmds, cmd)
 203
 204		prm, permCmd := a.permissions.Update(msg)
 205		a.permissions = prm.(dialog.PermissionDialogCmp)
 206		cmds = append(cmds, permCmd)
 207
 208		help, helpCmd := a.help.Update(msg)
 209		a.help = help.(dialog.HelpCmp)
 210		cmds = append(cmds, helpCmd)
 211
 212		session, sessionCmd := a.sessionDialog.Update(msg)
 213		a.sessionDialog = session.(dialog.SessionDialog)
 214		cmds = append(cmds, sessionCmd)
 215
 216		command, commandCmd := a.commandDialog.Update(msg)
 217		a.commandDialog = command.(dialog.CommandDialog)
 218		cmds = append(cmds, commandCmd)
 219
 220		filepicker, filepickerCmd := a.filepicker.Update(msg)
 221		a.filepicker = filepicker.(dialog.FilepickerCmp)
 222		cmds = append(cmds, filepickerCmd)
 223
 224		a.initDialog.SetSize(msg.Width, msg.Height)
 225
 226		if a.showLSPSetupDialog && a.lspSetupDialog != nil {
 227			a.lspSetupDialog.SetSize(msg.Width, msg.Height)
 228			lsp, lspCmd := a.lspSetupDialog.Update(msg)
 229			if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
 230				a.lspSetupDialog = lsp
 231			}
 232			cmds = append(cmds, lspCmd)
 233		}
 234
 235		if a.showMultiArgumentsDialog {
 236			a.multiArgumentsDialog.SetSize(msg.Width, msg.Height)
 237			args, argsCmd := a.multiArgumentsDialog.Update(msg)
 238			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
 239			cmds = append(cmds, argsCmd, a.multiArgumentsDialog.Init())
 240		}
 241
 242		return a, tea.Batch(cmds...)
 243	// Status
 244	case util.InfoMsg:
 245		s, cmd := a.status.Update(msg)
 246		a.status = s.(core.StatusCmp)
 247		cmds = append(cmds, cmd)
 248		return a, tea.Batch(cmds...)
 249	case pubsub.Event[logging.LogMessage]:
 250		if msg.Payload.Persist {
 251			switch msg.Payload.Level {
 252			case "error":
 253				s, cmd := a.status.Update(util.InfoMsg{
 254					Type: util.InfoTypeError,
 255					Msg:  msg.Payload.Message,
 256					TTL:  msg.Payload.PersistTime,
 257				})
 258				a.status = s.(core.StatusCmp)
 259				cmds = append(cmds, cmd)
 260			case "info":
 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			case "warn":
 270				s, cmd := a.status.Update(util.InfoMsg{
 271					Type: util.InfoTypeWarn,
 272					Msg:  msg.Payload.Message,
 273					TTL:  msg.Payload.PersistTime,
 274				})
 275
 276				a.status = s.(core.StatusCmp)
 277				cmds = append(cmds, cmd)
 278			default:
 279				s, cmd := a.status.Update(util.InfoMsg{
 280					Type: util.InfoTypeInfo,
 281					Msg:  msg.Payload.Message,
 282					TTL:  msg.Payload.PersistTime,
 283				})
 284				a.status = s.(core.StatusCmp)
 285				cmds = append(cmds, cmd)
 286			}
 287		}
 288	case util.ClearStatusMsg:
 289		s, _ := a.status.Update(msg)
 290		a.status = s.(core.StatusCmp)
 291
 292	// Permission
 293	case pubsub.Event[permission.PermissionRequest]:
 294		a.showPermissions = true
 295		return a, a.permissions.SetPermissions(msg.Payload)
 296	case dialog.PermissionResponseMsg:
 297		var cmd tea.Cmd
 298		switch msg.Action {
 299		case dialog.PermissionAllow:
 300			a.app.Permissions.Grant(msg.Permission)
 301		case dialog.PermissionAllowForSession:
 302			a.app.Permissions.GrantPersistant(msg.Permission)
 303		case dialog.PermissionDeny:
 304			a.app.Permissions.Deny(msg.Permission)
 305		}
 306		a.showPermissions = false
 307		return a, cmd
 308
 309	case page.PageChangeMsg:
 310		return a, a.moveToPage(msg.ID)
 311
 312	case dialog.CloseQuitMsg:
 313		a.showQuit = false
 314		return a, nil
 315
 316	case dialog.CloseSessionDialogMsg:
 317		a.showSessionDialog = false
 318		return a, nil
 319
 320	case dialog.CloseCommandDialogMsg:
 321		a.showCommandDialog = false
 322		return a, nil
 323
 324	case startCompactSessionMsg:
 325		// Start compacting the current session
 326		a.isCompacting = true
 327		a.compactingMessage = "Starting summarization..."
 328
 329		if a.selectedSession.ID == "" {
 330			a.isCompacting = false
 331			return a, util.ReportWarn("No active session to summarize")
 332		}
 333
 334		// Start the summarization process
 335		return a, func() tea.Msg {
 336			ctx := context.Background()
 337			a.app.CoderAgent.Summarize(ctx, a.selectedSession.ID)
 338			return nil
 339		}
 340
 341	case pubsub.Event[agent.AgentEvent]:
 342		payload := msg.Payload
 343		if payload.Error != nil {
 344			a.isCompacting = false
 345			return a, util.ReportError(payload.Error)
 346		}
 347
 348		a.compactingMessage = payload.Progress
 349
 350		if payload.Done && payload.Type == agent.AgentEventTypeSummarize {
 351			a.isCompacting = false
 352			return a, util.ReportInfo("Session summarization complete")
 353		} else if payload.Done && payload.Type == agent.AgentEventTypeResponse && a.selectedSession.ID != "" {
 354			model := a.app.CoderAgent.Model()
 355			contextWindow := model.ContextWindow
 356			tokens := a.selectedSession.CompletionTokens + a.selectedSession.PromptTokens
 357			if (tokens >= int64(float64(contextWindow)*0.95)) && config.Get().AutoCompact {
 358				return a, util.CmdHandler(startCompactSessionMsg{})
 359			}
 360		}
 361		// Continue listening for events
 362		return a, nil
 363
 364	case dialog.CloseThemeDialogMsg:
 365		a.showThemeDialog = false
 366		return a, nil
 367
 368	case dialog.ThemeChangedMsg:
 369		a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 370		a.showThemeDialog = false
 371		return a, tea.Batch(cmd, util.ReportInfo("Theme changed to: "+msg.ThemeName))
 372
 373	case dialog.CloseModelDialogMsg:
 374		a.showModelDialog = false
 375		return a, nil
 376
 377	case dialog.ModelSelectedMsg:
 378		a.showModelDialog = false
 379
 380		model, err := a.app.CoderAgent.Update(config.AgentCoder, msg.Model.ID)
 381		if err != nil {
 382			return a, util.ReportError(err)
 383		}
 384
 385		return a, util.ReportInfo(fmt.Sprintf("Model changed to %s", model.Name))
 386
 387	case dialog.ShowInitDialogMsg:
 388		a.showInitDialog = msg.Show
 389		return a, nil
 390
 391	case dialog.ShowLSPSetupMsg:
 392		a.showLSPSetupDialog = msg.Show
 393		if a.showLSPSetupDialog {
 394			// Initialize the LSP setup wizard with the app's LSP setup service
 395			a.lspSetupDialog = dialog.NewLSPSetupWizard(context.Background(), a.app.LSPSetup)
 396			a.lspSetupDialog.SetSize(a.width, a.height)
 397			return a, a.lspSetupDialog.Init()
 398		}
 399		return a, nil
 400
 401	case dialog.CloseLSPSetupMsg:
 402		a.showLSPSetupDialog = false
 403		if msg.Configure && len(msg.Servers) > 0 {
 404			// Use the app's ConfigureLSP method to handle the configuration
 405			err := a.app.ConfigureLSP(context.Background(), msg.Servers)
 406			if err != nil {
 407				logging.Error("Failed to update LSP configuration", "error", err)
 408				return a, util.ReportError(err)
 409			}
 410
 411			return a, util.ReportInfo("LSP configuration updated successfully")
 412		}
 413		return a, nil
 414
 415	case dialog.CloseInitDialogMsg:
 416		a.showInitDialog = false
 417		if msg.Initialize {
 418			// Run the initialization command
 419			for _, cmd := range a.commands {
 420				if cmd.ID == "init" {
 421					// Mark the project as initialized
 422					if err := config.MarkProjectInitialized(); err != nil {
 423						return a, util.ReportError(err)
 424					}
 425					return a, cmd.Handler(cmd)
 426				}
 427			}
 428		} else {
 429			// Mark the project as initialized without running the command
 430			if err := config.MarkProjectInitialized(); err != nil {
 431				return a, util.ReportError(err)
 432			}
 433		}
 434		return a, nil
 435
 436	case chat.SessionSelectedMsg:
 437		a.selectedSession = msg
 438		a.sessionDialog.SetSelectedSession(msg.ID)
 439
 440	case pubsub.Event[session.Session]:
 441		if msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == a.selectedSession.ID {
 442			a.selectedSession = msg.Payload
 443		}
 444	case dialog.SessionSelectedMsg:
 445		a.showSessionDialog = false
 446		if a.currentPage == page.ChatPage {
 447			return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
 448		}
 449		return a, nil
 450
 451	case dialog.CommandSelectedMsg:
 452		a.showCommandDialog = false
 453		// Execute the command handler if available
 454		if msg.Command.Handler != nil {
 455			return a, msg.Command.Handler(msg.Command)
 456		}
 457		return a, util.ReportInfo("Command selected: " + msg.Command.Title)
 458
 459	case dialog.ShowMultiArgumentsDialogMsg:
 460		// Show multi-arguments dialog
 461		a.multiArgumentsDialog = dialog.NewMultiArgumentsDialogCmp(msg.CommandID, msg.Content, msg.ArgNames)
 462		a.showMultiArgumentsDialog = true
 463		return a, a.multiArgumentsDialog.Init()
 464
 465	case dialog.CloseMultiArgumentsDialogMsg:
 466		// Close multi-arguments dialog
 467		a.showMultiArgumentsDialog = false
 468
 469		// If submitted, replace all named arguments and run the command
 470		if msg.Submit {
 471			content := msg.Content
 472
 473			// Replace each named argument with its value
 474			for name, value := range msg.Args {
 475				placeholder := "$" + name
 476				content = strings.ReplaceAll(content, placeholder, value)
 477			}
 478
 479			// Execute the command with arguments
 480			return a, util.CmdHandler(dialog.CommandRunCustomMsg{
 481				Content: content,
 482				Args:    msg.Args,
 483			})
 484		}
 485		return a, nil
 486
 487	case tea.KeyMsg:
 488		// If LSP setup dialog is open, let it handle the key press
 489		if a.showLSPSetupDialog && a.lspSetupDialog != nil {
 490			lsp, cmd := a.lspSetupDialog.Update(msg)
 491			if lsp, ok := lsp.(*dialog.LSPSetupWizard); ok {
 492				a.lspSetupDialog = lsp
 493			}
 494			return a, cmd
 495		}
 496
 497		// If multi-arguments dialog is open, let it handle the key press first
 498		if a.showMultiArgumentsDialog {
 499			args, cmd := a.multiArgumentsDialog.Update(msg)
 500			a.multiArgumentsDialog = args.(dialog.MultiArgumentsDialogCmp)
 501			return a, cmd
 502		}
 503
 504		switch {
 505
 506		case key.Matches(msg, keys.Quit):
 507			a.showQuit = !a.showQuit
 508			if a.showHelp {
 509				a.showHelp = false
 510			}
 511			if a.showSessionDialog {
 512				a.showSessionDialog = false
 513			}
 514			if a.showCommandDialog {
 515				a.showCommandDialog = false
 516			}
 517			if a.showFilepicker {
 518				a.showFilepicker = false
 519				a.filepicker.ToggleFilepicker(a.showFilepicker)
 520			}
 521			if a.showModelDialog {
 522				a.showModelDialog = false
 523			}
 524			if a.showMultiArgumentsDialog {
 525				a.showMultiArgumentsDialog = false
 526			}
 527			if a.showLSPSetupDialog {
 528				a.showLSPSetupDialog = false
 529			}
 530			return a, nil
 531		case key.Matches(msg, keys.SwitchSession):
 532			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
 533				// Load sessions and show the dialog
 534				sessions, err := a.app.Sessions.List(context.Background())
 535				if err != nil {
 536					return a, util.ReportError(err)
 537				}
 538				if len(sessions) == 0 {
 539					return a, util.ReportWarn("No sessions available")
 540				}
 541				a.sessionDialog.SetSessions(sessions)
 542				a.showSessionDialog = true
 543				return a, nil
 544			}
 545			return a, nil
 546		case key.Matches(msg, keys.Commands):
 547			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker {
 548				// Show commands dialog
 549				if len(a.commands) == 0 {
 550					return a, util.ReportWarn("No commands available")
 551				}
 552				a.commandDialog.SetCommands(a.commands)
 553				a.showCommandDialog = true
 554				return a, nil
 555			}
 556			return a, nil
 557		case key.Matches(msg, keys.Models):
 558			if a.showModelDialog {
 559				a.showModelDialog = false
 560				return a, nil
 561			}
 562			if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
 563				a.showModelDialog = true
 564				return a, nil
 565			}
 566			return a, nil
 567		case key.Matches(msg, keys.SwitchTheme):
 568			if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog {
 569				// Show theme switcher dialog
 570				a.showThemeDialog = true
 571				// Theme list is dynamically loaded by the dialog component
 572				return a, a.themeDialog.Init()
 573			}
 574			return a, nil
 575		case key.Matches(msg, returnKey) || key.Matches(msg):
 576			if msg.String() == quitKey {
 577				if a.currentPage == page.LogsPage {
 578					return a, a.moveToPage(page.ChatPage)
 579				}
 580			} else if !a.filepicker.IsCWDFocused() {
 581				if a.showQuit {
 582					a.showQuit = !a.showQuit
 583					return a, nil
 584				}
 585				if a.showHelp {
 586					a.showHelp = !a.showHelp
 587					return a, nil
 588				}
 589				if a.showInitDialog {
 590					a.showInitDialog = false
 591					// Mark the project as initialized without running the command
 592					if err := config.MarkProjectInitialized(); err != nil {
 593						return a, util.ReportError(err)
 594					}
 595					return a, nil
 596				}
 597				if a.showFilepicker {
 598					a.showFilepicker = false
 599					a.filepicker.ToggleFilepicker(a.showFilepicker)
 600					return a, nil
 601				}
 602				if a.currentPage == page.LogsPage {
 603					return a, a.moveToPage(page.ChatPage)
 604				}
 605			}
 606		case key.Matches(msg, keys.Logs):
 607			return a, a.moveToPage(page.LogsPage)
 608		case key.Matches(msg, keys.Help):
 609			if a.showQuit {
 610				return a, nil
 611			}
 612			a.showHelp = !a.showHelp
 613			return a, nil
 614		case key.Matches(msg, helpEsc):
 615			if a.app.CoderAgent.IsBusy() {
 616				if a.showQuit {
 617					return a, nil
 618				}
 619				a.showHelp = !a.showHelp
 620				return a, nil
 621			}
 622		case key.Matches(msg, keys.Filepicker):
 623			a.showFilepicker = !a.showFilepicker
 624			a.filepicker.ToggleFilepicker(a.showFilepicker)
 625			return a, nil
 626		}
 627	default:
 628		f, filepickerCmd := a.filepicker.Update(msg)
 629		a.filepicker = f.(dialog.FilepickerCmp)
 630		cmds = append(cmds, filepickerCmd)
 631
 632	}
 633
 634	if a.showFilepicker {
 635		f, filepickerCmd := a.filepicker.Update(msg)
 636		a.filepicker = f.(dialog.FilepickerCmp)
 637		cmds = append(cmds, filepickerCmd)
 638		// Only block key messages send all other messages down
 639		if _, ok := msg.(tea.KeyMsg); ok {
 640			return a, tea.Batch(cmds...)
 641		}
 642	}
 643
 644	if a.showQuit {
 645		q, quitCmd := a.quit.Update(msg)
 646		a.quit = q.(dialog.QuitDialog)
 647		cmds = append(cmds, quitCmd)
 648		// Only block key messages send all other messages down
 649		if _, ok := msg.(tea.KeyMsg); ok {
 650			return a, tea.Batch(cmds...)
 651		}
 652	}
 653	if a.showPermissions {
 654		d, permissionsCmd := a.permissions.Update(msg)
 655		a.permissions = d.(dialog.PermissionDialogCmp)
 656		cmds = append(cmds, permissionsCmd)
 657		// Only block key messages send all other messages down
 658		if _, ok := msg.(tea.KeyMsg); ok {
 659			return a, tea.Batch(cmds...)
 660		}
 661	}
 662
 663	if a.showSessionDialog {
 664		d, sessionCmd := a.sessionDialog.Update(msg)
 665		a.sessionDialog = d.(dialog.SessionDialog)
 666		cmds = append(cmds, sessionCmd)
 667		// Only block key messages send all other messages down
 668		if _, ok := msg.(tea.KeyMsg); ok {
 669			return a, tea.Batch(cmds...)
 670		}
 671	}
 672
 673	if a.showCommandDialog {
 674		d, commandCmd := a.commandDialog.Update(msg)
 675		a.commandDialog = d.(dialog.CommandDialog)
 676		cmds = append(cmds, commandCmd)
 677		// Only block key messages send all other messages down
 678		if _, ok := msg.(tea.KeyMsg); ok {
 679			return a, tea.Batch(cmds...)
 680		}
 681	}
 682
 683	if a.showModelDialog {
 684		d, modelCmd := a.modelDialog.Update(msg)
 685		a.modelDialog = d.(dialog.ModelDialog)
 686		cmds = append(cmds, modelCmd)
 687		// Only block key messages send all other messages down
 688		if _, ok := msg.(tea.KeyMsg); ok {
 689			return a, tea.Batch(cmds...)
 690		}
 691	}
 692
 693	if a.showInitDialog {
 694		d, initCmd := a.initDialog.Update(msg)
 695		a.initDialog = d.(dialog.InitDialogCmp)
 696		cmds = append(cmds, initCmd)
 697		// Only block key messages send all other messages down
 698		if _, ok := msg.(tea.KeyMsg); ok {
 699			return a, tea.Batch(cmds...)
 700		}
 701	}
 702
 703	if a.showThemeDialog {
 704		d, themeCmd := a.themeDialog.Update(msg)
 705		a.themeDialog = d.(dialog.ThemeDialog)
 706		cmds = append(cmds, themeCmd)
 707		// Only block key messages send all other messages down
 708		if _, ok := msg.(tea.KeyMsg); ok {
 709			return a, tea.Batch(cmds...)
 710		}
 711	}
 712
 713	if a.showLSPSetupDialog {
 714		d, lspCmd := a.lspSetupDialog.Update(msg)
 715		a.lspSetupDialog = d.(*dialog.LSPSetupWizard)
 716		cmds = append(cmds, lspCmd)
 717	}
 718
 719	s, _ := a.status.Update(msg)
 720	a.status = s.(core.StatusCmp)
 721	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 722	cmds = append(cmds, cmd)
 723	return a, tea.Batch(cmds...)
 724}
 725
 726// RegisterCommand adds a command to the command dialog
 727func (a *appModel) RegisterCommand(cmd dialog.Command) {
 728	a.commands = append(a.commands, cmd)
 729}
 730
 731func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
 732	if a.app.CoderAgent.IsBusy() {
 733		// For now we don't move to any page if the agent is busy
 734		return util.ReportWarn("Agent is busy, please wait...")
 735	}
 736
 737	var cmds []tea.Cmd
 738	if _, ok := a.loadedPages[pageID]; !ok {
 739		cmd := a.pages[pageID].Init()
 740		cmds = append(cmds, cmd)
 741		a.loadedPages[pageID] = true
 742	}
 743	a.previousPage = a.currentPage
 744	a.currentPage = pageID
 745	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
 746		cmd := sizable.SetSize(a.width, a.height)
 747		cmds = append(cmds, cmd)
 748	}
 749
 750	return tea.Batch(cmds...)
 751}
 752
 753func (a appModel) View() string {
 754	components := []string{
 755		a.pages[a.currentPage].View(),
 756	}
 757
 758	components = append(components, a.status.View())
 759
 760	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
 761
 762	if a.showPermissions {
 763		overlay := a.permissions.View()
 764		row := lipgloss.Height(appView) / 2
 765		row -= lipgloss.Height(overlay) / 2
 766		col := lipgloss.Width(appView) / 2
 767		col -= lipgloss.Width(overlay) / 2
 768		appView = layout.PlaceOverlay(
 769			col,
 770			row,
 771			overlay,
 772			appView,
 773			true,
 774		)
 775	}
 776
 777	if a.showFilepicker {
 778		overlay := a.filepicker.View()
 779		row := lipgloss.Height(appView) / 2
 780		row -= lipgloss.Height(overlay) / 2
 781		col := lipgloss.Width(appView) / 2
 782		col -= lipgloss.Width(overlay) / 2
 783		appView = layout.PlaceOverlay(
 784			col,
 785			row,
 786			overlay,
 787			appView,
 788			true,
 789		)
 790
 791	}
 792
 793	// Show compacting status overlay
 794	if a.isCompacting {
 795		t := theme.CurrentTheme()
 796		style := lipgloss.NewStyle().
 797			Border(lipgloss.RoundedBorder()).
 798			BorderForeground(t.BorderFocused()).
 799			BorderBackground(t.Background()).
 800			Padding(1, 2).
 801			Background(t.Background()).
 802			Foreground(t.Text())
 803
 804		overlay := style.Render("Summarizing\n" + a.compactingMessage)
 805		row := lipgloss.Height(appView) / 2
 806		row -= lipgloss.Height(overlay) / 2
 807		col := lipgloss.Width(appView) / 2
 808		col -= lipgloss.Width(overlay) / 2
 809		appView = layout.PlaceOverlay(
 810			col,
 811			row,
 812			overlay,
 813			appView,
 814			true,
 815		)
 816	}
 817
 818	if a.showHelp {
 819		bindings := layout.KeyMapToSlice(keys)
 820		if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
 821			bindings = append(bindings, p.BindingKeys()...)
 822		}
 823		if a.showPermissions {
 824			bindings = append(bindings, a.permissions.BindingKeys()...)
 825		}
 826		if a.currentPage == page.LogsPage {
 827			bindings = append(bindings, logsKeyReturnKey)
 828		}
 829		if !a.app.CoderAgent.IsBusy() {
 830			bindings = append(bindings, helpEsc)
 831		}
 832		a.help.SetBindings(bindings)
 833
 834		overlay := a.help.View()
 835		row := lipgloss.Height(appView) / 2
 836		row -= lipgloss.Height(overlay) / 2
 837		col := lipgloss.Width(appView) / 2
 838		col -= lipgloss.Width(overlay) / 2
 839		appView = layout.PlaceOverlay(
 840			col,
 841			row,
 842			overlay,
 843			appView,
 844			true,
 845		)
 846	}
 847
 848	if a.showQuit {
 849		overlay := a.quit.View()
 850		row := lipgloss.Height(appView) / 2
 851		row -= lipgloss.Height(overlay) / 2
 852		col := lipgloss.Width(appView) / 2
 853		col -= lipgloss.Width(overlay) / 2
 854		appView = layout.PlaceOverlay(
 855			col,
 856			row,
 857			overlay,
 858			appView,
 859			true,
 860		)
 861	}
 862
 863	if a.showSessionDialog {
 864		overlay := a.sessionDialog.View()
 865		row := lipgloss.Height(appView) / 2
 866		row -= lipgloss.Height(overlay) / 2
 867		col := lipgloss.Width(appView) / 2
 868		col -= lipgloss.Width(overlay) / 2
 869		appView = layout.PlaceOverlay(
 870			col,
 871			row,
 872			overlay,
 873			appView,
 874			true,
 875		)
 876	}
 877
 878	if a.showModelDialog {
 879		overlay := a.modelDialog.View()
 880		row := lipgloss.Height(appView) / 2
 881		row -= lipgloss.Height(overlay) / 2
 882		col := lipgloss.Width(appView) / 2
 883		col -= lipgloss.Width(overlay) / 2
 884		appView = layout.PlaceOverlay(
 885			col,
 886			row,
 887			overlay,
 888			appView,
 889			true,
 890		)
 891	}
 892
 893	if a.showCommandDialog {
 894		overlay := a.commandDialog.View()
 895		row := lipgloss.Height(appView) / 2
 896		row -= lipgloss.Height(overlay) / 2
 897		col := lipgloss.Width(appView) / 2
 898		col -= lipgloss.Width(overlay) / 2
 899		appView = layout.PlaceOverlay(
 900			col,
 901			row,
 902			overlay,
 903			appView,
 904			true,
 905		)
 906	}
 907
 908	if a.showInitDialog {
 909		overlay := a.initDialog.View()
 910		appView = layout.PlaceOverlay(
 911			a.width/2-lipgloss.Width(overlay)/2,
 912			a.height/2-lipgloss.Height(overlay)/2,
 913			overlay,
 914			appView,
 915			true,
 916		)
 917	}
 918
 919	if a.showLSPSetupDialog && a.lspSetupDialog != nil {
 920		overlay := a.lspSetupDialog.View()
 921		appView = layout.PlaceOverlay(
 922			a.width/2-lipgloss.Width(overlay)/2,
 923			a.height/2-lipgloss.Height(overlay)/2,
 924			overlay,
 925			appView,
 926			true,
 927		)
 928	}
 929
 930	if a.showThemeDialog {
 931		overlay := a.themeDialog.View()
 932		row := lipgloss.Height(appView) / 2
 933		row -= lipgloss.Height(overlay) / 2
 934		col := lipgloss.Width(appView) / 2
 935		col -= lipgloss.Width(overlay) / 2
 936		appView = layout.PlaceOverlay(
 937			col,
 938			row,
 939			overlay,
 940			appView,
 941			true,
 942		)
 943	}
 944
 945	if a.showMultiArgumentsDialog {
 946		overlay := a.multiArgumentsDialog.View()
 947		row := lipgloss.Height(appView) / 2
 948		row -= lipgloss.Height(overlay) / 2
 949		col := lipgloss.Width(appView) / 2
 950		col -= lipgloss.Width(overlay) / 2
 951		appView = layout.PlaceOverlay(
 952			col,
 953			row,
 954			overlay,
 955			appView,
 956			true,
 957		)
 958	}
 959
 960	return appView
 961}
 962
 963func New(app *app.App) tea.Model {
 964	startPage := page.ChatPage
 965	model := &appModel{
 966		currentPage:   startPage,
 967		loadedPages:   make(map[page.PageID]bool),
 968		status:        core.NewStatusCmp(app.LSPClients),
 969		help:          dialog.NewHelpCmp(),
 970		quit:          dialog.NewQuitCmp(),
 971		sessionDialog: dialog.NewSessionDialogCmp(),
 972		commandDialog: dialog.NewCommandDialogCmp(),
 973		modelDialog:   dialog.NewModelDialogCmp(),
 974		permissions:   dialog.NewPermissionDialogCmp(),
 975		initDialog:    dialog.NewInitDialogCmp(),
 976		themeDialog:   dialog.NewThemeDialogCmp(),
 977		app:           app,
 978		commands:      []dialog.Command{},
 979		pages: map[page.PageID]tea.Model{
 980			page.ChatPage: page.NewChatPage(app),
 981			page.LogsPage: page.NewLogsPage(),
 982		},
 983		filepicker: dialog.NewFilepickerCmp(app),
 984	}
 985
 986	model.RegisterCommand(dialog.Command{
 987		ID:          "init",
 988		Title:       "Initialize Project",
 989		Description: "Create/Update the OpenCode.md memory file",
 990		Handler: func(cmd dialog.Command) tea.Cmd {
 991			prompt := `Please analyze this codebase and create a OpenCode.md file containing:
 9921. Build/lint/test commands - especially for running a single test
 9932. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
 994
 995The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
 996If there's already a opencode.md, improve it.
 997If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
 998			return tea.Batch(
 999				util.CmdHandler(chat.SendMsg{
1000					Text: prompt,
1001				}),
1002			)
1003		},
1004	})
1005
1006	model.RegisterCommand(dialog.Command{
1007		ID:          "compact",
1008		Title:       "Compact Session",
1009		Description: "Summarize the current session and create a new one with the summary",
1010		Handler: func(cmd dialog.Command) tea.Cmd {
1011			return func() tea.Msg {
1012				return startCompactSessionMsg{}
1013			}
1014		},
1015	})
1016
1017	model.RegisterCommand(dialog.Command{
1018		ID:          "setup-lsp",
1019		Title:       "Setup LSP",
1020		Description: "Configure Language Server Protocol integration",
1021		Handler: func(cmd dialog.Command) tea.Cmd {
1022			return func() tea.Msg {
1023				return dialog.ShowLSPSetupMsg{Show: true}
1024			}
1025		},
1026	})
1027	// Load custom commands
1028	customCommands, err := dialog.LoadCustomCommands()
1029	if err != nil {
1030		logging.Warn("Failed to load custom commands", "error", err)
1031	} else {
1032		for _, cmd := range customCommands {
1033			model.RegisterCommand(cmd)
1034		}
1035	}
1036
1037	return model
1038}