on main help sign in
refactor: replace magic numbers with named constants
bc1992fKujtim Hoxha
internal/tui/page/chat/chat.go+101−107
@@ -35,20 +35,11 @@ var ChatPageID page.PageID = "chat"
35type (
35type (
36 OpenFilePickerMsg struct{}
36 OpenFilePickerMsg struct{}
37 ChatFocusedMsg struct {
37 ChatFocusedMsg struct {
38− Focused bool // True if the chat input is focused, false otherwise
38+ Focused bool
39 }
39 }
40 CancelTimerExpiredMsg struct{}
40 CancelTimerExpiredMsg struct{}
41)
41)
42
42
43−type ChatState string
44−
45−const (
46− ChatStateOnboarding ChatState = "onboarding"
47− ChatStateInitProject ChatState = "init_project"
48− ChatStateNewMessage ChatState = "new_message"
49− ChatStateInSession ChatState = "in_session"
50−)
51−
52type PanelType string
43type PanelType string
53
44
54const (
45const (
@@ -63,6 +54,15 @@ const (
63 SideBarWidth = 31 // Width of the sidebar
54 SideBarWidth = 31 // Width of the sidebar
64 SideBarDetailsPadding = 1 // Padding for the sidebar details section
55 SideBarDetailsPadding = 1 // Padding for the sidebar details section
65 HeaderHeight = 1 // Height of the header
56 HeaderHeight = 1 // Height of the header
57+
58+ // Layout constants for borders and padding
59+ BorderWidth = 1 // Width of component borders
60+ LeftRightBorders = 2 // Left + right border width (1 + 1)
61+ TopBottomBorders = 2 // Top + bottom border width (1 + 1)
62+ DetailsPositioning = 2 // Positioning adjustment for details panel
63+
64+ // Timing constants
65+ CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
66)
66)
67
67
68type ChatPage interface {
68type ChatPage interface {
@@ -70,9 +70,9 @@ type ChatPage interface {
70 layout.Help
70 layout.Help
71}
71}
72
72
73−// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
73+// cancelTimerCmd creates a command that expires the cancel timer
74func cancelTimerCmd() tea.Cmd {
74func cancelTimerCmd() tea.Cmd {
75− return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
75+ return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
76 return CancelTimerExpiredMsg{}
76 return CancelTimerExpiredMsg{}
77 })
77 })
78}
78}
@@ -81,34 +81,33 @@ type chatPage struct {
81 width, height int
81 width, height int
82 detailsWidth, detailsHeight int
82 detailsWidth, detailsHeight int
83 app *app.App
83 app *app.App
84+
84− state ChatState
85+ // Layout state
85− session session.Session
86− keyMap KeyMap
87− focusedPane PanelType
88− // Compact mode
89− compact bool
86+ compact bool
90− header header.Header
91− showingDetails bool
92−
93− sidebar sidebar.Sidebar
94− chat chat.MessageListCmp
95− editor editor.Editor
96− splash splash.Splash
97− canceling bool
98−
99− // This will force the compact mode even in big screens
100− // usually triggered by the user command
101− // this will also be set when the user config is set to compact mode
102 forceCompact bool
87 forceCompact bool
88+ focusedPane PanelType
89+
90+ // Session
91+ session session.Session
92+ keyMap KeyMap
93+
94+ // Components
95+ header header.Header
96+ sidebar sidebar.Sidebar
97+ chat chat.MessageListCmp
98+ editor editor.Editor
99+ splash splash.Splash
100+
101+ // Simple state flags
102+ showingDetails bool
103+ isCanceling bool
104+ splashFullScreen bool
103}
105}
104
106
105func New(app *app.App) ChatPage {
107func New(app *app.App) ChatPage {
106 return &chatPage{
108 return &chatPage{
107− app: app,
109+ app: app,
108− state: ChatStateOnboarding,
109−
110− keyMap: DefaultKeyMap(),
110+ keyMap: DefaultKeyMap(),
111−
112 header: header.New(app.LSPClients),
111 header: header.New(app.LSPClients),
113 sidebar: sidebar.New(app.History, app.LSPClients, false),
112 sidebar: sidebar.New(app.History, app.LSPClients, false),
114 chat: chat.New(app),
113 chat: chat.New(app),
@@ -120,19 +119,26 @@ func New(app *app.App) ChatPage {
120
119
121func (p *chatPage) Init() tea.Cmd {
120func (p *chatPage) Init() tea.Cmd {
122 cfg := config.Get()
121 cfg := config.Get()
123− if config.HasInitialDataConfig() {
124− if b, _ := config.ProjectNeedsInitialization(); b {
125− p.state = ChatStateInitProject
126− } else {
127− p.state = ChatStateNewMessage
128− p.focusedPane = PanelTypeEditor
129− }
130− }
131−
132 compact := cfg.Options.TUI.CompactMode
122 compact := cfg.Options.TUI.CompactMode
133 p.compact = compact
123 p.compact = compact
134 p.forceCompact = compact
124 p.forceCompact = compact
135 p.sidebar.SetCompactMode(p.compact)
125 p.sidebar.SetCompactMode(p.compact)
126+
127+ // Set splash state based on config
128+ if !config.HasInitialDataConfig() {
129+ // First-time setup: show model selection
130+ p.splash.SetOnboarding(true)
131+ p.splashFullScreen = true
132+ } else if b, _ := config.ProjectNeedsInitialization(); b {
133+ // Project needs CRUSH.md initialization
134+ p.splash.SetProjectInit(true)
135+ p.splashFullScreen = true
136+ } else {
137+ // Ready to chat: focus editor, splash in background
138+ p.focusedPane = PanelTypeEditor
139+ p.splashFullScreen = false
140+ }
141+
136 return tea.Batch(
142 return tea.Batch(
137 p.header.Init(),
143 p.header.Init(),
138 p.sidebar.Init(),
144 p.sidebar.Init(),
@@ -148,7 +154,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
148 case tea.WindowSizeMsg:
154 case tea.WindowSizeMsg:
149 return p, p.SetSize(msg.Width, msg.Height)
155 return p, p.SetSize(msg.Width, msg.Height)
150 case CancelTimerExpiredMsg:
156 case CancelTimerExpiredMsg:
151− p.canceling = false
157+ p.isCanceling = false
152 return p, nil
158 return p, nil
153 case chat.SendMsg:
159 case chat.SendMsg:
154 return p, p.sendMessage(msg.Text, msg.Attachments)
160 return p, p.sendMessage(msg.Text, msg.Attachments)
@@ -166,7 +172,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
166 }
172 }
167 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
173 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
168 case pubsub.Event[session.Session]:
174 case pubsub.Event[session.Session]:
169− // this needs to go to header/sidebar
170 u, cmd := p.header.Update(msg)
175 u, cmd := p.header.Update(msg)
171 p.header = u.(header.Header)
176 p.header = u.(header.Header)
172 cmds = append(cmds, cmd)
177 cmds = append(cmds, cmd)
@@ -196,32 +201,33 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196 case pubsub.Event[message.Message],
201 case pubsub.Event[message.Message],
197 anim.StepMsg,
202 anim.StepMsg,
198 spinner.TickMsg:
203 spinner.TickMsg:
199− // this needs to go to chat
200 u, cmd := p.chat.Update(msg)
204 u, cmd := p.chat.Update(msg)
201 p.chat = u.(chat.MessageListCmp)
205 p.chat = u.(chat.MessageListCmp)
202 cmds = append(cmds, cmd)
206 cmds = append(cmds, cmd)
203 return p, tea.Batch(cmds...)
207 return p, tea.Batch(cmds...)
204
208
205 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
209 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
206− // this needs to go to sidebar
207 u, cmd := p.sidebar.Update(msg)
210 u, cmd := p.sidebar.Update(msg)
208 p.sidebar = u.(sidebar.Sidebar)
211 p.sidebar = u.(sidebar.Sidebar)
209 cmds = append(cmds, cmd)
212 cmds = append(cmds, cmd)
210 return p, tea.Batch(cmds...)
213 return p, tea.Batch(cmds...)
211
214
212 case commands.CommandRunCustomMsg:
215 case commands.CommandRunCustomMsg:
213− // Check if the agent is busy before executing custom commands
214 if p.app.CoderAgent.IsBusy() {
216 if p.app.CoderAgent.IsBusy() {
215 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
217 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
216 }
218 }
217
219
218− // Handle custom command execution
219 cmd := p.sendMessage(msg.Content, nil)
220 cmd := p.sendMessage(msg.Content, nil)
220 if cmd != nil {
221 if cmd != nil {
221 return p, cmd
222 return p, cmd
222 }
223 }
223 case splash.OnboardingCompleteMsg:
224 case splash.OnboardingCompleteMsg:
224− p.state = ChatStateNewMessage
225+ p.splashFullScreen = false
226+ if b, _ := config.ProjectNeedsInitialization(); b {
227+ p.splash.SetProjectInit(true)
228+ p.splashFullScreen = true
229+ return p, p.SetSize(p.width, p.height)
230+ }
225 err := p.app.InitCoderAgent()
231 err := p.app.InitCoderAgent()
226 if err != nil {
232 if err != nil {
227 return p, util.ReportError(err)
233 return p, util.ReportError(err)
@@ -241,7 +247,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
241 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
247 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
242 }
248 }
243 case key.Matches(msg, p.keyMap.Tab):
249 case key.Matches(msg, p.keyMap.Tab):
244− if p.state == ChatStateOnboarding || p.state == ChatStateInitProject {
250+ if p.session.ID == "" {
245 u, cmd := p.splash.Update(msg)
251 u, cmd := p.splash.Update(msg)
246 p.splash = u.(splash.Splash)
252 p.splash = u.(splash.Splash)
247 return p, cmd
253 return p, cmd
@@ -255,7 +261,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
255 return p, nil
261 return p, nil
256 }
262 }
257
263
258− // Send the key press to the focused pane
259 switch p.focusedPane {
264 switch p.focusedPane {
260 case PanelTypeChat:
265 case PanelTypeChat:
261 u, cmd := p.chat.Update(msg)
266 u, cmd := p.chat.Update(msg)
@@ -277,22 +282,25 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
277func (p *chatPage) View() tea.View {
282func (p *chatPage) View() tea.View {
278 var chatView tea.View
283 var chatView tea.View
279 t := styles.CurrentTheme()
284 t := styles.CurrentTheme()
280− switch p.state {
285+
281− case ChatStateOnboarding, ChatStateInitProject:
286+ if p.session.ID == "" {
282− chatView = p.splash.View()
287+ splashView := p.splash.View()
283− case ChatStateNewMessage:
288+ // Full screen during onboarding or project initialization
289+ if p.splashFullScreen {
290+ chatView = splashView
291+ } else {
292+ // Show splash + editor for new message state
284− editorView := p.editor.View()
293+ editorView := p.editor.View()
285− chatView = tea.NewView(
294+ chatView = tea.NewView(
286− lipgloss.JoinVertical(
295+ lipgloss.JoinVertical(
287− lipgloss.Left,
296+ lipgloss.Left,
288− t.S().Base.Render(
297+ t.S().Base.Render(splashView.String()),
289− p.splash.View().String(),
298+ editorView.String(),
290 ),
299 ),
291− editorView.String(),
300+ )
292− ),
293− )
294− chatView.SetCursor(editorView.Cursor())
301+ chatView.SetCursor(editorView.Cursor())
295− case ChatStateInSession:
302+ }
303+ } else {
296 messagesView := p.chat.View()
304 messagesView := p.chat.View()
297 editorView := p.editor.View()
305 editorView := p.editor.View()
298 if p.compact {
306 if p.compact {
@@ -322,8 +330,6 @@ func (p *chatPage) View() tea.View {
322 )
330 )
323 chatView.SetCursor(editorView.Cursor())
331 chatView.SetCursor(editorView.Cursor())
324 }
332 }
325− default:
326− chatView = tea.NewView("Unknown chat state")
327 }
333 }
328
334
329 layers := []*lipgloss.Layer{
335 layers := []*lipgloss.Layer{
@@ -398,22 +404,22 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd {
398 p.width = width
404 p.width = width
399 p.height = height
405 p.height = height
400 var cmds []tea.Cmd
406 var cmds []tea.Cmd
401− switch p.state {
407+
402− case ChatStateOnboarding, ChatStateInitProject:
408+ if p.session.ID == "" {
403− // here we should just have the splash screen
409+ if p.splashFullScreen {
404− cmds = append(cmds, p.splash.SetSize(width, height))
410+ cmds = append(cmds, p.splash.SetSize(width, height))
405− case ChatStateNewMessage:
411+ } else {
406− cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
412+ cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
407− cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
413+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
408− cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
414+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
409− case ChatStateInSession:
415+ }
416+ } else {
410 if p.compact {
417 if p.compact {
411 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
418 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
412− // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
413− p.detailsWidth = width - 2 // because of position
419+ p.detailsWidth = width - DetailsPositioning
414− cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
420+ cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
415 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
421 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
416− cmds = append(cmds, p.header.SetWidth(width-1))
422+ cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
417 } else {
423 } else {
418 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
424 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
419 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
425 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
@@ -425,17 +431,13 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd {
425}
431}
426
432
427func (p *chatPage) newSession() tea.Cmd {
433func (p *chatPage) newSession() tea.Cmd {
428− if p.state != ChatStateInSession {
434+ if p.session.ID == "" {
429− // Cannot start a new session if we are not in the session state
430 return nil
435 return nil
431 }
436 }
432
437
433− // blank session
434 p.session = session.Session{}
438 p.session = session.Session{}
435− p.state = ChatStateNewMessage
436 p.focusedPane = PanelTypeEditor
439 p.focusedPane = PanelTypeEditor
437− p.canceling = false
440+ p.isCanceling = false
438− // Reset the chat and editor components
439 return tea.Batch(
441 return tea.Batch(
440 util.CmdHandler(chat.SessionClearedMsg{}),
442 util.CmdHandler(chat.SessionClearedMsg{}),
441 p.SetSize(p.width, p.height),
443 p.SetSize(p.width, p.height),
@@ -449,11 +451,8 @@ func (p *chatPage) setSession(session session.Session) tea.Cmd {
449
451
450 var cmds []tea.Cmd
452 var cmds []tea.Cmd
451 p.session = session
453 p.session = session
452− // We want to first resize the components
454+
453− if p.state != ChatStateInSession {
454− p.state = ChatStateInSession
455− cmds = append(cmds, p.SetSize(p.width, p.height))
455+ cmds = append(cmds, p.SetSize(p.width, p.height))
456− }
457 cmds = append(cmds, p.chat.SetSession(session))
456 cmds = append(cmds, p.chat.SetSession(session))
458 cmds = append(cmds, p.sidebar.SetSession(session))
457 cmds = append(cmds, p.sidebar.SetSession(session))
459 cmds = append(cmds, p.header.SetSession(session))
458 cmds = append(cmds, p.header.SetSession(session))
@@ -463,8 +462,7 @@ func (p *chatPage) setSession(session session.Session) tea.Cmd {
463}
462}
464
463
465func (p *chatPage) changeFocus() {
464func (p *chatPage) changeFocus() {
466− if p.state != ChatStateInSession {
465+ if p.session.ID == "" {
467− // Cannot change focus if we are not in the session state
468 return
466 return
469 }
467 }
470 switch p.focusedPane {
468 switch p.focusedPane {
@@ -480,25 +478,22 @@ func (p *chatPage) changeFocus() {
480}
478}
481
479
482func (p *chatPage) cancel() tea.Cmd {
480func (p *chatPage) cancel() tea.Cmd {
483− if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
481+ if p.session.ID == "" || !p.app.CoderAgent.IsBusy() {
484− // Cannot cancel if we are not in the session state
485 return nil
482 return nil
486 }
483 }
487
484
488− // second press of cancel key will actually cancel the session
485+ if p.isCanceling {
489− if p.canceling {
486+ p.isCanceling = false
490− p.canceling = false
491 p.app.CoderAgent.Cancel(p.session.ID)
487 p.app.CoderAgent.Cancel(p.session.ID)
492 return nil
488 return nil
493 }
489 }
494
490
495− p.canceling = true
491+ p.isCanceling = true
496 return cancelTimerCmd()
492 return cancelTimerCmd()
497}
493}
498
494
499func (p *chatPage) showDetails() {
495func (p *chatPage) showDetails() {
500− if p.state != ChatStateInSession || !p.compact {
496+ if p.session.ID == "" || !p.compact {
501− // Cannot show details if we are not in the session state or if we are not in compact mode
502 return
497 return
503 }
498 }
504 p.showingDetails = !p.showingDetails
499 p.showingDetails = !p.showingDetails
@@ -508,8 +503,7 @@ func (p *chatPage) showDetails() {
508func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
503func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
509 session := p.session
504 session := p.session
510 var cmds []tea.Cmd
505 var cmds []tea.Cmd
511− if p.state != ChatStateInSession {
512− // branch new session
506+ if p.session.ID == "" {
513 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
507 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
514 if err != nil {
508 if err != nil {
515 return util.ReportError(err)
509 return util.ReportError(err)
@@ -531,7 +525,7 @@ func (p *chatPage) Bindings() []key.Binding {
531 }
525 }
532 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
526 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
533 cancelBinding := p.keyMap.Cancel
527 cancelBinding := p.keyMap.Cancel
534− if p.canceling {
528+ if p.isCanceling {
535 cancelBinding = key.NewBinding(
529 cancelBinding = key.NewBinding(
536 key.WithKeys("esc"),
530 key.WithKeys("esc"),
537 key.WithHelp("esc", "press again to cancel"),
531 key.WithHelp("esc", "press again to cancel"),