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	// Logs
104	case pubsub.Event[logging.LogMessage]:
105		// Send to the status component
106		s, statusCmd := a.status.Update(msg)
107		a.status = s.(core.StatusCmp)
108		cmds = append(cmds, statusCmd)
109
110		// If the current page is logs, update the logs view
111		if a.currentPage == page.LogsPage {
112			updated, pageCmd := a.pages[a.currentPage].Update(msg)
113			a.pages[a.currentPage] = updated.(util.Model)
114			cmds = append(cmds, pageCmd)
115		}
116		return a, tea.Batch(cmds...)
117	case tea.KeyPressMsg:
118		return a, a.handleKeyPressMsg(msg)
119	}
120	s, _ := a.status.Update(msg)
121	a.status = s.(core.StatusCmp)
122	updated, cmd := a.pages[a.currentPage].Update(msg)
123	a.pages[a.currentPage] = updated.(util.Model)
124	cmds = append(cmds, cmd)
125	return a, tea.Batch(cmds...)
126}
127
128// handleWindowResize processes window resize events and updates all components.
129func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
130	var cmds []tea.Cmd
131	msg.Height -= 1 // Make space for the status bar
132	a.width, a.height = msg.Width, msg.Height
133
134	// Update status bar
135	s, cmd := a.status.Update(msg)
136	a.status = s.(core.StatusCmp)
137	cmds = append(cmds, cmd)
138
139	// Update the current page
140	updated, cmd := a.pages[a.currentPage].Update(msg)
141	a.pages[a.currentPage] = updated.(util.Model)
142	cmds = append(cmds, cmd)
143
144	// Update the dialogs
145	dialog, cmd := a.dialog.Update(msg)
146	a.dialog = dialog.(dialogs.DialogCmp)
147	cmds = append(cmds, cmd)
148
149	return tea.Batch(cmds...)
150}
151
152// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
153func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
154	switch {
155	// completions
156	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
157		u, cmd := a.completions.Update(msg)
158		a.completions = u.(completions.Completions)
159		return cmd
160
161	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
162		u, cmd := a.completions.Update(msg)
163		a.completions = u.(completions.Completions)
164		return cmd
165	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
166		u, cmd := a.completions.Update(msg)
167		a.completions = u.(completions.Completions)
168		return cmd
169	case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
170		u, cmd := a.completions.Update(msg)
171		a.completions = u.(completions.Completions)
172		return cmd
173	// dialogs
174	case key.Matches(msg, a.keyMap.Quit):
175		return util.CmdHandler(dialogs.OpenDialogMsg{
176			Model: quit.NewQuitDialog(),
177		})
178
179	case key.Matches(msg, a.keyMap.Commands):
180		return util.CmdHandler(dialogs.OpenDialogMsg{
181			Model: commands.NewCommandDialog(),
182		})
183	case key.Matches(msg, a.keyMap.SwitchSession):
184		return func() tea.Msg {
185			allSessions, _ := a.app.Sessions.List(context.Background())
186			return dialogs.OpenDialogMsg{
187				Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
188			}
189		}
190	// Page navigation
191	case key.Matches(msg, a.keyMap.Logs):
192		return a.moveToPage(page.LogsPage)
193
194	default:
195		if a.dialog.HasDialogs() {
196			u, dialogCmd := a.dialog.Update(msg)
197			a.dialog = u.(dialogs.DialogCmp)
198			return dialogCmd
199		} else {
200			updated, cmd := a.pages[a.currentPage].Update(msg)
201			a.pages[a.currentPage] = updated.(util.Model)
202			return cmd
203		}
204	}
205}
206
207// moveToPage handles navigation between different pages in the application.
208func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
209	if a.app.CoderAgent.IsBusy() {
210		// For now we don't move to any page if the agent is busy
211		return util.ReportWarn("Agent is busy, please wait...")
212	}
213
214	var cmds []tea.Cmd
215	if _, ok := a.loadedPages[pageID]; !ok {
216		cmd := a.pages[pageID].Init()
217		cmds = append(cmds, cmd)
218		a.loadedPages[pageID] = true
219	}
220	a.previousPage = a.currentPage
221	a.currentPage = pageID
222	if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
223		cmd := sizable.SetSize(a.width, a.height)
224		cmds = append(cmds, cmd)
225	}
226
227	return tea.Batch(cmds...)
228}
229
230// View renders the complete application interface including pages, dialogs, and overlays.
231func (a *appModel) View() tea.View {
232	pageView := a.pages[a.currentPage].View()
233	components := []string{
234		pageView.String(),
235	}
236	components = append(components, a.status.View().String())
237
238	appView := lipgloss.JoinVertical(lipgloss.Top, components...)
239	layers := []*lipgloss.Layer{
240		lipgloss.NewLayer(appView),
241	}
242	if a.dialog.HasDialogs() {
243		layers = append(
244			layers,
245			a.dialog.GetLayers()...,
246		)
247	}
248
249	cursor := pageView.Cursor()
250	activeView := a.dialog.ActiveView()
251	if activeView != nil {
252		cursor = activeView.Cursor()
253	}
254
255	if a.completions.Open() && cursor != nil {
256		cmp := a.completions.View().String()
257		x, y := a.completions.Position()
258		layers = append(
259			layers,
260			lipgloss.NewLayer(cmp).X(x).Y(y),
261		)
262	}
263
264	canvas := lipgloss.NewCanvas(
265		layers...,
266	)
267
268	t := styles.CurrentTheme()
269	view := tea.NewView(canvas.Render())
270	view.SetBackgroundColor(t.BgBase)
271	view.SetCursor(cursor)
272	return view
273}
274
275// New creates and initializes a new TUI application model.
276func New(app *app.App) tea.Model {
277	startPage := page.ChatPage
278	model := &appModel{
279		currentPage: startPage,
280		app:         app,
281		status:      core.NewStatusCmp(app.LSPClients),
282		loadedPages: make(map[page.PageID]bool),
283		keyMap:      DefaultKeyMap(),
284
285		pages: map[page.PageID]util.Model{
286			page.ChatPage: chat.NewChatPage(app),
287			page.LogsPage: page.NewLogsPage(),
288		},
289
290		dialog:      dialogs.NewDialogCmp(),
291		completions: completions.New(),
292	}
293
294	return model
295}