on main help sign in
refactor: compact mode (#1850)
19c67f7Kujtim Hoxha
internal/ui/dialog/commands.go+1−1
@@ -227,11 +227,11 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
227 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
227 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
228 height := max(0, min(defaultDialogHeight, area.Dy()))
228 height := max(0, min(defaultDialogHeight, area.Dy()))
229 if area.Dx() != c.windowWidth && c.selected == SystemCommands {
229 if area.Dx() != c.windowWidth && c.selected == SystemCommands {
230+ c.windowWidth = area.Dx()
230 // since some items in the list depend on width (e.g. toggle sidebar command),
231 // since some items in the list depend on width (e.g. toggle sidebar command),
231 // we need to reset the command items when width changes
232 // we need to reset the command items when width changes
232 c.setCommandItems(c.selected)
233 c.setCommandItems(c.selected)
233 }
234 }
234− c.windowWidth = area.Dx()
235 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
235 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
236 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
236 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
237 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
237 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
internal/ui/model/header.gonew file+112
@@ -0,0 +1,112 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/config"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/fsext"
11+ "github.com/charmbracelet/crush/internal/lsp"
12+ "github.com/charmbracelet/crush/internal/session"
13+ "github.com/charmbracelet/crush/internal/ui/common"
14+ "github.com/charmbracelet/crush/internal/ui/styles"
15+ "github.com/charmbracelet/x/ansi"
16+)
17+
18+const (
19+ headerDiag = "╱"
20+ minHeaderDiags = 3
21+ leftPadding = 1
22+ rightPadding = 1
23+)
24+
25+// renderCompactHeader renders the compact header for the given session.
26+func renderCompactHeader(
27+ com *common.Common,
28+ session *session.Session,
29+ lspClients *csync.Map[string, *lsp.Client],
30+ detailsOpen bool,
31+ width int,
32+) string {
33+ if session == nil || session.ID == "" {
34+ return ""
35+ }
36+
37+ t := com.Styles
38+
39+ var b strings.Builder
40+
41+ b.WriteString(t.Header.Charm.Render("Charm™"))
42+ b.WriteString(" ")
43+ b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
44+ b.WriteString(" ")
45+
46+ availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
47+ details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
48+
49+ remainingWidth := width -
50+ lipgloss.Width(b.String()) -
51+ lipgloss.Width(details) -
52+ leftPadding -
53+ rightPadding
54+
55+ if remainingWidth > 0 {
56+ b.WriteString(t.Header.Diagonals.Render(
57+ strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
58+ ))
59+ b.WriteString(" ")
60+ }
61+
62+ b.WriteString(details)
63+
64+ return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
65+}
66+
67+// renderHeaderDetails renders the details section of the header.
68+func renderHeaderDetails(
69+ com *common.Common,
70+ session *session.Session,
71+ lspClients *csync.Map[string, *lsp.Client],
72+ detailsOpen bool,
73+ availWidth int,
74+) string {
75+ t := com.Styles
76+
77+ var parts []string
78+
79+ errorCount := 0
80+ for l := range lspClients.Seq() {
81+ errorCount += l.GetDiagnosticCounts().Error
82+ }
83+
84+ if errorCount > 0 {
85+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
86+ }
87+
88+ agentCfg := config.Get().Agents[config.AgentCoder]
89+ model := config.Get().GetModelByType(agentCfg.Model)
90+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
91+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
92+ parts = append(parts, formattedPercentage)
93+
94+ const keystroke = "ctrl+d"
95+ if detailsOpen {
96+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
97+ } else {
98+ parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
99+ }
100+
101+ dot := t.Header.Separator.Render(" • ")
102+ metadata := strings.Join(parts, dot)
103+ metadata = dot + metadata
104+
105+ const dirTrimLimit = 4
106+ cfg := com.Config()
107+ cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
108+ cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
109+ cwd = t.Header.WorkingDir.Render(cwd)
110+
111+ return cwd + metadata
112+}
internal/ui/model/lsp.go+3
@@ -72,6 +72,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
72// lspList renders a list of LSP clients with their status and diagnostics,
72// lspList renders a list of LSP clients with their status and diagnostics,
73// truncating to maxItems if needed.
73// truncating to maxItems if needed.
74func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
74func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
75+ if maxItems <= 0 {
76+ return ""
77+ }
75 var renderedLsps []string
78 var renderedLsps []string
76 for _, l := range lsps {
79 for _, l := range lsps {
77 var icon string
80 var icon string
internal/ui/model/mcp.go+3
@@ -49,6 +49,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
49// mcpList renders a list of MCP clients with their status and counts,
49// mcpList renders a list of MCP clients with their status and counts,
50// truncating to maxItems if needed.
50// truncating to maxItems if needed.
51func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
51func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
52+ if maxItems <= 0 {
53+ return ""
54+ }
52 var renderedMcps []string
55 var renderedMcps []string
53
56
54 for _, m := range mcps {
57 for _, m := range mcps {
internal/ui/model/session.go+9−2
@@ -169,9 +169,13 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd {
169
169
170// filesInfo renders the modified files section for the sidebar, showing files
170// filesInfo renders the modified files section for the sidebar, showing files
171// with their addition/deletion counts.
171// with their addition/deletion counts.
172−func (m *UI) filesInfo(cwd string, width, maxItems int) string {
172+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
173 t := m.com.Styles
173 t := m.com.Styles
174+
175+ title := t.Subtle.Render("Modified Files")
176+ if isSection {
174− title := common.Section(t, "Modified Files", width)
177+ title = common.Section(t, "Modified Files", width)
178+ }
175 list := t.Subtle.Render("None")
179 list := t.Subtle.Render("None")
176
180
177 if len(m.sessionFiles) > 0 {
181 if len(m.sessionFiles) > 0 {
@@ -184,6 +188,9 @@ func (m *UI) filesInfo(cwd string, width, maxItems int) string {
184// fileList renders a list of files with their diff statistics, truncating to
188// fileList renders a list of files with their diff statistics, truncating to
185// maxItems and showing a "...and N more" message if needed.
189// maxItems and showing a "...and N more" message if needed.
186func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
190func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
191+ if maxItems <= 0 {
192+ return ""
193+ }
187 var renderedFiles []string
194 var renderedFiles []string
188 filesShown := 0
195 filesShown := 0
189
196
internal/ui/model/sidebar.go+1−1
@@ -133,7 +133,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
133
133
134 lspSection := m.lspInfo(width, maxLSPs, true)
134 lspSection := m.lspInfo(width, maxLSPs, true)
135 mcpSection := m.mcpInfo(width, maxMCPs, true)
135 mcpSection := m.mcpInfo(width, maxMCPs, true)
136− filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
136+ filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
137
137
138 uv.NewStyledString(
138 uv.NewStyledString(
139 lipgloss.NewStyle().
139 lipgloss.NewStyle().
internal/ui/model/ui.go+209−68
@@ -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"}
55
55
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+
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
58
67
@@ -71,7 +80,6 @@ const (
71 uiInitialize
80 uiInitialize
72 uiLanding
81 uiLanding
73 uiChat
82 uiChat
74− uiChatCompact
75)
83)
76
84
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
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
164}
182}
165
183
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
235
253
254+ // Initialize compact mode from config
255+ ui.forceCompactMode = com.Config().Options.TUI.CompactMode
256+
236 return ui
257 return ui
237}
258}
238
259
@@ -284,6 +305,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
284 }
305 }
285 case loadSessionMsg:
306 case loadSessionMsg:
286 m.state = uiChat
307 m.state = uiChat
308+ if m.forceCompactMode {
309+ m.isCompact = true
310+ }
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)
939 }
967 }
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()
972+ return true
941 }
973 }
942 return false
974 return false
943 }
975 }
@@ -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 {
1070 }
1102 }
1071 }
1103 }
1072
1104
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+
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)
1221
1259
1222 case uiChat:
1260 case uiChat:
1261+ if m.isCompact {
1262+ header := uv.NewStyledString(m.header)
1223− m.chat.Draw(scr, layout.main)
1263+ header.Draw(scr, layout.header)
1264+ } else {
1265+ m.drawSidebar(scr, layout.sidebar)
1266+ }
1224
1267
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)
1228
1269
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))
1230 editor.Draw(scr, layout.editor)
1275 editor.Draw(scr, layout.editor)
1231
1276
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)
1235−
1280+ }
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)
1245 }
1281 }
1246
1282
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
1292 }
1328 }
1329+ if m.detailsOpen && m.isCompact {
1330+ // Don't show cursor if details overlay is open
1331+ return nil
1332+ }
1293
1333
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
1538}
1578}
1539
1579
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+
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())
1559
1629
1560 case uiChat:
1630 case uiChat:
1561− m.renderSidebarLogo(m.layout.sidebar.Dx())
1631+ if m.isCompact {
1562−
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())
1633+ } else {
1634+ m.renderSidebarLogo(m.layout.sidebar.Dx())
1635+ }
1566 }
1636 }
1567}
1637}
1568
1638
@@ -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
1583− headerHeight := 4
1584
1653
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 // ------
1620 // help
1689 // help
1621
1690
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
1625
1694
@@ -1633,7 +1702,7 @@ func (m *UI) generateLayout(w, h int) layout {
1633 // editor
1702 // editor
1634 // ------
1703 // ------
1635 // help
1704 // help
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
1644
1713
1645 case uiChat:
1714 case uiChat:
1715+ if m.isCompact {
1646− // Layout
1716+ // Layout
1647− //
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+ //
1648− // ------|---
1743+ // ------|---
1649− // main |
1744+ // main |
1650− // ------| side
1745+ // ------| side
1651− // editor|
1746+ // editor|
1652− // ----------
1747+ // ----------
1653− // help
1748+ // help
1654−
1749+
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
1665−
1760+ }
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
1681 }
1761 }
1682
1762
1683 if !layout.editor.Empty() {
1763 if !layout.editor.Empty() {
@@ -1711,6 +1791,9 @@ type layout struct {
1711
1791
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
1794+
1795+ // session details is the area for the session details overlay in compact mode.
1796+ sessionDetails uv.Rectangle
1714}
1797}
1715
1798
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 {
1916
1999
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)
2004+ } else {
1920− m.header = renderLogo(m.com.Styles, compact, width)
2005+ m.header = renderLogo(m.com.Styles, compact, width)
2006+ }
1921}
2007}
1922
2008
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)
1940 }
2026 }
1941 m.state = uiChat
2027 m.state = uiChat
2028+ if m.forceCompactMode {
2029+ m.isCompact = true
2030+ }
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))
2034+ }
1944 }
2035 }
1945
2036
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
2253}
2344}
2254
2345
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+
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{
internal/ui/styles/styles.go+28−6
@@ -69,9 +69,22 @@ type Styles struct {
69 TagError lipgloss.Style
69 TagError lipgloss.Style
70 TagInfo lipgloss.Style
70 TagInfo lipgloss.Style
71
71
72− // Headers
72+ // Header
73+ Header struct {
73− HeaderTool lipgloss.Style
74+ Charm lipgloss.Style // Style for "Charm™" label
75+ Diagonals lipgloss.Style // Style for diagonal separators (╱)
76+ Percentage lipgloss.Style // Style for context percentage
77+ Keystroke lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
78+ KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
79+ WorkingDir lipgloss.Style // Style for current working directory
80+ Separator lipgloss.Style // Style for separator dots (•)
81+ }
82+
83+ CompactDetails struct {
74− HeaderToolNested lipgloss.Style
84+ View lipgloss.Style
85+ Version lipgloss.Style
86+ Title lipgloss.Style
87+ }
75
88
76 // Panels
89 // Panels
77 PanelMuted lipgloss.Style
90 PanelMuted lipgloss.Style
@@ -995,9 +1008,18 @@ func DefaultStyles() Styles {
995 s.TagError = s.TagBase.Background(redDark)
1008 s.TagError = s.TagBase.Background(redDark)
996 s.TagInfo = s.TagBase.Background(blueLight)
1009 s.TagInfo = s.TagBase.Background(blueLight)
997
1010
998− // headers
1011+ // Compact header styles
999− s.HeaderTool = lipgloss.NewStyle().Foreground(blue)
1012+ s.Header.Charm = base.Foreground(secondary)
1000− s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted)
1013+ s.Header.Diagonals = base.Foreground(primary)
1014+ s.Header.Percentage = s.Muted
1015+ s.Header.Keystroke = s.Muted
1016+ s.Header.KeystrokeTip = s.Subtle
1017+ s.Header.WorkingDir = s.Muted
1018+ s.Header.Separator = s.Subtle
1019+
1020+ s.CompactDetails.Title = s.Base
1021+ s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
1022+ s.CompactDetails.Version = s.Muted
1001
1023
1002 // panels
1024 // panels
1003 s.PanelMuted = s.Muted.Background(bgBaseLighter)
1025 s.PanelMuted = s.Muted.Background(bgBaseLighter)