@@ -1,6 +1,7 @@
1package model
1package model
5 "math/rand"
6 "math/rand"
@@ -15,6 +16,7 @@ import (
15 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/pubsub"
20 "github.com/charmbracelet/crush/internal/pubsub"
19 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/ui/common"
22 "github.com/charmbracelet/crush/internal/ui/common"
@@ -48,10 +50,19 @@ const (
48 uiChatCompact
50 uiChatCompact
53+type sessionLoadedMsg struct {
57+type sessionFilesLoadedMsg struct {
51// UI represents the main user interface model.
61// UI represents the main user interface model.
52type UI struct {
62type UI struct {
53− com *common.Common
63+ com *common.Common
54− sess *session.Session
64+ session *session.Session
65+ sessionFiles []SessionFile
56 // The width and height of the terminal in cells.
67 // The width and height of the terminal in cells.
@@ -65,7 +76,6 @@ type UI struct {
65 keyenh tea.KeyboardEnhancementsMsg
76 keyenh tea.KeyboardEnhancementsMsg
67 chat *ChatModel
78 chat *ChatModel
69 dialog *dialog.Overlay
79 dialog *dialog.Overlay
70 help help.Model
80 help help.Model
@@ -98,6 +108,9 @@ type UI struct {
100 mcpStates map[string]mcp.ClientInfo
110 mcpStates map[string]mcp.ClientInfo
112+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
103// New creates a new instance of the [UI] model.
116// New creates a new instance of the [UI] model.
@@ -114,7 +127,6 @@ func New(com *common.Common) *UI {
114 com: com,
127 com: com,
115 dialog: dialog.NewOverlay(),
128 dialog: dialog.NewOverlay(),
116 keyMap: DefaultKeyMap(),
129 keyMap: DefaultKeyMap(),
117− side: NewSidebarModel(com),
118 help: help.New(),
130 help: help.New(),
119 focus: uiFocusNone,
131 focus: uiFocusNone,
120 state: uiConfigure,
132 state: uiConfigure,
@@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161 if !m.sendProgressBar {
173 if !m.sendProgressBar {
162 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
174 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
176+ case sessionLoadedMsg:
178+ m.session = &msg.sess
179+ case sessionFilesLoadedMsg:
180+ m.sessionFiles = msg.files
181+ case pubsub.Event[history.File]:
182+ cmds = append(cmds, m.handleFileEvent(msg.Payload))
164 case pubsub.Event[app.LSPEvent]:
183 case pubsub.Event[app.LSPEvent]:
165 m.lspStates = app.GetLSPStates()
184 m.lspStates = app.GetLSPStates()
166 case pubsub.Event[mcp.Event]:
185 case pubsub.Event[mcp.Event]:
@@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203 return m, tea.Batch(cmds...)
222 return m, tea.Batch(cmds...)
225+func (m *UI) loadSession(sessionID string) tea.Cmd {
226+ return func() tea.Msg {
227+ // TODO: handle error
228+ session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
229+ return sessionLoadedMsg{session}
206func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
233func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207 if m.dialog.HasDialogs() {
234 if m.dialog.HasDialogs() {
208 return m.updateDialogs(msg)
235 return m.updateDialogs(msg)
@@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
249func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
276func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
250 layout := generateLayout(m, area.Dx(), area.Dy())
277 layout := generateLayout(m, area.Dx(), area.Dy())
279+ // Update cached layout and component sizes if needed.
280+ if m.layout != layout {
252 // Clear the screen first
285 // Clear the screen first
253 screen.Clear(scr)
286 screen.Clear(scr)
@@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
283 case uiChat:
316 case uiChat:
284 header := uv.NewStyledString(m.header)
317 header := uv.NewStyledString(m.header)
285 header.Draw(scr, layout.header)
318 header.Draw(scr, layout.header)
287− side := uv.NewStyledString(m.side.View())
288− side.Draw(scr, layout.sidebar)
319+ m.drawSidebar(scr, layout.sidebar)
290 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
320 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
291 Height(layout.main.Dy()).
321 Height(layout.main.Dy()).
292− Background(lipgloss.ANSIColor(rand.Intn(256))).
293 Render(" Chat Messages ")
322 Render(" Chat Messages ")
294 main := uv.NewStyledString(mainView)
323 main := uv.NewStyledString(mainView)
295 main.Draw(scr, layout.main)
324 main.Draw(scr, layout.main)
@@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding {
385 binds = append(binds, k.Quit)
414 binds = append(binds, k.Quit)
387 // TODO: other states
416 // TODO: other states
388− if m.sess == nil {
417+ // if m.session == nil {
389− // no session selected
418+ // no session selected
390− binds = append(binds,
419+ binds = append(binds,
391− k.Commands,
420+ k.Commands,
392− k.Models,
421+ k.Models,
393− k.Editor.Newline,
422+ k.Editor.Newline,
398 // else {
427 // else {
399 // we have a session
428 // we have a session
@@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding {
439− if m.sess == nil {
468+ if m.session == nil {
440 // no session selected
469 // no session selected
441 binds = append(binds,
470 binds = append(binds,
442 []key.Binding{
471 []key.Binding{
@@ -535,8 +564,7 @@ func (m *UI) updateSize() {
535 m.textarea.SetHeight(m.layout.editor.Dy())
564 m.textarea.SetHeight(m.layout.editor.Dy())
537 case uiChat:
566 case uiChat:
538− // TODO: set the width and heigh of the chat component
539− m.side.SetWidth(m.layout.sidebar.Dx())
567+ m.renderSidebarLogo(m.layout.sidebar.Dx())
540 m.textarea.SetWidth(m.layout.editor.Dx())
568 m.textarea.SetWidth(m.layout.editor.Dx())
541 m.textarea.SetHeight(m.layout.editor.Dy())
569 m.textarea.SetHeight(m.layout.editor.Dy())
@@ -548,7 +576,8 @@ func (m *UI) updateSize() {
551−// generateLayout generates a [layout] for the given rectangle.
579+// generateLayout calculates the layout rectangles for all UI components based
580+// on the current UI state and terminal dimensions.
552func generateLayout(m *UI, w, h int) layout {
581func generateLayout(m *UI, w, h int) layout {
553 // The screen area we're working with
582 // The screen area we're working with
554 area := image.Rect(0, 0, w, h)
583 area := image.Rect(0, 0, w, h)
@@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout {
558 // The editor height
587 // The editor height
559 editorHeight := 5
588 editorHeight := 5
560 // The sidebar width
589 // The sidebar width
561− sidebarWidth := 40
590+ sidebarWidth := 30
562 // The header height
591 // The header height
563 // TODO: handle compact
592 // TODO: handle compact
564 headerHeight := 4
593 headerHeight := 4
@@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout {
637 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
666 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
638 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
669 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
639 layout.sidebar = sideRect
670 layout.sidebar = sideRect
640 layout.main = mainRect
671 layout.main = mainRect
@@ -690,6 +721,8 @@ type layout struct {
690 help uv.Rectangle
721 help uv.Rectangle
724+// setEditorPrompt configures the textarea prompt function based on whether
725+// yolo mode is enabled.
693func (m *UI) setEditorPrompt() {
726func (m *UI) setEditorPrompt() {
694 if m.com.App.Permissions.SkipRequests() {
727 if m.com.App.Permissions.SkipRequests() {
695 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
728 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() {
698 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
731 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
734+// normalPromptFunc returns the normal editor prompt style (" > " on first
735+// line, "::: " on subsequent lines).
701func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
736func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
702 t := m.com.Styles
737 t := m.com.Styles
703 if info.LineNumber == 0 {
738 if info.LineNumber == 0 {
@@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
709 return t.EditorPromptNormalBlurred.Render()
744 return t.EditorPromptNormalBlurred.Render()
747+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
712func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
749func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
713 t := m.com.Styles
750 t := m.com.Styles
714 if info.LineNumber == 0 {
751 if info.LineNumber == 0 {
@@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{
740 "Thinking...",
777 "Thinking...",
780+// randomizePlaceholders selects random placeholder text for the textarea's
781+// ready and working states.
743func (m *UI) randomizePlaceholders() {
782func (m *UI) randomizePlaceholders() {
744 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
783 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
784 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
787+// renderHeader renders and caches the header logo at the specified width.
748func (m *UI) renderHeader(compact bool, width int) {
788func (m *UI) renderHeader(compact bool, width int) {
749 // TODO: handle the compact case differently
789 // TODO: handle the compact case differently
750 m.header = renderLogo(m.com.Styles, compact, width)
790 m.header = renderLogo(m.com.Styles, compact, width)
793+// renderSidebarLogo renders and caches the sidebar logo at the specified
795+func (m *UI) renderSidebarLogo(width int) {
796+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
799+// renderLogo renders the Crush logo with the given styles and dimensions.
753func renderLogo(t *styles.Styles, compact bool, width int) string {
800func renderLogo(t *styles.Styles, compact bool, width int) string {
754 return logo.Render(version.Version, compact, logo.Opts{
801 return logo.Render(version.Version, compact, logo.Opts{
755 FieldColor: t.LogoFieldColor,
802 FieldColor: t.LogoFieldColor,
@@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
757 TitleColorB: t.LogoTitleColorB,
804 TitleColorB: t.LogoTitleColorB,
758 CharmColor: t.LogoCharmColor,
805 CharmColor: t.LogoCharmColor,
759 VersionColor: t.LogoVersionColor,
806 VersionColor: t.LogoVersionColor,
760− Width: max(0, width-2),
807+ Width: width,