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