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