@@ -1,6 +1,7 @@
11package model
22
33import (
4+ "context"
45 "image"
56 "math/rand"
67 "os"
@@ -15,6 +16,7 @@ import (
1516 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1617 "github.com/charmbracelet/crush/internal/app"
1718 "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/history"
1820 "github.com/charmbracelet/crush/internal/pubsub"
1921 "github.com/charmbracelet/crush/internal/session"
2022 "github.com/charmbracelet/crush/internal/ui/common"
@@ -48,10 +50,19 @@ const (
4850 uiChatCompact
4951)
5052
53+type sessionLoadedMsg struct {
54+ sess session.Session
55+}
56+
57+type sessionFilesLoadedMsg struct {
58+ files []SessionFile
59+}
60+
5161// UI represents the main user interface model.
5262type UI struct {
53− com *common.Common
54− sess *session.Session
63+ com *common.Common
64+ session *session.Session
65+ sessionFiles []SessionFile
5566
5667 // The width and height of the terminal in cells.
5768 width int
@@ -65,7 +76,6 @@ type UI struct {
6576 keyenh tea.KeyboardEnhancementsMsg
6677
6778 chat *ChatModel
68− side *SidebarModel
6979 dialog *dialog.Overlay
7080 help help.Model
7181
@@ -98,6 +108,9 @@ type UI struct {
98108
99109 // mcp
100110 mcpStates map[string]mcp.ClientInfo
111+
112+ // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
113+ sidebarLogo string
101114}
102115
103116// New creates a new instance of the [UI] model.
@@ -114,7 +127,6 @@ func New(com *common.Common) *UI {
114127 com: com,
115128 dialog: dialog.NewOverlay(),
116129 keyMap: DefaultKeyMap(),
117− side: NewSidebarModel(com),
118130 help: help.New(),
119131 focus: uiFocusNone,
120132 state: uiConfigure,
@@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161173 if !m.sendProgressBar {
162174 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
163175 }
176+ case sessionLoadedMsg:
177+ m.state = uiChat
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))
164183 case pubsub.Event[app.LSPEvent]:
165184 m.lspStates = app.GetLSPStates()
166185 case pubsub.Event[mcp.Event]:
@@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203222 return m, tea.Batch(cmds...)
204223}
205224
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}
230+ }
231+}
232+
206233func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207234 if m.dialog.HasDialogs() {
208235 return m.updateDialogs(msg)
@@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
249276func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
250277 layout := generateLayout(m, area.Dx(), area.Dy())
251278
279+ // Update cached layout and component sizes if needed.
280+ if m.layout != layout {
281+ m.layout = layout
282+ m.updateSize()
283+ }
284+
252285 // Clear the screen first
253286 screen.Clear(scr)
254287
@@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
283316 case uiChat:
284317 header := uv.NewStyledString(m.header)
285318 header.Draw(scr, layout.header)
286−
287− side := uv.NewStyledString(m.side.View())
288− side.Draw(scr, layout.sidebar)
289−
319+ m.drawSidebar(scr, layout.sidebar)
290320 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
291321 Height(layout.main.Dy()).
292− Background(lipgloss.ANSIColor(rand.Intn(256))).
293322 Render(" Chat Messages ")
294323 main := uv.NewStyledString(mainView)
295324 main.Draw(scr, layout.main)
@@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding {
385414 binds = append(binds, k.Quit)
386415 default:
387416 // TODO: other states
388− if m.sess == nil {
389− // no session selected
390− binds = append(binds,
391− k.Commands,
392− k.Models,
393− k.Editor.Newline,
394− k.Quit,
395− k.Help,
396− )
397− }
417+ // if m.session == nil {
418+ // no session selected
419+ binds = append(binds,
420+ k.Commands,
421+ k.Models,
422+ k.Editor.Newline,
423+ k.Quit,
424+ k.Help,
425+ )
426+ // }
398427 // else {
399428 // we have a session
400429 // }
@@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding {
436465 k.Quit,
437466 })
438467 default:
439− if m.sess == nil {
468+ if m.session == nil {
440469 // no session selected
441470 binds = append(binds,
442471 []key.Binding{
@@ -535,8 +564,7 @@ func (m *UI) updateSize() {
535564 m.textarea.SetHeight(m.layout.editor.Dy())
536565
537566 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())
540568 m.textarea.SetWidth(m.layout.editor.Dx())
541569 m.textarea.SetHeight(m.layout.editor.Dy())
542570
@@ -548,7 +576,8 @@ func (m *UI) updateSize() {
548576 }
549577}
550578
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.
552581func generateLayout(m *UI, w, h int) layout {
553582 // The screen area we're working with
554583 area := image.Rect(0, 0, w, h)
@@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout {
558587 // The editor height
559588 editorHeight := 5
560589 // The sidebar width
561− sidebarWidth := 40
590+ sidebarWidth := 30
562591 // The header height
563592 // TODO: handle compact
564593 headerHeight := 4
@@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout {
635664 // help
636665
637666 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
667+ // Add padding left
668+ sideRect.Min.X += 1
638669 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
639670 layout.sidebar = sideRect
640671 layout.main = mainRect
@@ -690,6 +721,8 @@ type layout struct {
690721 help uv.Rectangle
691722}
692723
724+// setEditorPrompt configures the textarea prompt function based on whether
725+// yolo mode is enabled.
693726func (m *UI) setEditorPrompt() {
694727 if m.com.App.Permissions.SkipRequests() {
695728 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() {
698731 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
699732}
700733
734+// normalPromptFunc returns the normal editor prompt style (" > " on first
735+// line, "::: " on subsequent lines).
701736func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
702737 t := m.com.Styles
703738 if info.LineNumber == 0 {
@@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
709744 return t.EditorPromptNormalBlurred.Render()
710745}
711746
747+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
748+// and colored dots.
712749func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
713750 t := m.com.Styles
714751 if info.LineNumber == 0 {
@@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{
740777 "Thinking...",
741778}
742779
780+// randomizePlaceholders selects random placeholder text for the textarea's
781+// ready and working states.
743782func (m *UI) randomizePlaceholders() {
744783 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
745784 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
746785}
747786
787+// renderHeader renders and caches the header logo at the specified width.
748788func (m *UI) renderHeader(compact bool, width int) {
749789 // TODO: handle the compact case differently
750790 m.header = renderLogo(m.com.Styles, compact, width)
751791}
752792
793+// renderSidebarLogo renders and caches the sidebar logo at the specified
794+// width.
795+func (m *UI) renderSidebarLogo(width int) {
796+ m.sidebarLogo = renderLogo(m.com.Styles, true, width)
797+}
798+
799+// renderLogo renders the Crush logo with the given styles and dimensions.
753800func renderLogo(t *styles.Styles, compact bool, width int) string {
754801 return logo.Render(version.Version, compact, logo.Opts{
755802 FieldColor: t.LogoFieldColor,
@@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
757804 TitleColorB: t.LogoTitleColorB,
758805 CharmColor: t.LogoCharmColor,
759806 VersionColor: t.LogoVersionColor,
760− Width: max(0, width-2),
807+ Width: width,
761808 })
762809}