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