1package model
2
3import (
4 "image"
5 "math/rand"
6 "slices"
7 "strings"
8
9 "github.com/charmbracelet/bubbles/v2/help"
10 "github.com/charmbracelet/bubbles/v2/key"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/app"
13 "github.com/charmbracelet/crush/internal/ui/common"
14 "github.com/charmbracelet/crush/internal/ui/dialog"
15 "github.com/charmbracelet/lipgloss/v2"
16 uv "github.com/charmbracelet/ultraviolet"
17)
18
19// uiState represents the current focus state of the UI.
20type uiState uint8
21
22// Possible uiState values.
23const (
24 uiChat uiState = iota
25 uiEdit
26)
27
28// UI represents the main user interface model.
29type UI struct {
30 app *app.App
31 com *common.Common
32
33 state uiState
34
35 keyMap KeyMap
36
37 chat *ChatModel
38 editor *EditorModel
39 side *SidebarModel
40 dialog *dialog.Overlay
41 help help.Model
42
43 layout layout
44
45 // sendProgressBar instructs the TUI to send progress bar updates to the
46 // terminal.
47 sendProgressBar bool
48
49 // QueryVersion instructs the TUI to query for the terminal version when it
50 // starts.
51 QueryVersion bool
52}
53
54// New creates a new instance of the [UI] model.
55func New(com *common.Common, app *app.App) *UI {
56 return &UI{
57 app: app,
58 com: com,
59 dialog: dialog.NewOverlay(),
60 keyMap: DefaultKeyMap(),
61 editor: NewEditorModel(com, app),
62 side: NewSidebarModel(com),
63 help: help.New(),
64 }
65}
66
67// Init initializes the UI model.
68func (m *UI) Init() tea.Cmd {
69 if m.QueryVersion {
70 return tea.RequestTerminalVersion
71 }
72
73 return nil
74}
75
76// Update handles updates to the UI model.
77func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78 var cmds []tea.Cmd
79 switch msg := msg.(type) {
80 case tea.EnvMsg:
81 // Is this Windows Terminal?
82 if !m.sendProgressBar {
83 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
84 }
85 case tea.TerminalVersionMsg:
86 termVersion := strings.ToLower(string(msg))
87 // Only enable progress bar for the following terminals.
88 if !m.sendProgressBar {
89 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
90 }
91 return m, nil
92 case tea.WindowSizeMsg:
93 m.updateLayoutAndSize(msg.Width, msg.Height)
94 case tea.KeyPressMsg:
95 if m.dialog.HasDialogs() {
96 m.updateDialogs(msg, &cmds)
97 } else {
98 switch {
99 case key.Matches(msg, m.keyMap.Tab):
100 if m.state == uiChat {
101 m.state = uiEdit
102 cmds = append(cmds, m.editor.Focus())
103 } else {
104 m.state = uiChat
105 cmds = append(cmds, m.editor.Blur())
106 }
107 case key.Matches(msg, m.keyMap.Help):
108 m.help.ShowAll = !m.help.ShowAll
109 m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
110 case key.Matches(msg, m.keyMap.Quit):
111 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
112 m.dialog.AddDialog(dialog.NewQuit(m.com))
113 return m, nil
114 }
115 default:
116 m.updateFocused(msg, &cmds)
117 }
118 }
119 }
120
121 return m, tea.Batch(cmds...)
122}
123
124// View renders the UI model's view.
125func (m *UI) View() tea.View {
126 var v tea.View
127 v.AltScreen = true
128
129 layers := []*lipgloss.Layer{}
130
131 // Determine the help key map based on focus
132 helpKeyMap := m.focusedKeyMap()
133
134 // The screen areas we're working with
135 area := m.layout.area
136 chatRect := m.layout.chat
137 sideRect := m.layout.sidebar
138 editRect := m.layout.editor
139 helpRect := m.layout.help
140
141 if m.dialog.HasDialogs() {
142 if dialogView := m.dialog.View(); dialogView != "" {
143 // If the dialog has its own help, use that instead
144 if len(m.dialog.FullHelp()) > 0 || len(m.dialog.ShortHelp()) > 0 {
145 helpKeyMap = m.dialog
146 }
147
148 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
149 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
150 layers = append(layers,
151 lipgloss.NewLayer(dialogView).
152 X(dialogArea.Min.X).
153 Y(dialogArea.Min.Y).
154 Z(99),
155 )
156 }
157 }
158
159 if m.state == uiEdit && m.editor.Focused() {
160 cur := m.editor.Cursor()
161 cur.X++ // Adjust for app margins
162 cur.Y += editRect.Min.Y
163 v.Cursor = cur
164 }
165
166 mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y).
167 Width(area.Dx()).Height(area.Dy()).
168 AddLayers(
169 lipgloss.NewLayer(
170 lipgloss.NewStyle().Width(chatRect.Dx()).
171 Height(chatRect.Dy()).
172 Background(lipgloss.ANSIColor(rand.Intn(256))).
173 Render(" Main View "),
174 ).X(chatRect.Min.X).Y(chatRect.Min.Y),
175 lipgloss.NewLayer(m.side.View()).
176 X(sideRect.Min.X).Y(sideRect.Min.Y),
177 lipgloss.NewLayer(m.editor.View()).
178 X(editRect.Min.X).Y(editRect.Min.Y),
179 lipgloss.NewLayer(m.help.View(helpKeyMap)).
180 X(helpRect.Min.X).Y(helpRect.Min.Y),
181 )
182
183 layers = append(layers, mainLayer)
184
185 v.Layer = lipgloss.NewCanvas(layers...)
186 if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
187 // HACK: use a random percentage to prevent ghostty from hiding it
188 // after a timeout.
189 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
190 }
191
192 return v
193}
194
195func (m *UI) focusedKeyMap() help.KeyMap {
196 if m.state == uiChat {
197 return m.chat
198 }
199 return m.editor
200}
201
202// updateDialogs updates the dialog overlay with the given message and appends
203// any resulting commands to the cmds slice.
204func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
205 updatedDialog, cmd := m.dialog.Update(msg)
206 m.dialog = updatedDialog
207 if cmd != nil {
208 *cmds = append(*cmds, cmd)
209 }
210}
211
212// updateFocused updates the focused model (chat or editor) with the given message
213// and appends any resulting commands to the cmds slice.
214func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
215 switch m.state {
216 case uiChat:
217 m.updateChat(msg, cmds)
218 case uiEdit:
219 m.updateEditor(msg, cmds)
220 }
221}
222
223// updateChat updates the chat model with the given message and appends any
224// resulting commands to the cmds slice.
225func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
226 updatedChat, cmd := m.chat.Update(msg)
227 m.chat = updatedChat
228 if cmd != nil {
229 *cmds = append(*cmds, cmd)
230 }
231}
232
233// updateEditor updates the editor model with the given message and appends any
234// resulting commands to the cmds slice.
235func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
236 updatedEditor, cmd := m.editor.Update(msg)
237 m.editor = updatedEditor
238 if cmd != nil {
239 *cmds = append(*cmds, cmd)
240 }
241}
242
243// updateLayoutAndSize updates the layout and sub-models sizes based on the
244// given terminal width and height given in cells.
245func (m *UI) updateLayoutAndSize(w, h int) {
246 // The screen area we're working with
247 area := image.Rect(0, 0, w, h)
248 helpKeyMap := m.focusedKeyMap()
249 helpHeight := 1
250 if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
251 helpKeyMap = m.dialog
252 }
253 if m.help.ShowAll {
254 for _, row := range helpKeyMap.FullHelp() {
255 helpHeight = max(helpHeight, len(row))
256 }
257 }
258
259 // Add app margins
260 mainRect := area
261 mainRect.Min.X += 1
262 mainRect.Min.Y += 1
263 mainRect.Max.X -= 1
264 mainRect.Max.Y -= 1
265
266 mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
267 chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
268 chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
269
270 // Add 1 line margin bottom of chatRect
271 chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
272 // Add 1 line margin bottom of editRect
273 editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
274
275 m.layout = layout{
276 area: area,
277 main: mainRect,
278 chat: chatRect,
279 editor: editRect,
280 sidebar: sideRect,
281 help: helpRect,
282 }
283
284 // Update sub-model sizes
285 m.side.SetWidth(m.layout.sidebar.Dx())
286 m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
287 m.help.Width = m.layout.help.Dx()
288}
289
290// layout defines the positioning of UI elements.
291type layout struct {
292 // area is the overall available area.
293 area uv.Rectangle
294
295 // main is the main area excluding help.
296 main uv.Rectangle
297
298 // chat is the area for the chat pane.
299 chat uv.Rectangle
300
301 // editor is the area for the editor pane.
302 editor uv.Rectangle
303
304 // sidebar is the area for the sidebar.
305 sidebar uv.Rectangle
306
307 // help is the area for the help view.
308 help uv.Rectangle
309}