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 dialog *dialog.Overlay
40 help help.Model
41
42 layout layout
43
44 // sendProgressBar instructs the TUI to send progress bar updates to the
45 // terminal.
46 sendProgressBar bool
47
48 // QueryVersion instructs the TUI to query for the terminal version when it
49 // starts.
50 QueryVersion bool
51}
52
53// New creates a new instance of the [UI] model.
54func New(com *common.Common, app *app.App) *UI {
55 return &UI{
56 app: app,
57 com: com,
58 dialog: dialog.NewOverlay(),
59 keyMap: DefaultKeyMap(),
60 editor: NewEditorModel(com, app),
61 help: help.New(),
62 }
63}
64
65// Init initializes the UI model.
66func (m *UI) Init() tea.Cmd {
67 if m.QueryVersion {
68 return tea.RequestTerminalVersion
69 }
70
71 return nil
72}
73
74// Update handles updates to the UI model.
75func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76 var cmds []tea.Cmd
77 switch msg := msg.(type) {
78 case tea.EnvMsg:
79 // Is this Windows Terminal?
80 if !m.sendProgressBar {
81 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
82 }
83 case tea.TerminalVersionMsg:
84 termVersion := strings.ToLower(string(msg))
85 // Only enable progress bar for the following terminals.
86 if !m.sendProgressBar {
87 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
88 }
89 return m, nil
90 case tea.WindowSizeMsg:
91 m.updateLayout(msg.Width, msg.Height)
92 m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
93 m.help.Width = m.layout.help.Dx()
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.updateLayout(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(
176 lipgloss.NewStyle().Width(sideRect.Dx()).
177 Height(sideRect.Dy()).
178 Background(lipgloss.ANSIColor(rand.Intn(256))).
179 Render(" Side View "),
180 ).X(sideRect.Min.X).Y(sideRect.Min.Y),
181 lipgloss.NewLayer(m.editor.View()).
182 X(editRect.Min.X).Y(editRect.Min.Y),
183 lipgloss.NewLayer(m.help.View(helpKeyMap)).
184 X(helpRect.Min.X).Y(helpRect.Min.Y),
185 )
186
187 layers = append(layers, mainLayer)
188
189 v.Layer = lipgloss.NewCanvas(layers...)
190 if m.sendProgressBar && m.app != nil && m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
191 // HACK: use a random percentage to prevent ghostty from hiding it
192 // after a timeout.
193 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
194 }
195
196 return v
197}
198
199func (m *UI) focusedKeyMap() help.KeyMap {
200 if m.state == uiChat {
201 return m.chat
202 }
203 return m.editor
204}
205
206// updateDialogs updates the dialog overlay with the given message and appends
207// any resulting commands to the cmds slice.
208func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
209 updatedDialog, cmd := m.dialog.Update(msg)
210 m.dialog = updatedDialog
211 if cmd != nil {
212 *cmds = append(*cmds, cmd)
213 }
214}
215
216// updateFocused updates the focused model (chat or editor) with the given message
217// and appends any resulting commands to the cmds slice.
218func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
219 switch m.state {
220 case uiChat:
221 m.updateChat(msg, cmds)
222 case uiEdit:
223 m.updateEditor(msg, cmds)
224 }
225}
226
227// updateChat updates the chat model with the given message and appends any
228// resulting commands to the cmds slice.
229func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
230 updatedChat, cmd := m.chat.Update(msg)
231 m.chat = updatedChat
232 if cmd != nil {
233 *cmds = append(*cmds, cmd)
234 }
235}
236
237// updateEditor updates the editor model with the given message and appends any
238// resulting commands to the cmds slice.
239func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
240 updatedEditor, cmd := m.editor.Update(msg)
241 m.editor = updatedEditor
242 if cmd != nil {
243 *cmds = append(*cmds, cmd)
244 }
245}
246
247// updateLayout updates the layout based on the given terminal width and
248// height given in cells.
249func (m *UI) updateLayout(w, h int) {
250 // The screen area we're working with
251 area := image.Rect(0, 0, w, h)
252 helpKeyMap := m.focusedKeyMap()
253 helpHeight := 1
254 if m.dialog.HasDialogs() && len(m.dialog.FullHelp()) > 0 && len(m.dialog.ShortHelp()) > 0 {
255 helpKeyMap = m.dialog
256 }
257 if m.help.ShowAll {
258 for _, row := range helpKeyMap.FullHelp() {
259 helpHeight = max(helpHeight, len(row))
260 }
261 }
262
263 // Add app margins
264 mainRect := area
265 mainRect.Min.X += 1
266 mainRect.Min.Y += 1
267 mainRect.Max.X -= 1
268 mainRect.Max.Y -= 1
269
270 mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight))
271 chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40))
272 chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5))
273
274 // Add 1 line margin bottom of chatRect
275 chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1))
276 // Add 1 line margin bottom of editRect
277 editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1))
278
279 m.layout = layout{
280 area: area,
281 main: mainRect,
282 chat: chatRect,
283 editor: editRect,
284 sidebar: sideRect,
285 help: helpRect,
286 }
287}
288
289// layout defines the positioning of UI elements.
290type layout struct {
291 // area is the overall available area.
292 area uv.Rectangle
293
294 // main is the main area excluding help.
295 main uv.Rectangle
296
297 // chat is the area for the chat pane.
298 chat uv.Rectangle
299
300 // editor is the area for the editor pane.
301 editor uv.Rectangle
302
303 // sidebar is the area for the sidebar.
304 sidebar uv.Rectangle
305
306 // help is the area for the help view.
307 help uv.Rectangle
308}