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 // Logs
104 case pubsub.Event[logging.LogMessage]:
105 // Send to the status component
106 s, statusCmd := a.status.Update(msg)
107 a.status = s.(core.StatusCmp)
108 cmds = append(cmds, statusCmd)
109
110 // If the current page is logs, update the logs view
111 if a.currentPage == page.LogsPage {
112 updated, pageCmd := a.pages[a.currentPage].Update(msg)
113 a.pages[a.currentPage] = updated.(util.Model)
114 cmds = append(cmds, pageCmd)
115 }
116 return a, tea.Batch(cmds...)
117 case tea.KeyPressMsg:
118 return a, a.handleKeyPressMsg(msg)
119 }
120 s, _ := a.status.Update(msg)
121 a.status = s.(core.StatusCmp)
122 updated, cmd := a.pages[a.currentPage].Update(msg)
123 a.pages[a.currentPage] = updated.(util.Model)
124 cmds = append(cmds, cmd)
125 return a, tea.Batch(cmds...)
126}
127
128// handleWindowResize processes window resize events and updates all components.
129func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
130 var cmds []tea.Cmd
131 msg.Height -= 1 // Make space for the status bar
132 a.width, a.height = msg.Width, msg.Height
133
134 // Update status bar
135 s, cmd := a.status.Update(msg)
136 a.status = s.(core.StatusCmp)
137 cmds = append(cmds, cmd)
138
139 // Update the current page
140 updated, cmd := a.pages[a.currentPage].Update(msg)
141 a.pages[a.currentPage] = updated.(util.Model)
142 cmds = append(cmds, cmd)
143
144 // Update the dialogs
145 dialog, cmd := a.dialog.Update(msg)
146 a.dialog = dialog.(dialogs.DialogCmp)
147 cmds = append(cmds, cmd)
148
149 return tea.Batch(cmds...)
150}
151
152// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
153func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
154 switch {
155 // completions
156 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
157 u, cmd := a.completions.Update(msg)
158 a.completions = u.(completions.Completions)
159 return cmd
160
161 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
162 u, cmd := a.completions.Update(msg)
163 a.completions = u.(completions.Completions)
164 return cmd
165 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
166 u, cmd := a.completions.Update(msg)
167 a.completions = u.(completions.Completions)
168 return cmd
169 case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
170 u, cmd := a.completions.Update(msg)
171 a.completions = u.(completions.Completions)
172 return cmd
173 // dialogs
174 case key.Matches(msg, a.keyMap.Quit):
175 return util.CmdHandler(dialogs.OpenDialogMsg{
176 Model: quit.NewQuitDialog(),
177 })
178
179 case key.Matches(msg, a.keyMap.Commands):
180 return util.CmdHandler(dialogs.OpenDialogMsg{
181 Model: commands.NewCommandDialog(),
182 })
183 case key.Matches(msg, a.keyMap.SwitchSession):
184 return func() tea.Msg {
185 allSessions, _ := a.app.Sessions.List(context.Background())
186 return dialogs.OpenDialogMsg{
187 Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
188 }
189 }
190 // Page navigation
191 case key.Matches(msg, a.keyMap.Logs):
192 return a.moveToPage(page.LogsPage)
193
194 default:
195 if a.dialog.HasDialogs() {
196 u, dialogCmd := a.dialog.Update(msg)
197 a.dialog = u.(dialogs.DialogCmp)
198 return dialogCmd
199 } else {
200 updated, cmd := a.pages[a.currentPage].Update(msg)
201 a.pages[a.currentPage] = updated.(util.Model)
202 return cmd
203 }
204 }
205}
206
207// moveToPage handles navigation between different pages in the application.
208func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
209 if a.app.CoderAgent.IsBusy() {
210 // For now we don't move to any page if the agent is busy
211 return util.ReportWarn("Agent is busy, please wait...")
212 }
213
214 var cmds []tea.Cmd
215 if _, ok := a.loadedPages[pageID]; !ok {
216 cmd := a.pages[pageID].Init()
217 cmds = append(cmds, cmd)
218 a.loadedPages[pageID] = true
219 }
220 a.previousPage = a.currentPage
221 a.currentPage = pageID
222 if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
223 cmd := sizable.SetSize(a.width, a.height)
224 cmds = append(cmds, cmd)
225 }
226
227 return tea.Batch(cmds...)
228}
229
230// View renders the complete application interface including pages, dialogs, and overlays.
231func (a *appModel) View() tea.View {
232 pageView := a.pages[a.currentPage].View()
233 components := []string{
234 pageView.String(),
235 }
236 components = append(components, a.status.View().String())
237
238 appView := lipgloss.JoinVertical(lipgloss.Top, components...)
239 layers := []*lipgloss.Layer{
240 lipgloss.NewLayer(appView),
241 }
242 if a.dialog.HasDialogs() {
243 layers = append(
244 layers,
245 a.dialog.GetLayers()...,
246 )
247 }
248
249 cursor := pageView.Cursor()
250 activeView := a.dialog.ActiveView()
251 if activeView != nil {
252 cursor = activeView.Cursor()
253 }
254
255 if a.completions.Open() && cursor != nil {
256 cmp := a.completions.View().String()
257 x, y := a.completions.Position()
258 layers = append(
259 layers,
260 lipgloss.NewLayer(cmp).X(x).Y(y),
261 )
262 }
263
264 canvas := lipgloss.NewCanvas(
265 layers...,
266 )
267
268 t := styles.CurrentTheme()
269 view := tea.NewView(canvas.Render())
270 view.SetBackgroundColor(t.BgBase)
271 view.SetCursor(cursor)
272 return view
273}
274
275// New creates and initializes a new TUI application model.
276func New(app *app.App) tea.Model {
277 startPage := page.ChatPage
278 model := &appModel{
279 currentPage: startPage,
280 app: app,
281 status: core.NewStatusCmp(app.LSPClients),
282 loadedPages: make(map[page.PageID]bool),
283 keyMap: DefaultKeyMap(),
284
285 pages: map[page.PageID]util.Model{
286 page.ChatPage: chat.NewChatPage(app),
287 page.LogsPage: page.NewLogsPage(),
288 },
289
290 dialog: dialogs.NewDialogCmp(),
291 completions: completions.New(),
292 }
293
294 return model
295}