tui.go

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