@@ -53,6 +53,15 @@ const maxAttachmentSize = int64(5 * 1024 * 1024)
53// Allowed image formats.
53// Allowed image formats.
54var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
54var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
56+// Compact mode breakpoints.
58+ compactModeWidthBreakpoint = 120
59+ compactModeHeightBreakpoint = 30
62+// Session details panel max height.
63+const sessionDetailsMaxHeight = 20
56// uiFocusState represents the current focus state of the UI.
65// uiFocusState represents the current focus state of the UI.
57type uiFocusState uint8
66type uiFocusState uint8
@@ -71,7 +80,6 @@ const (
71 uiInitialize
80 uiInitialize
77type openEditorMsg struct {
85type openEditorMsg struct {
@@ -161,6 +169,16 @@ type UI struct {
161 // custom commands & mcp commands
169 // custom commands & mcp commands
162 customCommands []commands.CustomCommand
170 customCommands []commands.CustomCommand
163 mcpCustomCommands []commands.MCPCustomCommand
171 mcpCustomCommands []commands.MCPCustomCommand
173+ // forceCompactMode tracks whether compact mode is forced by user toggle
174+ forceCompactMode bool
176+ // isCompact tracks whether we're currently in compact layout mode (either
177+ // by user toggle or auto-switch based on window size)
180+ // detailsOpen tracks whether the details panel is open (in compact mode)
166// New creates a new instance of the [UI] model.
184// New creates a new instance of the [UI] model.
@@ -233,6 +251,9 @@ func New(com *common.Common) *UI {
233 ui.textarea.Placeholder = ui.readyPlaceholder
251 ui.textarea.Placeholder = ui.readyPlaceholder
234 ui.status = status
252 ui.status = status
254+ // Initialize compact mode from config
255+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
236 return ui
257 return ui
@@ -284,6 +305,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
285 case loadSessionMsg:
306 case loadSessionMsg:
286 m.state = uiChat
307 m.state = uiChat
308+ if m.forceCompactMode {
287 m.session = msg.session
311 m.session = msg.session
288 m.sessionFiles = msg.files
312 m.sessionFiles = msg.files
289 msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
313 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) {
370 return m, nil
394 return m, nil
371 case tea.WindowSizeMsg:
395 case tea.WindowSizeMsg:
372 m.width, m.height = msg.Width, msg.Height
396 m.width, m.height = msg.Width, msg.Height
397+ m.handleCompactMode(m.width, m.height)
373 m.updateLayoutAndSize()
398 m.updateLayoutAndSize()
374 case tea.KeyboardEnhancementsMsg:
399 case tea.KeyboardEnhancementsMsg:
375 m.keyenh = msg
400 m.keyenh = msg
@@ -840,6 +865,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
840 case dialog.ActionToggleHelp:
865 case dialog.ActionToggleHelp:
841 m.status.ToggleHelp()
866 m.status.ToggleHelp()
842 m.dialog.CloseDialog(dialog.CommandsID)
867 m.dialog.CloseDialog(dialog.CommandsID)
868+ case dialog.ActionToggleCompactMode:
869+ cmds = append(cmds, m.toggleCompactMode())
870+ m.dialog.CloseDialog(dialog.CommandsID)
843 case dialog.ActionQuit:
871 case dialog.ActionQuit:
844 cmds = append(cmds, tea.Quit)
872 cmds = append(cmds, tea.Quit)
845 case dialog.ActionInitializeProject:
873 case dialog.ActionInitializeProject:
@@ -938,6 +966,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
938 cmds = append(cmds, cmd)
966 cmds = append(cmds, cmd)
940 return true
968 return true
969+ case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
970+ m.detailsOpen = !m.detailsOpen
971+ m.updateLayoutAndSize()
942 return false
974 return false
@@ -972,7 +1004,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
972 case uiInitialize:
1004 case uiInitialize:
973 cmds = append(cmds, m.updateInitializeView(msg)...)
1005 cmds = append(cmds, m.updateInitializeView(msg)...)
974 return tea.Batch(cmds...)
1006 return tea.Batch(cmds...)
975− case uiChat, uiLanding, uiChatCompact:
1007+ case uiChat, uiLanding:
976 switch m.focus {
1008 switch m.focus {
977 case uiFocusEditor:
1009 case uiFocusEditor:
978 // Handle completions if open.
1010 // Handle completions if open.
@@ -1070,6 +1102,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1105+ // remove the details if they are open when user starts typing
1107+ m.detailsOpen = false
1108+ m.updateLayoutAndSize()
1073 ta, cmd := m.textarea.Update(msg)
1111 ta, cmd := m.textarea.Update(msg)
1074 m.textarea = ta
1112 m.textarea = ta
1075 cmds = append(cmds, cmd)
1113 cmds = append(cmds, cmd)
@@ -1220,28 +1258,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1220 editor.Draw(scr, layout.editor)
1258 editor.Draw(scr, layout.editor)
1222 case uiChat:
1260 case uiChat:
1262+ header := uv.NewStyledString(m.header)
1223− m.chat.Draw(scr, layout.main)
1263+ header.Draw(scr, layout.header)
1265+ m.drawSidebar(scr, layout.sidebar)
1225− header := uv.NewStyledString(m.header)
1226− header.Draw(scr, layout.header)
1268+ m.chat.Draw(scr, layout.main)
1227− m.drawSidebar(scr, layout.sidebar)
1229− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
1270+ editorWidth := scr.Bounds().Dx()
1272+ editorWidth -= layout.sidebar.Dx()
1274+ editor := uv.NewStyledString(m.renderEditorView(editorWidth))
1230 editor.Draw(scr, layout.editor)
1275 editor.Draw(scr, layout.editor)
1232− case uiChatCompact:
1277+ // Draw details overlay in compact mode when open
1233− header := uv.NewStyledString(m.header)
1278+ if m.isCompact && m.detailsOpen {
1234− header.Draw(scr, layout.header)
1279+ m.drawSessionDetails(scr, layout.sessionDetails)
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)
1243− editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1244− editor.Draw(scr, layout.editor)
1247 // Add status and help layer
1283 // Add status and help layer
@@ -1290,6 +1326,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
1290 // Don't show cursor if editor is not visible
1326 // Don't show cursor if editor is not visible
1291 return nil
1327 return nil
1329+ if m.detailsOpen && m.isCompact {
1330+ // Don't show cursor if details overlay is open
1294 if m.textarea.Focused() {
1334 if m.textarea.Focused() {
1295 cur := m.textarea.Cursor()
1335 cur := m.textarea.Cursor()
@@ -1537,6 +1577,36 @@ func (m *UI) FullHelp() [][]key.Binding {
1537 return binds
1577 return binds
1580+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
1581+func (m *UI) toggleCompactMode() tea.Cmd {
1582+ m.forceCompactMode = !m.forceCompactMode
1584+ err := m.com.Config().SetCompactMode(m.forceCompactMode)
1586+ return uiutil.ReportError(err)
1589+ m.handleCompactMode(m.width, m.height)
1590+ m.updateLayoutAndSize()
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 {
1602+ if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
1605+ m.isCompact = false
1540// updateLayoutAndSize updates the layout and sizes of UI components.
1610// updateLayoutAndSize updates the layout and sizes of UI components.
1541func (m *UI) updateLayoutAndSize() {
1611func (m *UI) updateLayoutAndSize() {
1542 m.layout = m.generateLayout(m.width, m.height)
1612 m.layout = m.generateLayout(m.width, m.height)
@@ -1558,11 +1628,11 @@ func (m *UI) updateSize() {
1558 m.renderHeader(false, m.layout.header.Dx())
1628 m.renderHeader(false, m.layout.header.Dx())
1560 case uiChat:
1630 case uiChat:
1561− m.renderSidebarLogo(m.layout.sidebar.Dx())
1631+ if m.isCompact {
1563− case uiChatCompact:
1564− // TODO: set the width and heigh of the chat component
1565− m.renderHeader(true, m.layout.header.Dx())
1632+ m.renderHeader(true, m.layout.header.Dx())
1634+ m.renderSidebarLogo(m.layout.sidebar.Dx())
@@ -1579,8 +1649,7 @@ func (m *UI) generateLayout(w, h int) layout {
1579 // The sidebar width
1649 // The sidebar width
1580 sidebarWidth := 30
1650 sidebarWidth := 30
1581 // The header height
1651 // The header height
1582− // TODO: handle compact
1652+ const landingHeaderHeight = 4
1585 var helpKeyMap help.KeyMap = m
1654 var helpKeyMap help.KeyMap = m
1586 if m.status.ShowingAll() {
1655 if m.status.ShowingAll() {
@@ -1619,7 +1688,7 @@ func (m *UI) generateLayout(w, h int) layout {
1619 // ------
1688 // ------
1622− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1691+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1623 layout.header = headerRect
1692 layout.header = headerRect
1624 layout.main = mainRect
1693 layout.main = mainRect
@@ -1633,7 +1702,7 @@ func (m *UI) generateLayout(w, h int) layout {
1633 // editor
1702 // editor
1634 // ------
1703 // ------
1636− headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
1705+ headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
1637 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1706 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1638 // Remove extra padding from editor (but keep it for header and main)
1707 // Remove extra padding from editor (but keep it for header and main)
1639 editorRect.Min.X -= 1
1708 editorRect.Min.X -= 1
@@ -1643,41 +1712,52 @@ func (m *UI) generateLayout(w, h int) layout {
1643 layout.editor = editorRect
1712 layout.editor = editorRect
1645 case uiChat:
1714 case uiChat:
1646− // Layout
1716+ // Layout
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
1648− // ------|---
1743+ // ------|---
1649− // main |
1744+ // main |
1650− // ------| side
1745+ // ------| side
1651− // editor|
1746+ // editor|
1652− // ----------
1747+ // ----------
1653− // help
1748+ // help
1655− mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1750+ mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
1656− // Add padding left
1751+ // Add padding left
1657− sideRect.Min.X += 1
1752+ sideRect.Min.X += 1
1658− mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1753+ mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
1659− mainRect.Max.X -= 1 // Add padding right
1754+ mainRect.Max.X -= 1 // Add padding right
1660− // Add bottom margin to main
1755+ // Add bottom margin to main
1661− mainRect.Max.Y -= 1
1756+ mainRect.Max.Y -= 1
1662− layout.sidebar = sideRect
1757+ layout.sidebar = sideRect
1663− layout.main = mainRect
1758+ layout.main = mainRect
1664− layout.editor = editorRect
1759+ layout.editor = editorRect
1666− case uiChatCompact:
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
1683 if !layout.editor.Empty() {
1763 if !layout.editor.Empty() {
@@ -1711,6 +1791,9 @@ type layout struct {
1712 // status is the area for the status view.
1792 // status is the area for the status view.
1713 status uv.Rectangle
1793 status uv.Rectangle
1795+ // session details is the area for the session details overlay in compact mode.
1796+ sessionDetails uv.Rectangle
1716func (m *UI) openEditor(value string) tea.Cmd {
1799func (m *UI) openEditor(value string) tea.Cmd {
@@ -1916,8 +1999,11 @@ func (m *UI) renderEditorView(width int) string {
1917// renderHeader renders and caches the header logo at the specified width.
2000// renderHeader renders and caches the header logo at the specified width.
1918func (m *UI) renderHeader(compact bool, width int) {
2001func (m *UI) renderHeader(compact bool, width int) {
1919− // TODO: handle the compact case differently
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)
1920− m.header = renderLogo(m.com.Styles, compact, width)
2005+ m.header = renderLogo(m.com.Styles, compact, width)
1923// renderSidebarLogo renders and caches the sidebar logo at the specified
2009// 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
1939 return uiutil.ReportError(err)
2025 return uiutil.ReportError(err)
1941 m.state = uiChat
2027 m.state = uiChat
2028+ if m.forceCompactMode {
2031+ if newSession.ID != "" {
1942− m.session = &newSession
2032+ m.session = &newSession
1943− cmds = append(cmds, m.loadSession(newSession.ID))
2033+ cmds = append(cmds, m.loadSession(newSession.ID))
1946 // Capture session ID to avoid race with main goroutine updating m.session.
2037 // Capture session ID to avoid race with main goroutine updating m.session.
@@ -2252,6 +2343,56 @@ func (m *UI) pasteIdx() int {
2252 return result + 1
2343 return result + 1
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 {
2354+ width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
2355+ height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
2357+ title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
2358+ blocks := []string{
2361+ m.modelInfo(width),
2365+ detailsHeader := lipgloss.JoinVertical(
2370+ version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
2372+ remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
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
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.
2386+ lipgloss.JoinVertical(
2255// renderLogo renders the Crush logo with the given styles and dimensions.
2396// renderLogo renders the Crush logo with the given styles and dimensions.
2256func renderLogo(t *styles.Styles, compact bool, width int) string {
2397func renderLogo(t *styles.Styles, compact bool, width int) string {
2257 return logo.Render(version.Version, compact, logo.Opts{
2398 return logo.Render(version.Version, compact, logo.Opts{