@@ -53,6 +53,15 @@ const maxAttachmentSize = int64(5 * 1024 * 1024)
5353// Allowed image formats.
5454var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
5555
56+// Compact mode breakpoints.
57+const (
58+ compactModeWidthBreakpoint = 120
59+ compactModeHeightBreakpoint = 30
60+)
61+
62+// Session details panel max height.
63+const sessionDetailsMaxHeight = 20
64+
5665// uiFocusState represents the current focus state of the UI.
5766type uiFocusState uint8
5867
@@ -71,7 +80,6 @@ const (
7180 uiInitialize
7281 uiLanding
7382 uiChat
74− uiChatCompact
7583)
7684
7785type openEditorMsg struct {
@@ -161,6 +169,16 @@ type UI struct {
161169 // custom commands & mcp commands
162170 customCommands []commands.CustomCommand
163171 mcpCustomCommands []commands.MCPCustomCommand
172+
173+ // forceCompactMode tracks whether compact mode is forced by user toggle
174+ forceCompactMode bool
175+
176+ // isCompact tracks whether we're currently in compact layout mode (either
177+ // by user toggle or auto-switch based on window size)
178+ isCompact bool
179+
180+ // detailsOpen tracks whether the details panel is open (in compact mode)
181+ detailsOpen bool
164182}
165183
166184// New creates a new instance of the [UI] model.
@@ -233,6 +251,9 @@ func New(com *common.Common) *UI {
233251 ui.textarea.Placeholder = ui.readyPlaceholder
234252 ui.status = status
235253
254+ // Initialize compact mode from config
255+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
256+
236257 return ui
237258}
238259
@@ -284,6 +305,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
284305 }
285306 case loadSessionMsg:
286307 m.state = uiChat
308+ if m.forceCompactMode {
309+ m.isCompact = true
310+ }
287311 m.session = msg.session
288312 m.sessionFiles = msg.files
289313 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
@@ -370,6 +394,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
370394 return m, nil
371395 case tea.WindowSizeMsg:
372396 m.width, m.height = msg.Width, msg.Height
397+ m.handleCompactMode(m.width, m.height)
373398 m.updateLayoutAndSize()
374399 case tea.KeyboardEnhancementsMsg:
375400 m.keyenh = msg
@@ -840,6 +865,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
840865 case dialog.ActionToggleHelp:
841866 m.status.ToggleHelp()
842867 m.dialog.CloseDialog(dialog.CommandsID)
868+ case dialog.ActionToggleCompactMode:
869+ cmds = append(cmds, m.toggleCompactMode())
870+ m.dialog.CloseDialog(dialog.CommandsID)
843871 case dialog.ActionQuit:
844872 cmds = append(cmds, tea.Quit)
845873 case dialog.ActionInitializeProject:
@@ -938,6 +966,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
938966 cmds = append(cmds, cmd)
939967 }
940968 return true
969+ case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
970+ m.detailsOpen = !m.detailsOpen
971+ m.updateLayoutAndSize()
972+ return true
941973 }
942974 return false
943975 }
@@ -972,7 +1004,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
9721004 case uiInitialize:
9731005 cmds = append(cmds, m.updateInitializeView(msg)...)
9741006 return tea.Batch(cmds...)
975− case uiChat, uiLanding, uiChatCompact:
1007+ case uiChat, uiLanding:
9761008 switch m.focus {
9771009 case uiFocusEditor:
9781010 // Handle completions if open.
@@ -1070,6 +1102,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
10701102 }
10711103 }
10721104
1105+ // remove the details if they are open when user starts typing
1106+ if m.detailsOpen {
1107+ m.detailsOpen = false
1108+ m.updateLayoutAndSize()
1109+ }
1110+
10731111 ta, cmd := m.textarea.Update(msg)
10741112 m.textarea = ta
10751113 cmds = append(cmds, cmd)
@@ -1220,28 +1258,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
12201258 editor.Draw(scr, layout.editor)
12211259
12221260 case uiChat:
1223− m.chat.Draw(scr, layout.main)
1261+ if m.isCompact {
1262+ header := uv.NewStyledString(m.header)
1263+ header.Draw(scr, layout.header)
1264+ } else {
1265+ m.drawSidebar(scr, layout.sidebar)
1266+ }
12241267
1225− header := uv.NewStyledString(m.header)
1226− header.Draw(scr, layout.header)
1227− m.drawSidebar(scr, layout.sidebar)
1268+ m.chat.Draw(scr, layout.main)
12281269
1229− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
1270+ editorWidth := scr.Bounds().Dx()
1271+ if !m.isCompact {
1272+ editorWidth -= layout.sidebar.Dx()
1273+ }
1274+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
12301275 editor.Draw(scr, layout.editor)
12311276
1232− case uiChatCompact:
1233− header := uv.NewStyledString(m.header)
1234− header.Draw(scr, layout.header)
1235−
1236− mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
1237− Height(layout.main.Dy()).
1238− Background(lipgloss.ANSIColor(rand.Intn(256))).
1239− Render(" Compact Chat Messages ")
1240− main := uv.NewStyledString(mainView)
1241− main.Draw(scr, layout.main)
1242−
1243− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1244− editor.Draw(scr, layout.editor)
1277+ // Draw details overlay in compact mode when open
1278+ if m.isCompact && m.detailsOpen {
1279+ m.drawSessionDetails(scr, layout.sessionDetails)
1280+ }
12451281 }
12461282
12471283 // Add status and help layer
@@ -1290,6 +1326,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
12901326 // Don't show cursor if editor is not visible
12911327 return nil
12921328 }
1329+ if m.detailsOpen && m.isCompact {
1330+ // Don't show cursor if details overlay is open
1331+ return nil
1332+ }
12931333
12941334 if m.textarea.Focused() {
12951335 cur := m.textarea.Cursor()
@@ -1537,6 +1577,36 @@ func (m *UI) FullHelp() [][]key.Binding {
15371577 return binds
15381578}
15391579
1580+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1581+func (m *UI) toggleCompactMode() tea.Cmd {
1582+ m.forceCompactMode = !m.forceCompactMode
1583+
1584+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
1585+ if err != nil {
1586+ return uiutil.ReportError(err)
1587+ }
1588+
1589+ m.handleCompactMode(m.width, m.height)
1590+ m.updateLayoutAndSize()
1591+
1592+ return nil
1593+}
1594+
1595+// handleCompactMode updates the UI state based on window size and compact mode setting.
1596+func (m *UI) handleCompactMode(newWidth, newHeight int) {
1597+ if m.state == uiChat {
1598+ if m.forceCompactMode {
1599+ m.isCompact = true
1600+ return
1601+ }
1602+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1603+ m.isCompact = true
1604+ } else {
1605+ m.isCompact = false
1606+ }
1607+ }
1608+}
1609+
15401610// updateLayoutAndSize updates the layout and sizes of UI components.
15411611func (m *UI) updateLayoutAndSize() {
15421612 m.layout = m.generateLayout(m.width, m.height)
@@ -1558,11 +1628,11 @@ func (m *UI) updateSize() {
15581628 m.renderHeader(false, m.layout.header.Dx())
15591629
15601630 case uiChat:
1561− m.renderSidebarLogo(m.layout.sidebar.Dx())
1562−
1563− case uiChatCompact:
1564− // TODO: set the width and heigh of the chat component
1565− m.renderHeader(true, m.layout.header.Dx())
1631+ if m.isCompact {
1632+ m.renderHeader(true, m.layout.header.Dx())
1633+ } else {
1634+ m.renderSidebarLogo(m.layout.sidebar.Dx())
1635+ }
15661636 }
15671637}
15681638
@@ -1579,8 +1649,7 @@ func (m *UI) generateLayout(w, h int) layout {
15791649 // The sidebar width
15801650 sidebarWidth := 30
15811651 // The header height
1582− // TODO: handle compact
1583− headerHeight := 4
1652+ const landingHeaderHeight = 4
15841653
15851654 var helpKeyMap help.KeyMap = m
15861655 if m.status.ShowingAll() {
@@ -1619,7 +1688,7 @@ func (m *UI) generateLayout(w, h int) layout {
16191688 // ------
16201689 // help
16211690
1622− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1691+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
16231692 layout.header = headerRect
16241693 layout.main = mainRect
16251694
@@ -1633,7 +1702,7 @@ func (m *UI) generateLayout(w, h int) layout {
16331702 // editor
16341703 // ------
16351704 // help
1636− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1705+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
16371706 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
16381707 // Remove extra padding from editor (but keep it for header and main)
16391708 editorRect.Min.X -= 1
@@ -1643,41 +1712,52 @@ func (m *UI) generateLayout(w, h int) layout {
16431712 layout.editor = editorRect
16441713
16451714 case uiChat:
1646− // Layout
1647− //
1648− // ------|---
1649− // main |
1650− // ------| side
1651− // editor|
1652− // ----------
1653− // help
1654−
1655− mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1656− // Add padding left
1657− sideRect.Min.X += 1
1658− mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1659− mainRect.Max.X -= 1 // Add padding right
1660− // Add bottom margin to main
1661− mainRect.Max.Y -= 1
1662− layout.sidebar = sideRect
1663− layout.main = mainRect
1664− layout.editor = editorRect
1665−
1666− case uiChatCompact:
1667− // Layout
1668− //
1669− // compact-header
1670− // ------
1671− // main
1672− // ------
1673− // editor
1674− // ------
1675− // help
1676− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
1677− mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1678− layout.header = headerRect
1679− layout.main = mainRect
1680− layout.editor = editorRect
1715+ if m.isCompact {
1716+ // Layout
1717+ //
1718+ // compact-header
1719+ // ------
1720+ // main
1721+ // ------
1722+ // editor
1723+ // ------
1724+ // help
1725+ const compactHeaderHeight = 1
1726+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
1727+ detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
1728+ sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
1729+ layout.sessionDetails = sessionDetailsArea
1730+ layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
1731+ // Add one line gap between header and main content
1732+ mainRect.Min.Y += 1
1733+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1734+ mainRect.Max.X -= 1 // Add padding right
1735+ // Add bottom margin to main
1736+ mainRect.Max.Y -= 1
1737+ layout.header = headerRect
1738+ layout.main = mainRect
1739+ layout.editor = editorRect
1740+ } else {
1741+ // Layout
1742+ //
1743+ // ------|---
1744+ // main |
1745+ // ------| side
1746+ // editor|
1747+ // ----------
1748+ // help
1749+
1750+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1751+ // Add padding left
1752+ sideRect.Min.X += 1
1753+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1754+ mainRect.Max.X -= 1 // Add padding right
1755+ // Add bottom margin to main
1756+ mainRect.Max.Y -= 1
1757+ layout.sidebar = sideRect
1758+ layout.main = mainRect
1759+ layout.editor = editorRect
1760+ }
16811761 }
16821762
16831763 if !layout.editor.Empty() {
@@ -1711,6 +1791,9 @@ type layout struct {
17111791
17121792 // status is the area for the status view.
17131793 status uv.Rectangle
1794+
1795+ // session details is the area for the session details overlay in compact mode.
1796+ sessionDetails uv.Rectangle
17141797}
17151798
17161799func (m *UI) openEditor(value string) tea.Cmd {
@@ -1916,8 +1999,11 @@ func (m *UI) renderEditorView(width int) string {
19161999
19172000// renderHeader renders and caches the header logo at the specified width.
19182001func (m *UI) renderHeader(compact bool, width int) {
1919− // TODO: handle the compact case differently
1920− m.header = renderLogo(m.com.Styles, compact, width)
2002+ if compact && m.session != nil && m.com.App != nil {
2003+ m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
2004+ } else {
2005+ m.header = renderLogo(m.com.Styles, compact, width)
2006+ }
19212007}
19222008
19232009// renderSidebarLogo renders and caches the sidebar logo at the specified
@@ -1939,8 +2025,13 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
19392025 return uiutil.ReportError(err)
19402026 }
19412027 m.state = uiChat
1942− m.session = &newSession
1943− cmds = append(cmds, m.loadSession(newSession.ID))
2028+ if m.forceCompactMode {
2029+ m.isCompact = true
2030+ }
2031+ if newSession.ID != "" {
2032+ m.session = &newSession
2033+ cmds = append(cmds, m.loadSession(newSession.ID))
2034+ }
19442035 }
19452036
19462037 // Capture session ID to avoid race with main goroutine updating m.session.
@@ -2252,6 +2343,56 @@ func (m *UI) pasteIdx() int {
22522343 return result + 1
22532344}
22542345
2346+// drawSessionDetails draws the session details in compact mode.
2347+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2348+ if m.session == nil {
2349+ return
2350+ }
2351+
2352+ s := m.com.Styles
2353+
2354+ width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2355+ height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2356+
2357+ title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2358+ blocks := []string{
2359+ title,
2360+ "",
2361+ m.modelInfo(width),
2362+ "",
2363+ }
2364+
2365+ detailsHeader := lipgloss.JoinVertical(
2366+ lipgloss.Left,
2367+ blocks...,
2368+ )
2369+
2370+ version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2371+
2372+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
2373+
2374+ const maxSectionWidth = 50
2375+ sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
2376+ maxItemsPerSection := remainingHeight - 3 // Account for section title and spacing
2377+
2378+ lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
2379+ mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
2380+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
2381+ sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
2382+ uv.NewStyledString(
2383+ s.CompactDetails.View.
2384+ Width(area.Dx()).
2385+ Render(
2386+ lipgloss.JoinVertical(
2387+ lipgloss.Left,
2388+ detailsHeader,
2389+ sections,
2390+ version,
2391+ ),
2392+ ),
2393+ ).Draw(scr, area)
2394+}
2395+
22552396// renderLogo renders the Crush logo with the given styles and dimensions.
22562397func renderLogo(t *styles.Styles, compact bool, width int) string {
22572398 return logo.Render(version.Version, compact, logo.Opts{