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