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