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