on main help sign in
chore: rewrite the chat page
1526d1aKujtim Hoxha
internal/tui/components/chat/chat.go+20−10
@@ -8,14 +8,15 @@ import (
8 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/app"
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/llm/agent"
10 "github.com/charmbracelet/crush/internal/llm/agent"
11+ "github.com/charmbracelet/crush/internal/logging"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
16 "github.com/charmbracelet/crush/internal/tui/components/core/list"
17 "github.com/charmbracelet/crush/internal/tui/components/core/list"
18+ "github.com/charmbracelet/crush/internal/tui/styles"
17 "github.com/charmbracelet/crush/internal/tui/util"
19 "github.com/charmbracelet/crush/internal/tui/util"
18− "github.com/charmbracelet/lipgloss/v2"
19)
20)
20
21
21type SendMsg struct {
22type SendMsg struct {
@@ -37,6 +38,9 @@ type MessageListCmp interface {
37 util.Model
38 util.Model
38 layout.Sizeable
39 layout.Sizeable
39 layout.Focusable
40 layout.Focusable
41+ layout.Help
42+
43+ SetSession(session.Session) tea.Cmd
40}
44}
41
45
42// messageListCmp implements MessageListCmp, providing a virtualized list
46// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -53,9 +57,9 @@ type messageListCmp struct {
53 defaultListKeyMap list.KeyMap
57 defaultListKeyMap list.KeyMap
54}
58}
55
59
56−// NewMessagesListCmp creates a new message list component with custom keybindings
60+// New creates a new message list component with custom keybindings
57// and reverse ordering (newest messages at bottom).
61// and reverse ordering (newest messages at bottom).
58−func NewMessagesListCmp(app *app.App) MessageListCmp {
62+func New(app *app.App) MessageListCmp {
59 defaultListKeyMap := list.DefaultKeyMap()
63 defaultListKeyMap := list.DefaultKeyMap()
60 listCmp := list.New(
64 listCmp := list.New(
61 list.WithGapSize(1),
65 list.WithGapSize(1),
@@ -70,13 +74,14 @@ func NewMessagesListCmp(app *app.App) MessageListCmp {
70 }
74 }
71}
75}
72
76
73−// Init initializes the component (no initialization needed).
77+// Init initializes the component.
74func (m *messageListCmp) Init() tea.Cmd {
78func (m *messageListCmp) Init() tea.Cmd {
75 return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
79 return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
76}
80}
77
81
78// Update handles incoming messages and updates the component state.
82// Update handles incoming messages and updates the component state.
79func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84+ logging.Info("messageListCmp.Update", "msg", msg)
80 switch msg := msg.(type) {
85 switch msg := msg.(type) {
81 case SessionSelectedMsg:
86 case SessionSelectedMsg:
82 if msg.ID != m.session.ID {
87 if msg.ID != m.session.ID {
@@ -102,11 +107,15 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102
107
103// View renders the message list or an initial screen if empty.
108// View renders the message list or an initial screen if empty.
104func (m *messageListCmp) View() tea.View {
109func (m *messageListCmp) View() tea.View {
110+ t := styles.CurrentTheme()
105 return tea.NewView(
111 return tea.NewView(
106− lipgloss.JoinVertical(
112+ t.S().Base.
107− lipgloss.Left,
113+ Padding(1).
114+ Width(m.width).
115+ Height(m.height).
116+ Render(
108− m.listCmp.View().String(),
117+ m.listCmp.View().String(),
109− ),
118+ ),
110 )
119 )
111}
120}
112
121
@@ -371,6 +380,7 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
371
380
372// SetSession loads and displays messages for a new session.
381// SetSession loads and displays messages for a new session.
373func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
382func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
383+ logging.Info("messageListCmp.SetSession", "sessionID", session.ID)
374 if m.session.ID == session.ID {
384 if m.session.ID == session.ID {
375 return nil
385 return nil
376 }
386 }
@@ -489,8 +499,8 @@ func (m *messageListCmp) GetSize() (int, int) {
489// SetSize updates the component dimensions and propagates to the list component.
499// SetSize updates the component dimensions and propagates to the list component.
490func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
500func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
491 m.width = width
501 m.width = width
492− m.height = height - 1
502+ m.height = height
493− return m.listCmp.SetSize(width, height-1)
503+ return m.listCmp.SetSize(width-2, height-2) // for padding
494}
504}
495
505
496// Blur implements MessageListCmp.
506// Blur implements MessageListCmp.
internal/tui/components/chat/editor/editor.go+20−6
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -26,6 +27,16 @@ import (
26 "github.com/charmbracelet/lipgloss/v2"
27 "github.com/charmbracelet/lipgloss/v2"
27)
28)
28
29
30+type Editor interface {
31+ util.Model
32+ layout.Sizeable
33+ layout.Focusable
34+ layout.Help
35+ layout.Positional
36+
37+ SetSession(session session.Session) tea.Cmd
38+}
39+
29type FileCompletionItem struct {
40type FileCompletionItem struct {
30 Path string // The file path
41 Path string // The file path
31}
42}
@@ -145,11 +156,6 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 var cmd tea.Cmd
156 var cmd tea.Cmd
146 var cmds []tea.Cmd
157 var cmds []tea.Cmd
147 switch msg := msg.(type) {
158 switch msg := msg.(type) {
148− case chat.SessionSelectedMsg:
149− if msg.ID != m.session.ID {
150− m.session = msg
151− }
152− return m, nil
153 case filepicker.FilePickedMsg:
159 case filepicker.FilePickedMsg:
154 if len(m.attachments) >= maxAttachments {
160 if len(m.attachments) >= maxAttachments {
155 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
161 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
@@ -372,7 +378,14 @@ func (c *editorCmp) Bindings() []key.Binding {
372 return c.keyMap.KeyBindings()
378 return c.keyMap.KeyBindings()
373}
379}
374
380
381+// TODO: most likely we do not need to have the session here
382+// we need to move some functionality to the page level
383+func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
384+ c.session = session
385+ return nil
386+}
387+
375−func NewEditorCmp(app *app.App) util.Model {
388+func New(app *app.App) Editor {
376 t := styles.CurrentTheme()
389 t := styles.CurrentTheme()
377 ta := textarea.New()
390 ta := textarea.New()
378 ta.SetStyles(t.S().TextArea)
391 ta.SetStyles(t.S().TextArea)
@@ -393,6 +406,7 @@ func NewEditorCmp(app *app.App) util.Model {
393 ta.Focus()
406 ta.Focus()
394
407
395 return &editorCmp{
408 return &editorCmp{
409+ // TODO: remove the app instance from here
396 app: app,
410 app: app,
397 textarea: ta,
411 textarea: ta,
398 keyMap: DefaultEditorKeyMap(),
412 keyMap: DefaultEditorKeyMap(),
internal/tui/components/chat/header/header.go+10−7
@@ -11,7 +11,6 @@ import (
11 "github.com/charmbracelet/crush/internal/lsp/protocol"
11 "github.com/charmbracelet/crush/internal/lsp/protocol"
12 "github.com/charmbracelet/crush/internal/pubsub"
12 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
14− "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/lipgloss/v2"
16 "github.com/charmbracelet/lipgloss/v2"
@@ -19,7 +18,8 @@ import (
19
18
20type Header interface {
19type Header interface {
21 util.Model
20 util.Model
22− SetSession(session session.Session)
21+ SetSession(session session.Session) tea.Cmd
22+ SetWidth(width int) tea.Cmd
23 SetDetailsOpen(open bool)
23 SetDetailsOpen(open bool)
24}
24}
25
25
@@ -43,10 +43,6 @@ func (h *header) Init() tea.Cmd {
43
43
44func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45 switch msg := msg.(type) {
45 switch msg := msg.(type) {
46− case tea.WindowSizeMsg:
47− p.width = msg.Width - 2
48− case chat.SessionSelectedMsg:
49− p.session = msg
50 case pubsub.Event[session.Session]:
46 case pubsub.Event[session.Session]:
51 if msg.Type == pubsub.UpdatedEvent {
47 if msg.Type == pubsub.UpdatedEvent {
52 if p.session.ID == msg.Payload.ID {
48 if p.session.ID == msg.Payload.ID {
@@ -131,6 +127,13 @@ func (h *header) SetDetailsOpen(open bool) {
131}
127}
132
128
133// SetSession implements Header.
129// SetSession implements Header.
134−func (h *header) SetSession(session session.Session) {
130+func (h *header) SetSession(session session.Session) tea.Cmd {
135 h.session = session
131 h.session = session
132+ return nil
133+}
134+
135+// SetWidth implements Header.
136+func (h *header) SetWidth(width int) tea.Cmd {
137+ h.width = width
138+ return nil
136}
139}
internal/tui/components/chat/sidebar/sidebar.go+34−29
@@ -13,7 +13,6 @@ import (
13 "github.com/charmbracelet/crush/internal/diff"
13 "github.com/charmbracelet/crush/internal/diff"
14 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/history"
15 "github.com/charmbracelet/crush/internal/history"
16−
17 "github.com/charmbracelet/crush/internal/lsp"
16 "github.com/charmbracelet/crush/internal/lsp"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
19 "github.com/charmbracelet/crush/internal/pubsub"
18 "github.com/charmbracelet/crush/internal/pubsub"
@@ -29,10 +28,6 @@ import (
29 "github.com/charmbracelet/x/ansi"
28 "github.com/charmbracelet/x/ansi"
30)
29)
31
30
32−const (
33− logoBreakpoint = 65
34−)
35−
36type FileHistory struct {
31type FileHistory struct {
37 initialVersion history.File
32 initialVersion history.File
38 latestVersion history.File
33 latestVersion history.File
@@ -52,6 +47,7 @@ type Sidebar interface {
52 util.Model
47 util.Model
53 layout.Sizeable
48 layout.Sizeable
54 SetSession(session session.Session) tea.Cmd
49 SetSession(session session.Session) tea.Cmd
50+ SetCompactMode(bool)
55}
51}
56
52
57type sidebarCmp struct {
53type sidebarCmp struct {
@@ -66,7 +62,7 @@ type sidebarCmp struct {
66 files sync.Map
62 files sync.Map
67}
63}
68
64
69−func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
65+func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
70 return &sidebarCmp{
66 return &sidebarCmp{
71 lspClients: lspClients,
67 lspClients: lspClients,
72 history: history,
68 history: history,
@@ -75,15 +71,11 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
75}
71}
76
72
77func (m *sidebarCmp) Init() tea.Cmd {
73func (m *sidebarCmp) Init() tea.Cmd {
78− m.logo = m.logoBlock(false)
79− m.cwd = cwd()
80 return nil
74 return nil
81}
75}
82
76
83func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 switch msg := msg.(type) {
78 switch msg := msg.(type) {
85− case chat.SessionSelectedMsg:
86− return m, m.SetSession(msg)
87 case SessionFilesMsg:
79 case SessionFilesMsg:
88 m.files = sync.Map{}
80 m.files = sync.Map{}
89 for _, file := range msg.Files {
81 for _, file := range msg.Files {
@@ -137,8 +129,19 @@ func (m *sidebarCmp) View() tea.View {
137 m.mcpBlock(),
129 m.mcpBlock(),
138 )
130 )
139
131
132+ // TODO: CHECK out why we need to set the background here weird issue
133+ style := t.S().Base.
134+ Background(t.BgBase).
135+ Width(m.width).
136+ Height(m.height).
137+ Padding(1)
138+ if m.compactMode {
139+ style = style.PaddingTop(0)
140+ }
140 return tea.NewView(
141 return tea.NewView(
142+ style.Render(
141− lipgloss.JoinVertical(lipgloss.Left, parts...),
143+ lipgloss.JoinVertical(lipgloss.Left, parts...),
144+ ),
142 )
145 )
143}
146}
144
147
@@ -232,12 +235,8 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
232}
235}
233
236
234func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
237func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
235− if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
236− m.logo = m.logoBlock(true)
238+ m.logo = m.logoBlock()
237− } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
239+ m.cwd = cwd()
238− m.logo = m.logoBlock(false)
239− }
240−
241 m.width = width
240 m.width = width
242 m.height = height
241 m.height = height
243 return nil
242 return nil
@@ -247,9 +246,9 @@ func (m *sidebarCmp) GetSize() (int, int) {
247 return m.width, m.height
246 return m.width, m.height
248}
247}
249
248
250−func (m *sidebarCmp) logoBlock(compact bool) string {
249+func (m *sidebarCmp) logoBlock() string {
251 t := styles.CurrentTheme()
250 t := styles.CurrentTheme()
252− return logo.Render(version.Version, compact, logo.Opts{
251+ return logo.Render(version.Version, true, logo.Opts{
253 FieldColor: t.Primary,
252 FieldColor: t.Primary,
254 TitleColorA: t.Secondary,
253 TitleColorA: t.Secondary,
255 TitleColorB: t.Primary,
254 TitleColorB: t.Primary,
@@ -258,12 +257,15 @@ func (m *sidebarCmp) logoBlock(compact bool) string {
258 })
257 })
259}
258}
260
259
260+func (m *sidebarCmp) getMaxWidth() int {
261+ return min(m.width-2, 58) // -2 for padding
262+}
263+
261func (m *sidebarCmp) filesBlock() string {
264func (m *sidebarCmp) filesBlock() string {
262− maxWidth := min(m.width, 58)
263 t := styles.CurrentTheme()
265 t := styles.CurrentTheme()
264
266
265 section := t.S().Subtle.Render(
267 section := t.S().Subtle.Render(
266− core.Section("Modified Files", maxWidth),
268+ core.Section("Modified Files", m.getMaxWidth()),
267 )
269 )
268
270
269 files := make([]SessionFile, 0)
271 files := make([]SessionFile, 0)
@@ -304,7 +306,7 @@ func (m *sidebarCmp) filesBlock() string {
304 filePath := file.FilePath
306 filePath := file.FilePath
305 filePath = strings.TrimPrefix(filePath, cwd)
307 filePath = strings.TrimPrefix(filePath, cwd)
306 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
308 filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
307− filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
309+ filePath = ansi.Truncate(filePath, m.getMaxWidth()-lipgloss.Width(extraContent)-2, "…")
308 fileList = append(fileList,
310 fileList = append(fileList,
309 core.Status(
311 core.Status(
310 core.StatusOpts{
312 core.StatusOpts{
@@ -313,7 +315,7 @@ func (m *sidebarCmp) filesBlock() string {
313 Title: filePath,
315 Title: filePath,
314 ExtraContent: extraContent,
316 ExtraContent: extraContent,
315 },
317 },
316− m.width,
318+ m.getMaxWidth(),
317 ),
319 ),
318 )
320 )
319 }
321 }
@@ -325,11 +327,10 @@ func (m *sidebarCmp) filesBlock() string {
325}
327}
326
328
327func (m *sidebarCmp) lspBlock() string {
329func (m *sidebarCmp) lspBlock() string {
328− maxWidth := min(m.width, 58)
329 t := styles.CurrentTheme()
330 t := styles.CurrentTheme()
330
331
331 section := t.S().Subtle.Render(
332 section := t.S().Subtle.Render(
332− core.Section("LSPs", maxWidth),
333+ core.Section("LSPs", m.getMaxWidth()),
333 )
334 )
334
335
335 lspList := []string{section, ""}
336 lspList := []string{section, ""}
@@ -387,7 +388,7 @@ func (m *sidebarCmp) lspBlock() string {
387 Description: l.Command,
388 Description: l.Command,
388 ExtraContent: strings.Join(errs, " "),
389 ExtraContent: strings.Join(errs, " "),
389 },
390 },
390− m.width,
391+ m.getMaxWidth(),
391 ),
392 ),
392 )
393 )
393 }
394 }
@@ -399,11 +400,10 @@ func (m *sidebarCmp) lspBlock() string {
399}
400}
400
401
401func (m *sidebarCmp) mcpBlock() string {
402func (m *sidebarCmp) mcpBlock() string {
402− maxWidth := min(m.width, 58)
403 t := styles.CurrentTheme()
403 t := styles.CurrentTheme()
404
404
405 section := t.S().Subtle.Render(
405 section := t.S().Subtle.Render(
406− core.Section("MCPs", maxWidth),
406+ core.Section("MCPs", m.getMaxWidth()),
407 )
407 )
408
408
409 mcpList := []string{section, ""}
409 mcpList := []string{section, ""}
@@ -427,7 +427,7 @@ func (m *sidebarCmp) mcpBlock() string {
427 Title: n,
427 Title: n,
428 Description: l.Command,
428 Description: l.Command,
429 },
429 },
430− m.width,
430+ m.getMaxWidth(),
431 ),
431 ),
432 )
432 )
433 }
433 }
@@ -510,6 +510,11 @@ func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
510 return m.loadSessionFiles
510 return m.loadSessionFiles
511}
511}
512
512
513+// SetCompactMode sets the compact mode for the sidebar.
514+func (m *sidebarCmp) SetCompactMode(compact bool) {
515+ m.compactMode = compact
516+}
517+
513func cwd() string {
518func cwd() string {
514 cwd := config.Get().WorkingDir()
519 cwd := config.Get().WorkingDir()
515 t := styles.CurrentTheme()
520 t := styles.CurrentTheme()
internal/tui/components/chat/splash/splash.go+19−2
@@ -17,6 +17,11 @@ type Splash interface {
17 layout.Help
17 layout.Help
18}
18}
19
19
20+const (
21+ SplashScreenPaddingX = 2 // Padding X for the splash screen
22+ SplashScreenPaddingY = 1 // Padding Y for the splash screen
23+)
24+
20type splashCmp struct {
25type splashCmp struct {
21 width, height int
26 width, height int
22 keyMap KeyMap
27 keyMap KeyMap
@@ -61,8 +66,20 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61
66
62// View implements SplashPage.
67// View implements SplashPage.
63func (s *splashCmp) View() tea.View {
68func (s *splashCmp) View() tea.View {
69+ t := styles.CurrentTheme()
64 content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
70 content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
65− return tea.NewView(content)
71+ return tea.NewView(
72+ t.S().Base.
73+ Width(s.width).
74+ Height(s.height).
75+ PaddingTop(SplashScreenPaddingY).
76+ PaddingLeft(SplashScreenPaddingX).
77+ PaddingRight(SplashScreenPaddingX).
78+ PaddingBottom(SplashScreenPaddingY).
79+ Render(
80+ content,
81+ ),
82+ )
66}
83}
67
84
68func (s *splashCmp) logoBlock() string {
85func (s *splashCmp) logoBlock() string {
@@ -74,7 +91,7 @@ func (s *splashCmp) logoBlock() string {
74 TitleColorB: t.Primary,
91 TitleColorB: t.Primary,
75 CharmColor: t.Secondary,
92 CharmColor: t.Secondary,
76 VersionColor: t.Primary,
93 VersionColor: t.Primary,
77− Width: s.width - padding,
94+ Width: s.width - (SplashScreenPaddingX * 2),
78 })
95 })
79}
96}
80
97
internal/tui/components/core/layout/split.go−5
@@ -1,11 +1,8 @@
1package layout
1package layout
2
2
3import (
3import (
4− "log/slog"
5−
6 "github.com/charmbracelet/bubbles/v2/key"
4 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
5 tea "github.com/charmbracelet/bubbletea/v2"
8−
9 "github.com/charmbracelet/crush/internal/tui/styles"
6 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/crush/internal/tui/util"
7 "github.com/charmbracelet/crush/internal/tui/util"
11 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
@@ -156,8 +153,6 @@ func (s *splitPaneLayout) View() tea.View {
156func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
153func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
157 s.width = width
154 s.width = width
158 s.height = height
155 s.height = height
159− slog.Info("Setting split pane size", "width", width, "height", height)
160−
161 var topHeight, bottomHeight int
156 var topHeight, bottomHeight int
162 var cmds []tea.Cmd
157 var cmds []tea.Cmd
163 if s.bottomPanel != nil {
158 if s.bottomPanel != nil {
internal/tui/components/logo/logo.go+1−1
@@ -85,7 +85,7 @@ func Render(version string, compact bool, o Opts) string {
85 }
85 }
86
86
87 // Right field.
87 // Right field.
88− rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
88+ rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
89 const stepDownAt = 0
89 const stepDownAt = 0
90 rightField := new(strings.Builder)
90 rightField := new(strings.Builder)
91 for i := range fieldHeight {
91 for i := range fieldHeight {
internal/tui/page/chat/chat.go+381−263
@@ -2,21 +2,27 @@ package chat
2
2
3import (
3import (
4 "context"
4 "context"
5− "strings"
6 "time"
5 "time"
7
6
8 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
8+ "github.com/charmbracelet/bubbles/v2/spinner"
9 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/history"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/session"
16+ "github.com/charmbracelet/crush/internal/tui/components/anim"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
18 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
19 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
21+ "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
22+ "github.com/charmbracelet/crush/internal/tui/components/completions"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
25+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
20 "github.com/charmbracelet/crush/internal/tui/page"
26 "github.com/charmbracelet/crush/internal/tui/page"
21 "github.com/charmbracelet/crush/internal/tui/styles"
27 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
28 "github.com/charmbracelet/crush/internal/tui/util"
@@ -26,8 +32,6 @@ import (
26
32
27var ChatPageID page.PageID = "chat"
33var ChatPageID page.PageID = "chat"
28
34
29−const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
30−
31type (
35type (
32 OpenFilePickerMsg struct{}
36 OpenFilePickerMsg struct{}
33 ChatFocusedMsg struct {
37 ChatFocusedMsg struct {
@@ -36,104 +40,171 @@ type (
36 CancelTimerExpiredMsg struct{}
40 CancelTimerExpiredMsg struct{}
37)
41)
38
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+
52+type PanelType string
53+
54+const (
55+ PanelTypeChat PanelType = "chat"
56+ PanelTypeEditor PanelType = "editor"
57+ PanelTypeSplash PanelType = "splash"
58+)
59+
60+const (
61+ CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
62+ EditorHeight = 5 // Height of the editor input area including padding
63+ SideBarWidth = 31 // Width of the sidebar
64+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
65+ HeaderHeight = 1 // Height of the header
66+)
67+
39type ChatPage interface {
68type ChatPage interface {
40 util.Model
69 util.Model
41 layout.Help
70 layout.Help
42}
71}
43
72
44−type chatPage struct {
73+// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
45− wWidth, wHeight int // Window dimensions
74+func cancelTimerCmd() tea.Cmd {
46− app *app.App
75+ return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
47−
76+ return CancelTimerExpiredMsg{}
48− layout layout.SplitPaneLayout
77+ })
49−
78+}
50− session session.Session
51
79
80+type chatPage struct {
81+ width, height int
82+ detailsWidth, detailsHeight int
83+ app *app.App
84+ state ChatState
85+ session session.Session
52− keyMap KeyMap
86+ keyMap KeyMap
87+ focusedPane PanelType
88+ // Compact mode
89+ 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
103+}
53
104
54− chatFocused bool
105+func New(app *app.App) ChatPage {
106+ return &chatPage{
107+ app: app,
108+ state: ChatStateOnboarding,
55
109
56− compactMode bool
110+ keyMap: DefaultKeyMap(),
57− forceCompactMode bool // Force compact mode regardless of window size
58− showDetails bool // Show details in the header
59− header header.Header
60− compactSidebar layout.Container
61
111
62− cancelPending bool // True if ESC was pressed once and waiting for second press
112+ header: header.New(app.LSPClients),
113+ sidebar: sidebar.New(app.History, app.LSPClients, false),
114+ chat: chat.New(app),
115+ editor: editor.New(app),
116+ splash: splash.New(),
117+ }
63}
118}
64
119
65func (p *chatPage) Init() tea.Cmd {
120func (p *chatPage) Init() tea.Cmd {
121+ cfg := config.Get()
122+ if cfg.IsReady() {
123+ if b, _ := config.ProjectNeedsInitialization(); b {
124+ p.state = ChatStateInitProject
125+ } else {
126+ p.state = ChatStateNewMessage
127+ p.focusedPane = PanelTypeEditor
128+ }
129+ }
130+
131+ compact := cfg.Options.TUI.CompactMode
132+ p.compact = compact
133+ p.forceCompact = compact
134+ p.sidebar.SetCompactMode(p.compact)
66 return tea.Batch(
135 return tea.Batch(
67− p.layout.Init(),
136+ p.header.Init(),
68− p.compactSidebar.Init(),
137+ p.sidebar.Init(),
69− p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
138+ p.chat.Init(),
139+ p.editor.Init(),
140+ p.splash.Init(),
70 )
141 )
71}
142}
72
143
73−// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
74−func (p *chatPage) cancelTimerCmd() tea.Cmd {
75− return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
76− return CancelTimerExpiredMsg{}
77− })
78−}
79−
80func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81 var cmds []tea.Cmd
145 var cmds []tea.Cmd
82 switch msg := msg.(type) {
146 switch msg := msg.(type) {
147+ case tea.WindowSizeMsg:
148+ return p, p.SetSize(msg.Width, msg.Height)
83 case CancelTimerExpiredMsg:
149 case CancelTimerExpiredMsg:
84− p.cancelPending = false
150+ p.canceling = false
85 return p, nil
151 return p, nil
86− case tea.WindowSizeMsg:
152+ case chat.SendMsg:
87− h, cmd := p.header.Update(msg)
153+ return p, p.sendMessage(msg.Text, msg.Attachments)
88− cmds = append(cmds, cmd)
154+ case chat.SessionSelectedMsg:
89− p.header = h.(header.Header)
155+ return p, p.setSession(msg)
90− cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
156+ case commands.ToggleCompactModeMsg:
91− // the mode is only relevant when there is a session
157+ p.forceCompact = !p.forceCompact
92− if p.session.ID != "" {
158+ if p.forceCompact {
93− // Only auto-switch to compact mode if not forced
94− if !p.forceCompactMode {
95− if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
96− p.wWidth = msg.Width
97− p.wHeight = msg.Height
98− cmds = append(cmds, p.setCompactMode(true))
159+ p.setCompactMode(true)
99− return p, tea.Batch(cmds...)
100− } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
160+ } else if p.width >= CompactModeBreakpoint {
101− p.wWidth = msg.Width
102− p.wHeight = msg.Height
103− return p, p.setCompactMode(false)
161+ p.setCompactMode(false)
104− }
105− }
106− }
107− p.wWidth = msg.Width
108− p.wHeight = msg.Height
109− layoutHeight := msg.Height
110− if p.compactMode {
111− // make space for the header
112− layoutHeight -= 1
113 }
162 }
114− cmd = p.layout.SetSize(msg.Width, layoutHeight)
163+ return p, p.SetSize(p.width, p.height)
164+ case pubsub.Event[session.Session]:
165+ // this needs to go to header/sidebar
166+ u, cmd := p.header.Update(msg)
167+ p.header = u.(header.Header)
168+ cmds = append(cmds, cmd)
169+ u, cmd = p.sidebar.Update(msg)
170+ p.sidebar = u.(sidebar.Sidebar)
171+ cmds = append(cmds, cmd)
172+ return p, tea.Batch(cmds...)
173+ case chat.SessionClearedMsg:
174+ u, cmd := p.header.Update(msg)
175+ p.header = u.(header.Header)
176+ cmds = append(cmds, cmd)
177+ u, cmd = p.sidebar.Update(msg)
178+ p.sidebar = u.(sidebar.Sidebar)
179+ cmds = append(cmds, cmd)
180+ u, cmd = p.chat.Update(msg)
181+ p.chat = u.(chat.MessageListCmp)
182+ cmds = append(cmds, cmd)
183+ return p, tea.Batch(cmds...)
184+ case filepicker.FilePickedMsg,
185+ completions.CompletionsClosedMsg,
186+ completions.SelectCompletionMsg:
187+ u, cmd := p.editor.Update(msg)
188+ p.editor = u.(editor.Editor)
189+ cmds = append(cmds, cmd)
190+ return p, tea.Batch(cmds...)
191+
192+ case pubsub.Event[message.Message],
193+ anim.StepMsg,
194+ spinner.TickMsg:
195+ // this needs to go to chat
196+ u, cmd := p.chat.Update(msg)
197+ p.chat = u.(chat.MessageListCmp)
198+ cmds = append(cmds, cmd)
199+ return p, tea.Batch(cmds...)
200+
201+ case pubsub.Event[history.File], sidebar.SessionFilesMsg:
202+ // this needs to go to sidebar
203+ u, cmd := p.sidebar.Update(msg)
204+ p.sidebar = u.(sidebar.Sidebar)
115 cmds = append(cmds, cmd)
205 cmds = append(cmds, cmd)
116 return p, tea.Batch(cmds...)
206 return p, tea.Batch(cmds...)
117
207
118− case chat.SendMsg:
119− cmd := p.sendMessage(msg.Text, msg.Attachments)
120− if cmd != nil {
121− return p, cmd
122− }
123− case commands.ToggleCompactModeMsg:
124− // Only allow toggling if window width is larger than compact breakpoint
125− if p.wWidth > CompactModeBreakpoint {
126− p.forceCompactMode = !p.forceCompactMode
127− // If force compact mode is enabled, switch to compact mode
128− // If force compact mode is disabled, switch based on window size
129− if p.forceCompactMode {
130− return p, p.setCompactMode(true)
131− } else {
132− // Return to auto mode based on window size
133− shouldBeCompact := p.wWidth <= CompactModeBreakpoint
134− return p, p.setCompactMode(shouldBeCompact)
135− }
136− }
137 case commands.CommandRunCustomMsg:
208 case commands.CommandRunCustomMsg:
138 // Check if the agent is busy before executing custom commands
209 // Check if the agent is busy before executing custom commands
139 if p.app.CoderAgent.IsBusy() {
210 if p.app.CoderAgent.IsBusy() {
@@ -145,30 +216,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 if cmd != nil {
216 if cmd != nil {
146 return p, cmd
217 return p, cmd
147 }
218 }
148− case chat.SessionSelectedMsg:
149− if p.session.ID == "" {
150− cmd := p.setMessages()
151− if cmd != nil {
152− cmds = append(cmds, cmd)
153− }
154− }
155− needsModeChange := p.session.ID == ""
156− p.session = msg
157− p.header.SetSession(msg)
158− if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
159− cmds = append(cmds, p.setCompactMode(true))
160− }
161 case tea.KeyPressMsg:
219 case tea.KeyPressMsg:
162 switch {
220 switch {
163 case key.Matches(msg, p.keyMap.NewSession):
221 case key.Matches(msg, p.keyMap.NewSession):
164− p.session = session.Session{}
165− return p, tea.Batch(
222+ return p, p.newSession()
166− p.clearMessages(),
167− util.CmdHandler(chat.SessionClearedMsg{}),
168− p.setCompactMode(false),
169− p.layout.FocusPanel(layout.BottomPanel),
170− util.CmdHandler(ChatFocusedMsg{Focused: false}),
171− )
172 case key.Matches(msg, p.keyMap.AddAttachment):
223 case key.Matches(msg, p.keyMap.AddAttachment):
173 agentCfg := config.Get().Agents["coder"]
224 agentCfg := config.Get().Agents["coder"]
174 model := config.Get().GetModelByType(agentCfg.Model)
225 model := config.Get().GetModelByType(agentCfg.Model)
@@ -178,167 +229,264 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
178 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
229 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
179 }
230 }
180 case key.Matches(msg, p.keyMap.Tab):
231 case key.Matches(msg, p.keyMap.Tab):
181− if p.session.ID == "" {
232+ p.changeFocus()
182− return p, nil
233+ return p, nil
183− }
184− p.chatFocused = !p.chatFocused
185− if p.chatFocused {
186− cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
187− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
188− } else {
189− cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
190− cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
191− }
192− return p, tea.Batch(cmds...)
193 case key.Matches(msg, p.keyMap.Cancel):
234 case key.Matches(msg, p.keyMap.Cancel):
194− if p.session.ID != "" {
235+ return p, p.cancel()
195− if p.cancelPending {
196− // Second ESC press - actually cancel the session
197− p.cancelPending = false
198− p.app.CoderAgent.Cancel(p.session.ID)
199− return p, nil
200− } else {
201− // First ESC press - start the timer
202− p.cancelPending = true
203− return p, p.cancelTimerCmd()
204− }
205− }
206 case key.Matches(msg, p.keyMap.Details):
236 case key.Matches(msg, p.keyMap.Details):
207− if p.session.ID == "" || !p.compactMode {
208− return p, nil // No session to show details for
209− }
210− p.showDetails = !p.showDetails
237+ p.showDetails()
211− p.header.SetDetailsOpen(p.showDetails)
212− if p.showDetails {
213− return p, tea.Batch()
214− }
215−
216 return p, nil
238 return p, nil
217 }
239 }
240+
241+ // Send the key press to the focused pane
242+ switch p.focusedPane {
243+ case PanelTypeChat:
244+ u, cmd := p.chat.Update(msg)
245+ p.chat = u.(chat.MessageListCmp)
246+ cmds = append(cmds, cmd)
247+ case PanelTypeEditor:
248+ u, cmd := p.editor.Update(msg)
249+ p.editor = u.(editor.Editor)
250+ cmds = append(cmds, cmd)
251+ }
218 }
252 }
219− u, cmd := p.layout.Update(msg)
220− cmds = append(cmds, cmd)
221− p.layout = u.(layout.SplitPaneLayout)
222− h, cmd := p.header.Update(msg)
223− p.header = h.(header.Header)
224− cmds = append(cmds, cmd)
225− s, cmd := p.compactSidebar.Update(msg)
226− p.compactSidebar = s.(layout.Container)
227− cmds = append(cmds, cmd)
228 return p, tea.Batch(cmds...)
253 return p, tea.Batch(cmds...)
229}
254}
230
255
231−func (p *chatPage) setMessages() tea.Cmd {
256+func (p *chatPage) View() tea.View {
232− messagesContainer := layout.NewContainer(
257+ var chatView tea.View
233− chat.NewMessagesListCmp(p.app),
258+ t := styles.CurrentTheme()
234− layout.WithPadding(1, 1, 0, 1),
259+ switch p.state {
235− )
260+ case ChatStateOnboarding, ChatStateInitProject:
236− return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
261+ chatView = tea.NewView(
262+ t.S().Base.Render(
263+ p.splash.View().String(),
264+ ),
265+ )
266+ case ChatStateNewMessage:
267+ editorView := p.editor.View()
268+ chatView = tea.NewView(
269+ lipgloss.JoinVertical(
270+ lipgloss.Left,
271+ t.S().Base.Render(
272+ p.splash.View().String(),
273+ ),
274+ editorView.String(),
275+ ),
276+ )
277+ chatView.SetCursor(editorView.Cursor())
278+ case ChatStateInSession:
279+ messagesView := p.chat.View()
280+ editorView := p.editor.View()
281+ if p.compact {
282+ headerView := p.header.View()
283+ chatView = tea.NewView(
284+ lipgloss.JoinVertical(
285+ lipgloss.Left,
286+ headerView.String(),
287+ messagesView.String(),
288+ editorView.String(),
289+ ),
290+ )
291+ chatView.SetCursor(editorView.Cursor())
292+ } else {
293+ sidebarView := p.sidebar.View()
294+ messages := lipgloss.JoinHorizontal(
295+ lipgloss.Left,
296+ messagesView.String(),
297+ sidebarView.String(),
298+ )
299+ chatView = tea.NewView(
300+ lipgloss.JoinVertical(
301+ lipgloss.Left,
302+ messages,
303+ p.editor.View().String(),
304+ ),
305+ )
306+ chatView.SetCursor(editorView.Cursor())
307+ }
308+ default:
309+ chatView = tea.NewView("Unknown chat state")
237−}
310+ }
238
311
239−func (p *chatPage) setSidebar() tea.Cmd {
312+ layers := []*lipgloss.Layer{
240− sidebarContainer := sidebarCmp(p.app, false, p.session)
313+ lipgloss.NewLayer(chatView.String()).X(0).Y(0),
241− sidebarContainer.Init()
242− return p.layout.SetRightPanel(sidebarContainer)
243−}
314+ }
244
315
245−func (p *chatPage) clearMessages() tea.Cmd {
316+ if p.showingDetails {
246− return p.layout.ClearLeftPanel()
317+ style := t.S().Base.
318+ Width(p.detailsWidth).
319+ Border(lipgloss.RoundedBorder()).
320+ BorderForeground(t.BorderFocus)
321+ version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
322+ details := style.Render(
323+ lipgloss.JoinVertical(
324+ lipgloss.Left,
325+ p.sidebar.View().String(),
326+ version,
327+ ),
328+ )
329+ layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
330+ }
331+ canvas := lipgloss.NewCanvas(
332+ layers...,
333+ )
334+ view := tea.NewView(canvas.Render())
335+ view.SetCursor(chatView.Cursor())
336+ return view
247}
337}
248
338
249−func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
339+func (p *chatPage) setCompactMode(compact bool) {
340+ if p.compact == compact {
341+ return
342+ }
250− p.compactMode = compact
343+ p.compact = compact
251− var cmds []tea.Cmd
252 if compact {
344 if compact {
253− // add offset for the header
345+ p.compact = true
254− p.layout.SetOffset(0, 1)
346+ p.sidebar.SetCompactMode(true)
255− // make space for the header
256− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
257− // remove the sidebar
258− cmds = append(cmds, p.layout.ClearRightPanel())
259− return tea.Batch(cmds...)
260 } else {
347 } else {
261− // remove the offset for the header
348+ p.compact = false
262− p.layout.SetOffset(0, 0)
349+ p.showingDetails = false
263− // restore the original size
350+ p.sidebar.SetCompactMode(false)
264− cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
351+ }
265− // set the sidebar
352+}
266− cmds = append(cmds, p.setSidebar())
267− l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
268− p.layout = l.(layout.SplitPaneLayout)
269− cmds = append(cmds, cmd)
270
353
354+func (p *chatPage) handleCompactMode(newWidth int) {
355+ if p.forceCompact {
271− return tea.Batch(cmds...)
356+ return
357+ }
358+ if newWidth < CompactModeBreakpoint && !p.compact {
359+ p.setCompactMode(true)
360+ }
361+ if newWidth >= CompactModeBreakpoint && p.compact {
362+ p.setCompactMode(false)
272 }
363 }
273}
364}
274
365
275−func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
366+func (p *chatPage) SetSize(width, height int) tea.Cmd {
367+ p.handleCompactMode(width)
368+ p.width = width
369+ p.height = height
276 var cmds []tea.Cmd
370 var cmds []tea.Cmd
277− if p.session.ID == "" {
371+ switch p.state {
278− session, err := p.app.Sessions.Create(context.Background(), "New Session")
372+ case ChatStateOnboarding, ChatStateInitProject:
279− if err != nil {
373+ // here we should just have the splash screen
280− return util.ReportError(err)
374+ cmds = append(cmds, p.splash.SetSize(width, height))
375+ case ChatStateNewMessage:
376+ cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
377+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
378+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
379+ case ChatStateInSession:
380+ if p.compact {
381+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
382+ // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
383+ p.detailsWidth = width - 2 // because of position
384+ cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
385+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
386+ cmds = append(cmds, p.header.SetWidth(width-1))
387+ } else {
388+ cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
389+ cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
390+ cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
281 }
391 }
392+ cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
393+ }
394+ return tea.Batch(cmds...)
395+}
282
396
283− p.session = session
397+func (p *chatPage) newSession() tea.Cmd {
284− cmd := p.setMessages()
398+ if p.state != ChatStateInSession {
285− if cmd != nil {
399+ // Cannot start a new session if we are not in the session state
286− cmds = append(cmds, cmd)
400+ return nil
287− }
288− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
289 }
401 }
402+ // blank session
403+ p.session = session.Session{}
404+ p.state = ChatStateNewMessage
405+ p.focusedPane = PanelTypeEditor
406+ p.canceling = false
407+ // Reset the chat and editor components
408+ return tea.Batch(
409+ util.CmdHandler(chat.SessionClearedMsg{}),
410+ p.SetSize(p.width, p.height),
411+ )
412+}
290
413
291− _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
414+func (p *chatPage) setSession(session session.Session) tea.Cmd {
292− if err != nil {
415+ if p.session.ID == session.ID {
293− return util.ReportError(err)
416+ return nil
294 }
417 }
418+
419+ var cmds []tea.Cmd
420+ p.session = session
421+ // We want to first resize the components
422+ if p.state != ChatStateInSession {
423+ p.state = ChatStateInSession
424+ cmds = append(cmds, p.SetSize(p.width, p.height))
425+ }
426+ cmds = append(cmds, p.chat.SetSession(session))
427+ cmds = append(cmds, p.sidebar.SetSession(session))
428+ cmds = append(cmds, p.header.SetSession(session))
429+ cmds = append(cmds, p.editor.SetSession(session))
430+
295− return tea.Batch(cmds...)
431+ return tea.Sequence(cmds...)
296}
432}
297
433
298−func (p *chatPage) SetSize(width, height int) tea.Cmd {
434+func (p *chatPage) changeFocus() {
299− return p.layout.SetSize(width, height)
435+ if p.state != ChatStateInSession {
436+ // Cannot change focus if we are not in the session state
437+ return
438+ }
439+ switch p.focusedPane {
440+ case PanelTypeChat:
441+ p.focusedPane = PanelTypeEditor
442+ case PanelTypeEditor:
443+ p.focusedPane = PanelTypeChat
444+ }
300}
445}
301
446
302−func (p *chatPage) GetSize() (int, int) {
447+func (p *chatPage) cancel() tea.Cmd {
303− return p.layout.GetSize()
448+ if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
449+ // Cannot cancel if we are not in the session state
450+ return nil
451+ }
452+
453+ // second press of cancel key will actually cancel the session
454+ if p.canceling {
455+ p.canceling = false
456+ p.app.CoderAgent.Cancel(p.session.ID)
457+ return nil
458+ }
459+
460+ p.canceling = true
461+ return cancelTimerCmd()
304}
462}
305
463
306−func (p *chatPage) View() tea.View {
464+func (p *chatPage) showDetails() {
307− if !p.compactMode || p.session.ID == "" {
465+ if p.state != ChatStateInSession || !p.compact {
308− // If not in compact mode or there is no session, we don't show the header
466+ // Cannot show details if we are not in the session state or if we are not in compact mode
309− return p.layout.View()
467+ return
310 }
468 }
311− layoutView := p.layout.View()
469+ p.showingDetails = !p.showingDetails
312− chatView := strings.Join(
470+ p.header.SetDetailsOpen(p.showingDetails)
313− []string{
471+}
314− p.header.View().String(),
472+
315− layoutView.String(),
473+func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
316− }, "\n",
474+ session := p.session
317− )
475+ var cmds []tea.Cmd
318− layers := []*lipgloss.Layer{
476+ if p.state != ChatStateInSession {
319− lipgloss.NewLayer(chatView).X(0).Y(0),
477+ // branch new session
478+ newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
479+ if err != nil {
480+ return util.ReportError(err)
481+ }
482+ session = newSession
483+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
320 }
484 }
321− if p.showDetails {
485+ _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
322− t := styles.CurrentTheme()
486+ if err != nil {
323− style := t.S().Base.
487+ return util.ReportError(err)
324− Border(lipgloss.RoundedBorder()).
325− BorderForeground(t.BorderFocus)
326− version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
327− details := style.Render(
328− lipgloss.JoinVertical(
329− lipgloss.Left,
330− p.compactSidebar.View().String(),
331− version,
332− ),
333− )
334− layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
335 }
488 }
336− canvas := lipgloss.NewCanvas(
489+ return tea.Batch(cmds...)
337− layers...,
338− )
339− view := tea.NewView(canvas.Render())
340− view.SetCursor(layoutView.Cursor())
341− return view
342}
490}
343
491
344func (p *chatPage) Bindings() []key.Binding {
492func (p *chatPage) Bindings() []key.Binding {
@@ -348,7 +496,7 @@ func (p *chatPage) Bindings() []key.Binding {
348 }
496 }
349 if p.app.CoderAgent.IsBusy() {
497 if p.app.CoderAgent.IsBusy() {
350 cancelBinding := p.keyMap.Cancel
498 cancelBinding := p.keyMap.Cancel
351− if p.cancelPending {
499+ if p.canceling {
352 cancelBinding = key.NewBinding(
500 cancelBinding = key.NewBinding(
353 key.WithKeys("esc"),
501 key.WithKeys("esc"),
354 key.WithHelp("esc", "press again to cancel"),
502 key.WithHelp("esc", "press again to cancel"),
@@ -357,56 +505,26 @@ func (p *chatPage) Bindings() []key.Binding {
357 bindings = append([]key.Binding{cancelBinding}, bindings...)
505 bindings = append([]key.Binding{cancelBinding}, bindings...)
358 }
506 }
359
507
360− if p.chatFocused {
508+ switch p.focusedPane {
509+ case PanelTypeChat:
361 bindings = append([]key.Binding{
510 bindings = append([]key.Binding{
362 key.NewBinding(
511 key.NewBinding(
363 key.WithKeys("tab"),
512 key.WithKeys("tab"),
364 key.WithHelp("tab", "focus editor"),
513 key.WithHelp("tab", "focus editor"),
365 ),
514 ),
366 }, bindings...)
515 }, bindings...)
367− } else {
516+ bindings = append(bindings, p.chat.Bindings()...)
517+ case PanelTypeEditor:
368 bindings = append([]key.Binding{
518 bindings = append([]key.Binding{
369 key.NewBinding(
519 key.NewBinding(
370 key.WithKeys("tab"),
520 key.WithKeys("tab"),
371 key.WithHelp("tab", "focus chat"),
521 key.WithHelp("tab", "focus chat"),
372 ),
522 ),
373 }, bindings...)
523 }, bindings...)
524+ bindings = append(bindings, p.editor.Bindings()...)
525+ case PanelTypeSplash:
526+ bindings = append(bindings, p.splash.Bindings()...)
374 }
527 }
375
528
376− bindings = append(bindings, p.layout.Bindings()...)
377 return bindings
529 return bindings
378}
530}
379−
380−func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
381− padding := layout.WithPadding(1, 1, 1, 1)
382− if compact {
383− padding = layout.WithPadding(0, 1, 1, 1)
384− }
385− sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
386− if session.ID != "" {
387− sidebar.SetSession(session)
388− }
389−
390− return layout.NewContainer(
391− sidebar,
392− padding,
393− )
394−}
395−
396−func NewChatPage(app *app.App) ChatPage {
397− editorContainer := layout.NewContainer(
398− editor.NewEditorCmp(app),
399− )
400− return &chatPage{
401− app: app,
402− layout: layout.NewSplitPane(
403− layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
404− layout.WithBottomPanel(editorContainer),
405− layout.WithFixedBottomHeight(5),
406− layout.WithFixedRightWidth(31),
407− ),
408− compactSidebar: sidebarCmp(app, true, session.Session{}),
409− keyMap: DefaultKeyMap(),
410− header: header.New(app.LSPClients),
411− }
412−}
internal/tui/tui.go+1−1
@@ -419,7 +419,7 @@ func (a *appModel) View() tea.View {
419
419
420// New creates and initializes a new TUI application model.
420// New creates and initializes a new TUI application model.
421func New(app *app.App) tea.Model {
421func New(app *app.App) tea.Model {
422− chatPage := chat.NewChatPage(app)
422+ chatPage := chat.New(app)
423 keyMap := DefaultKeyMap()
423 keyMap := DefaultKeyMap()
424 keyMap.pageBindings = chatPage.Bindings()
424 keyMap.pageBindings = chatPage.Bindings()
425
425