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/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 core.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.(core.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.(core.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.(core.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.(core.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 return util.CmdHandler(dialogs.OpenDialogMsg{
193 Model: quit.NewQuitDialog(),
194 })
195
196 case key.Matches(msg, a.keyMap.Commands):
197 return util.CmdHandler(dialogs.OpenDialogMsg{
198 Model: commands.NewCommandDialog(),
199 })
200 // Page navigation
201 case key.Matches(msg, a.keyMap.Logs):
202 return a.moveToPage(page.LogsPage)
203
204 default:
205 if a.dialog.HasDialogs() {
206 u, dialogCmd := a.dialog.Update(msg)
207 a.dialog = u.(dialogs.DialogCmp)
208 return dialogCmd
209 } else {
210 updated, cmd := a.pages[a.currentPage].Update(msg)
211 a.pages[a.currentPage] = updated.(util.Model)
212 return cmd
213 }
214 }
215}
216
217// moveToPage handles navigation between different pages in the application.
218func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
219 if a.app.CoderAgent.IsBusy() {
220 // For now we don't move to any page if the agent is busy
221 return util.ReportWarn("Agent is busy, please wait...")
222 }
223
224 var cmds []tea.Cmd
225 if _, ok := a.loadedPages[pageID]; !ok {
226 cmd := a.pages[pageID].Init()
227 cmds = append(cmds, cmd)
228 a.loadedPages[pageID] = true
229 }
230 a.previousPage = a.currentPage
231 a.currentPage = pageID
232 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
233 cmd := sizable.SetSize(a.width, a.height)
234 cmds = append(cmds, cmd)
235 }
236
237 return tea.Batch(cmds...)
238}
239
240// View renders the complete application interface including pages, dialogs, and overlays.
241func (a *appModel) View() tea.View {
242 pageView := a.pages[a.currentPage].View()
243 components := []string{
244 pageView.String(),
245 }
246 components = append(components, a.status.View().String())
247
248 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
249 layers := []*lipgloss.Layer{
250 lipgloss.NewLayer(appView),
251 }
252 if a.dialog.HasDialogs() {
253 layers = append(
254 layers,
255 a.dialog.GetLayers()...,
256 )
257 }
258
259 cursor := pageView.Cursor()
260 activeView := a.dialog.ActiveView()
261 if activeView != nil {
262 cursor = activeView.Cursor()
263 }
264
265 if a.completions.Open() && cursor != nil {
266 cmp := a.completions.View().String()
267 x, y := a.completions.Position()
268 layers = append(
269 layers,
270 lipgloss.NewLayer(cmp).X(x).Y(y),
271 )
272 }
273
274 canvas := lipgloss.NewCanvas(
275 layers...,
276 )
277
278 t := styles.CurrentTheme()
279 view := tea.NewView(canvas.Render())
280 view.SetBackgroundColor(t.BgBase)
281 view.SetCursor(cursor)
282 return view
283}
284
285// New creates and initializes a new TUI application model.
286func New(app *app.App) tea.Model {
287 startPage := page.ChatPage
288 model := &appModel{
289 currentPage: startPage,
290 app: app,
291 status: core.NewStatusCmp(app.LSPClients),
292 loadedPages: make(map[page.PageID]bool),
293 keyMap: DefaultKeyMap(),
294
295 pages: map[page.PageID]util.Model{
296 page.ChatPage: chat.NewChatPage(app),
297 page.LogsPage: page.NewLogsPage(),
298 },
299
300 dialog: dialogs.NewDialogCmp(),
301 completions: completions.New(),
302 }
303
304 return model
305}