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