tui.go

  1package tui
  2
  3import (
  4	"github.com/charmbracelet/bubbles/v2/key"
  5	tea "github.com/charmbracelet/bubbletea/v2"
  6	"github.com/charmbracelet/lipgloss/v2"
  7	"github.com/opencode-ai/opencode/internal/app"
  8	"github.com/opencode-ai/opencode/internal/logging"
  9	"github.com/opencode-ai/opencode/internal/pubsub"
 10	"github.com/opencode-ai/opencode/internal/tui/components/completions"
 11	"github.com/opencode-ai/opencode/internal/tui/components/core"
 12	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 13	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
 14	"github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
 15	"github.com/opencode-ai/opencode/internal/tui/layout"
 16	"github.com/opencode-ai/opencode/internal/tui/page"
 17	"github.com/opencode-ai/opencode/internal/tui/page/chat"
 18	"github.com/opencode-ai/opencode/internal/tui/styles"
 19	"github.com/opencode-ai/opencode/internal/tui/util"
 20)
 21
 22// appModel represents the main application model that manages pages, dialogs, and UI state.
 23type appModel struct {
 24	width, height int
 25	keyMap        KeyMap
 26
 27	currentPage  page.PageID
 28	previousPage page.PageID
 29	pages        map[page.PageID]util.Model
 30	loadedPages  map[page.PageID]bool
 31
 32	status core.StatusCmp
 33
 34	app *app.App
 35
 36	dialog      dialogs.DialogCmp
 37	completions completions.Completions
 38}
 39
 40// Init initializes the application model and returns initial commands.
 41func (a appModel) Init() tea.Cmd {
 42	var cmds []tea.Cmd
 43	cmd := a.pages[a.currentPage].Init()
 44	cmds = append(cmds, cmd)
 45	a.loadedPages[a.currentPage] = true
 46
 47	cmd = a.status.Init()
 48	cmds = append(cmds, cmd)
 49	return tea.Batch(cmds...)
 50}
 51
 52// Update handles incoming messages and updates the application state.
 53func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 54	var cmds []tea.Cmd
 55	var cmd tea.Cmd
 56
 57	switch msg := msg.(type) {
 58	case tea.WindowSizeMsg:
 59		return a, a.handleWindowResize(msg)
 60
 61	// Completions messages
 62	case completions.OpenCompletionsMsg, completions.FilterCompletionsMsg, completions.CloseCompletionsMsg:
 63		u, completionCmd := a.completions.Update(msg)
 64		a.completions = u.(completions.Completions)
 65		return a, completionCmd
 66
 67	// Dialog messages
 68	case dialogs.OpenDialogMsg, dialogs.CloseDialogMsg:
 69		u, dialogCmd := a.dialog.Update(msg)
 70		a.dialog = u.(dialogs.DialogCmp)
 71		return a, dialogCmd
 72	case commands.ShowArgumentsDialogMsg:
 73		return a, util.CmdHandler(
 74			dialogs.OpenDialogMsg{
 75				Model: commands.NewCommandArgumentsDialog(
 76					msg.CommandID,
 77					msg.Content,
 78					msg.ArgNames,
 79				),
 80			},
 81		)
 82	// Page change messages
 83	case page.PageChangeMsg:
 84		return a, a.moveToPage(msg.ID)
 85
 86	// Status Messages
 87	case util.InfoMsg, util.ClearStatusMsg:
 88		s, statusCmd := a.status.Update(msg)
 89		a.status = s.(core.StatusCmp)
 90		cmds = append(cmds, statusCmd)
 91		return a, tea.Batch(cmds...)
 92
 93	// Logs
 94	case pubsub.Event[logging.LogMessage]:
 95		// Send to the status component
 96		s, statusCmd := a.status.Update(msg)
 97		a.status = s.(core.StatusCmp)
 98		cmds = append(cmds, statusCmd)
 99
100		// If the current page is logs, update the logs view
101		if a.currentPage == page.LogsPage {
102			updated, pageCmd := a.pages[a.currentPage].Update(msg)
103			a.pages[a.currentPage] = updated.(util.Model)
104			cmds = append(cmds, pageCmd)
105		}
106		return a, tea.Batch(cmds...)
107	case tea.KeyPressMsg:
108		return a, a.handleKeyPressMsg(msg)
109	}
110	s, _ := a.status.Update(msg)
111	a.status = s.(core.StatusCmp)
112	updated, cmd := a.pages[a.currentPage].Update(msg)
113	a.pages[a.currentPage] = updated.(util.Model)
114	cmds = append(cmds, cmd)
115	return a, tea.Batch(cmds...)
116}
117
118// handleWindowResize processes window resize events and updates all components.
119func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
120	var cmds []tea.Cmd
121	msg.Height -= 1 // Make space for the status bar
122	a.width, a.height = msg.Width, msg.Height
123
124	// Update status bar
125	s, cmd := a.status.Update(msg)
126	a.status = s.(core.StatusCmp)
127	cmds = append(cmds, cmd)
128
129	// Update the current page
130	updated, cmd := a.pages[a.currentPage].Update(msg)
131	a.pages[a.currentPage] = updated.(util.Model)
132	cmds = append(cmds, cmd)
133
134	// Update the dialogs
135	dialog, cmd := a.dialog.Update(msg)
136	a.dialog = dialog.(dialogs.DialogCmp)
137	cmds = append(cmds, cmd)
138
139	return tea.Batch(cmds...)
140}
141
142// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
143func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
144	switch {
145	// completions
146	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
147		u, cmd := a.completions.Update(msg)
148		a.completions = u.(completions.Completions)
149		return cmd
150
151	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
152		u, cmd := a.completions.Update(msg)
153		a.completions = u.(completions.Completions)
154		return cmd
155	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
156		u, cmd := a.completions.Update(msg)
157		a.completions = u.(completions.Completions)
158		return cmd
159	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
160		u, cmd := a.completions.Update(msg)
161		a.completions = u.(completions.Completions)
162		return cmd
163	// dialogs
164	case key.Matches(msg, a.keyMap.Quit):
165		return util.CmdHandler(dialogs.OpenDialogMsg{
166			Model: quit.NewQuitDialog(),
167		})
168
169	case key.Matches(msg, a.keyMap.Commands):
170		return util.CmdHandler(dialogs.OpenDialogMsg{
171			Model: commands.NewCommandDialog(),
172		})
173
174	// Page navigation
175	case key.Matches(msg, a.keyMap.Logs):
176		return a.moveToPage(page.LogsPage)
177
178	default:
179		if a.dialog.HasDialogs() {
180			u, dialogCmd := a.dialog.Update(msg)
181			a.dialog = u.(dialogs.DialogCmp)
182			return dialogCmd
183		} else {
184			updated, cmd := a.pages[a.currentPage].Update(msg)
185			a.pages[a.currentPage] = updated.(util.Model)
186			return cmd
187		}
188	}
189}
190
191// moveToPage handles navigation between different pages in the application.
192func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
193	if a.app.CoderAgent.IsBusy() {
194		// For now we don't move to any page if the agent is busy
195		return util.ReportWarn("Agent is busy, please wait...")
196	}
197
198	var cmds []tea.Cmd
199	if _, ok := a.loadedPages[pageID]; !ok {
200		cmd := a.pages[pageID].Init()
201		cmds = append(cmds, cmd)
202		a.loadedPages[pageID] = true
203	}
204	a.previousPage = a.currentPage
205	a.currentPage = pageID
206	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
207		cmd := sizable.SetSize(a.width, a.height)
208		cmds = append(cmds, cmd)
209	}
210
211	return tea.Batch(cmds...)
212}
213
214// View renders the complete application interface including pages, dialogs, and overlays.
215func (a *appModel) View() tea.View {
216	pageView := a.pages[a.currentPage].View()
217	components := []string{
218		pageView.String(),
219	}
220	components = append(components, a.status.View().String())
221
222	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
223	layers := []*lipgloss.Layer{
224		lipgloss.NewLayer(appView),
225	}
226	if a.dialog.HasDialogs() {
227		layers = append(
228			layers,
229			a.dialog.GetLayers()...,
230		)
231	}
232
233	cursor := pageView.Cursor()
234	activeView := a.dialog.ActiveView()
235	if activeView != nil {
236		cursor = activeView.Cursor()
237	}
238
239	if a.completions.Open() && cursor != nil {
240		cmp := a.completions.View().String()
241		x, y := a.completions.Position()
242		layers = append(
243			layers,
244			lipgloss.NewLayer(cmp).X(x).Y(y),
245		)
246	}
247
248	canvas := lipgloss.NewCanvas(
249		layers...,
250	)
251
252	t := styles.CurrentTheme()
253	view := tea.NewView(canvas.Render())
254	view.SetBackgroundColor(t.BgBase)
255	view.SetCursor(cursor)
256	return view
257}
258
259// New creates and initializes a new TUI application model.
260func New(app *app.App) tea.Model {
261	startPage := page.ChatPage
262	model := &appModel{
263		currentPage: startPage,
264		app:         app,
265		status:      core.NewStatusCmp(app.LSPClients),
266		loadedPages: make(map[page.PageID]bool),
267		keyMap:      DefaultKeyMap(),
268
269		pages: map[page.PageID]util.Model{
270			page.ChatPage: chat.NewChatPage(app),
271			page.LogsPage: page.NewLogsPage(),
272		},
273
274		dialog:      dialogs.NewDialogCmp(),
275		completions: completions.New(),
276	}
277
278	return model
279}