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