on main help sign in
Merge branch 'main' into not-needed-keeb
commit624f583ba01e0d615c43f9974b472354a891e1b5 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 15 Jul 2025, 13:03 (1 year ago) parent4baeaaf58e2f72
32 files changed+838−114
CRUSH.md+1−1 cmd/logs.go+159−53 cmd/root.go+1−2 internal/app/app.go+3−3 internal/config/load.go+1 internal/llm/agent/agent.go+22−13 internal/message/content.go+6−4 internal/tui/components/chat/chat.go+6−3 internal/tui/components/chat/editor/editor.go+3 internal/tui/components/chat/header/header.go+6 internal/tui/components/chat/messages/messages.go+8 internal/tui/components/chat/sidebar/sidebar.go+427−11 internal/tui/components/chat/splash/splash.go+2−4 internal/tui/components/core/status_test.go+147 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden+1 internal/tui/components/core/testdata/TestStatus/Default.golden+1 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden+1 internal/tui/components/core/testdata/TestStatus/LongDescription.golden+1 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden+1 internal/tui/components/core/testdata/TestStatus/NoIcon.golden+1 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden+1 internal/tui/components/core/testdata/TestStatus/WithColors.golden+1 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden+1 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden+1 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden+1 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden+1 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden+1 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden+1 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden+1 internal/tui/components/dialogs/permissions/permissions.go+13−6 internal/tui/page/chat/chat.go+14−10 internal/tui/tui.go+4−4
CRUSH.md+1−1
@@ -4,7 +4,7 @@
44
55- **Build**: `go build .` or `go run .`
66- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
7−- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
7+- **Lint**: `task lint-fix`
88- **Format**: `task fmt` (gofumpt -w .)
99- **Dev**: `task dev` (runs with profiling enabled)
1010
cmd/logs.go+159−53
@@ -1,11 +1,14 @@
11package cmd
22
33import (
4+ "bufio"
45 "encoding/json"
56 "fmt"
7+ "io"
68 "os"
79 "path/filepath"
810 "slices"
11+ "strings"
912 "time"
1013
1114 "github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
1417 "github.com/spf13/cobra"
1518)
1619
17−func init() {
18− rootCmd.AddCommand(logsCmd)
19−}
20−
2120var logsCmd = &cobra.Command{
2221 Use: "logs",
2322 Short: "View crush logs",
24− Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
23+ Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
2524 RunE: func(cmd *cobra.Command, args []string) error {
2625 cwd, err := cmd.Flags().GetString("cwd")
2726 if err != nil {
2827 return fmt.Errorf("failed to get current working directory: %v", err)
2928 }
29+
30+ follow, err := cmd.Flags().GetBool("follow")
31+ if err != nil {
32+ return fmt.Errorf("failed to get follow flag: %v", err)
33+ }
34+
35+ tailLines, err := cmd.Flags().GetInt("tail")
36+ if err != nil {
37+ return fmt.Errorf("failed to get tail flag: %v", err)
38+ }
39+
3040 log.SetLevel(log.DebugLevel)
41+ // Configure log to output to stdout instead of stderr
42+ log.SetOutput(os.Stdout)
43+
3144 cfg, err := config.Load(cwd, false)
3245 if err != nil {
3346 return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
3851 log.Warn("Looks like you are not in a crush project. No logs found.")
3952 return nil
4053 }
41− t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
42− if err != nil {
43− return fmt.Errorf("failed to tail log file: %v", err)
44− }
4554
46− // Print the text of each received line
47− for line := range t.Lines {
48− var data map[string]any
49− if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
50− continue
55+ if follow {
56+ // Follow mode - tail the file continuously
57+ t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
58+ if err != nil {
59+ return fmt.Errorf("failed to tail log file: %v", err)
5160 }
52− msg := data["msg"]
53− level := data["level"]
54− otherData := []any{}
55− keys := []string{}
56− for k := range data {
57− keys = append(keys, k)
58− }
59− slices.Sort(keys)
60− for _, k := range keys {
61− switch k {
62− case "msg", "level", "time":
63− continue
64− case "source":
65− source, ok := data[k].(map[string]any)
66− if !ok {
67− continue
68− }
69− sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
70− otherData = append(otherData, "source", sourceFile)
7161
72− default:
73− otherData = append(otherData, k, data[k])
74− }
62+ // Print the text of each received line
63+ for line := range t.Lines {
64+ printLogLine(line.Text)
7565 }
76− log.SetTimeFunction(func(_ time.Time) time.Time {
77− // parse the timestamp from the log line if available
78− t, err := time.Parse(time.RFC3339, data["time"].(string))
66+ } else if tailLines > 0 {
67+ // Tail mode - show last N lines
68+ lines, err := readLastNLines(logsFile, tailLines)
69+ if err != nil {
70+ return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
71+ }
72+ for _, line := range lines {
73+ printLogLine(line)
74+ }
75+ } else {
76+ // Oneshot mode - read the entire file once
77+ file, err := os.Open(logsFile)
78+ if err != nil {
79+ return fmt.Errorf("failed to open log file: %v", err)
80+ }
81+ defer file.Close()
82+
83+ reader := bufio.NewReader(file)
84+ for {
85+ line, err := reader.ReadString('\n')
7986 if err != nil {
80− return time.Now() // fallback to current time if parsing fails
87+ if err == io.EOF && line != "" {
88+ // Handle last line without newline
89+ printLogLine(line)
90+ }
91+ break
8192 }
82− return t
83− })
84− switch level {
85− case "INFO":
86− log.Info(msg, otherData...)
87− case "DEBUG":
88− log.Debug(msg, otherData...)
89− case "ERROR":
90− log.Error(msg, otherData...)
91− case "WARN":
92− log.Warn(msg, otherData...)
93− default:
94− log.Info(msg, otherData...)
93+ // Remove trailing newline
94+ line = strings.TrimSuffix(line, "\n")
95+ printLogLine(line)
9596 }
9697 }
98+
9799 return nil
98100 },
99101}
102+
103+func init() {
104+ logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
105+ logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
106+ rootCmd.AddCommand(logsCmd)
107+}
108+
109+// readLastNLines reads the last N lines from a file using a simple circular buffer approach
110+func readLastNLines(filename string, n int) ([]string, error) {
111+ file, err := os.Open(filename)
112+ if err != nil {
113+ return nil, err
114+ }
115+ defer file.Close()
116+
117+ // Use a circular buffer to keep only the last N lines
118+ lines := make([]string, n)
119+ count := 0
120+ index := 0
121+
122+ reader := bufio.NewReader(file)
123+ for {
124+ line, err := reader.ReadString('\n')
125+ if err != nil {
126+ if err == io.EOF && line != "" {
127+ // Handle last line without newline
128+ line = strings.TrimSuffix(line, "\n")
129+ lines[index] = line
130+ count++
131+ index = (index + 1) % n
132+ }
133+ break
134+ }
135+ // Remove trailing newline
136+ line = strings.TrimSuffix(line, "\n")
137+ lines[index] = line
138+ count++
139+ index = (index + 1) % n
140+ }
141+
142+ // Extract the last N lines in correct order
143+ if count <= n {
144+ // We have fewer lines than requested, return them all
145+ return lines[:count], nil
146+ }
147+
148+ // We have more lines than requested, extract from circular buffer
149+ result := make([]string, n)
150+ for i := range n {
151+ result[i] = lines[(index+i)%n]
152+ }
153+ return result, nil
154+}
155+
156+func printLogLine(lineText string) {
157+ var data map[string]any
158+ if err := json.Unmarshal([]byte(lineText), &data); err != nil {
159+ return
160+ }
161+ msg := data["msg"]
162+ level := data["level"]
163+ otherData := []any{}
164+ keys := []string{}
165+ for k := range data {
166+ keys = append(keys, k)
167+ }
168+ slices.Sort(keys)
169+ for _, k := range keys {
170+ switch k {
171+ case "msg", "level", "time":
172+ continue
173+ case "source":
174+ source, ok := data[k].(map[string]any)
175+ if !ok {
176+ continue
177+ }
178+ sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
179+ otherData = append(otherData, "source", sourceFile)
180+
181+ default:
182+ otherData = append(otherData, k, data[k])
183+ }
184+ }
185+ log.SetTimeFunction(func(_ time.Time) time.Time {
186+ // parse the timestamp from the log line if available
187+ t, err := time.Parse(time.RFC3339, data["time"].(string))
188+ if err != nil {
189+ return time.Now() // fallback to current time if parsing fails
190+ }
191+ return t
192+ })
193+ switch level {
194+ case "INFO":
195+ log.Info(msg, otherData...)
196+ case "DEBUG":
197+ log.Debug(msg, otherData...)
198+ case "ERROR":
199+ log.Error(msg, otherData...)
200+ case "WARN":
201+ log.Warn(msg, otherData...)
202+ default:
203+ log.Info(msg, otherData...)
204+ }
205+}
cmd/root.go+1−2
@@ -85,7 +85,6 @@ to assist developers in writing, debugging, and understanding code directly from
8585 slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
8686 return err
8787 }
88− // Defer shutdown here so it runs for both interactive and non-interactive modes
8988 defer app.Shutdown()
9089
9190 // Initialize MCP tools early for both modes
@@ -107,6 +106,7 @@ to assist developers in writing, debugging, and understanding code directly from
107106 program := tea.NewProgram(
108107 tui.New(app),
109108 tea.WithAltScreen(),
109+ tea.WithContext(ctx),
110110 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
111111 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
112112 )
@@ -117,7 +117,6 @@ to assist developers in writing, debugging, and understanding code directly from
117117 slog.Error(fmt.Sprintf("TUI run error: %v", err))
118118 return fmt.Errorf("TUI error: %v", err)
119119 }
120− app.Shutdown()
121120 return nil
122121 },
123122}
internal/app/app.go+3−3
@@ -282,6 +282,9 @@ func (app *App) Subscribe(program *tea.Program) {
282282
283283// Shutdown performs a clean shutdown of the application
284284func (app *App) Shutdown() {
285+ if app.CoderAgent != nil {
286+ app.CoderAgent.CancelAll()
287+ }
285288 app.cancelFuncsMutex.Lock()
286289 for _, cancel := range app.watcherCancelFuncs {
287290 cancel()
@@ -301,9 +304,6 @@ func (app *App) Shutdown() {
301304 }
302305 cancel()
303306 }
304− if app.CoderAgent != nil {
305− app.CoderAgent.CancelAll()
306− }
307307
308308 for _, cleanup := range app.cleanupFuncs {
309309 if cleanup != nil {
internal/config/load.go+1
@@ -301,6 +301,7 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
301301 defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
302302 if defaultSmallModel == nil {
303303 err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
304+ return
304305 }
305306 smallModel = SelectedModel{
306307 Provider: string(p.ID),
internal/llm/agent/agent.go+22−13
@@ -403,7 +403,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
403403 agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
404404 if err != nil {
405405 if errors.Is(err, context.Canceled) {
406− agentMessage.AddFinish(message.FinishReasonCanceled)
406+ agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
407407 a.messages.Update(context.Background(), agentMessage)
408408 return a.err(ErrRequestCancelled)
409409 }
@@ -454,11 +454,15 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
454454 // Process each event in the stream.
455455 for event := range eventChan {
456456 if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
457− a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
457+ if errors.Is(processErr, context.Canceled) {
458+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
459+ } else {
460+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
461+ }
458462 return assistantMsg, nil, processErr
459463 }
460464 if ctx.Err() != nil {
461− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
465+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
462466 return assistantMsg, nil, ctx.Err()
463467 }
464468 }
@@ -468,7 +472,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
468472 for i, toolCall := range toolCalls {
469473 select {
470474 case <-ctx.Done():
471− a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
475+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
472476 // Make all future tool calls cancelled
473477 for j := i; j < len(toolCalls); j++ {
474478 toolResults[j] = message.ToolResult{
@@ -516,7 +520,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
516520 IsError: true,
517521 }
518522 }
519− a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
523+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
520524 break
521525 }
522526 }
@@ -548,8 +552,8 @@ out:
548552 return assistantMsg, &msg, err
549553}
550554
551−func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
552− msg.AddFinish(finishReson)
555+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason, message, details string) {
556+ msg.AddFinish(finishReson, message, details)
553557 _ = a.messages.Update(ctx, *msg)
554558}
555559
@@ -580,15 +584,10 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
580584 assistantMsg.FinishToolCall(event.ToolCall.ID)
581585 return a.messages.Update(ctx, *assistantMsg)
582586 case provider.EventError:
583− if errors.Is(event.Error, context.Canceled) {
584− slog.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
585− return context.Canceled
586− }
587− slog.Error(event.Error.Error())
588587 return event.Error
589588 case provider.EventComplete:
590589 assistantMsg.SetToolCalls(event.Response.ToolCalls)
591− assistantMsg.AddFinish(event.Response.FinishReason)
590+ assistantMsg.AddFinish(event.Response.FinishReason, "", "")
592591 if err := a.messages.Update(ctx, *assistantMsg); err != nil {
593592 return fmt.Errorf("failed to update message: %w", err)
594593 }
@@ -801,6 +800,16 @@ func (a *agent) CancelAll() {
801800 a.Cancel(key.(string)) // key is sessionID
802801 return true
803802 })
803+
804+ timeout := time.After(5 * time.Second)
805+ for a.IsBusy() {
806+ select {
807+ case <-timeout:
808+ return
809+ default:
810+ time.Sleep(200 * time.Millisecond)
811+ }
812+ }
804813}
805814
806815func (a *agent) UpdateModel() error {
internal/message/content.go+6−4
@@ -102,8 +102,10 @@ type ToolResult struct {
102102func (ToolResult) isPart() {}
103103
104104type Finish struct {
105− Reason FinishReason `json:"reason"`
106− Time int64 `json:"time"`
105+ Reason FinishReason `json:"reason"`
106+ Time int64 `json:"time"`
107+ Message string `json:"message,omitempty"`
108+ Details string `json:"details,omitempty"`
107109}
108110
109111func (Finish) isPart() {}
@@ -308,7 +310,7 @@ func (m *Message) SetToolResults(tr []ToolResult) {
308310 }
309311}
310312
311−func (m *Message) AddFinish(reason FinishReason) {
313+func (m *Message) AddFinish(reason FinishReason, message, details string) {
312314 // remove any existing finish part
313315 for i, part := range m.Parts {
314316 if _, ok := part.(Finish); ok {
@@ -316,7 +318,7 @@ func (m *Message) AddFinish(reason FinishReason) {
316318 break
317319 }
318320 }
319− m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
321+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
320322}
321323
322324func (m *Message) AddImageURL(url, detail string) {
internal/tui/components/chat/chat.go+6−3
@@ -337,7 +337,7 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
337337 var cmds []tea.Cmd
338338
339339 for _, tc := range msg.ToolCalls() {
340− if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
340+ if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
341341 cmds = append(cmds, cmd)
342342 }
343343 }
@@ -346,18 +346,21 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
346346}
347347
348348// updateOrAddToolCall updates an existing tool call or adds a new one.
349−func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
349+func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
350350 // Try to find existing tool call
351351 for index, existingTC := range existingToolCalls {
352352 if tc.ID == existingTC.GetToolCall().ID {
353353 existingTC.SetToolCall(tc)
354+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
355+ existingTC.SetCancelled()
356+ }
354357 m.listCmp.UpdateItem(index, existingTC)
355358 return nil
356359 }
357360 }
358361
359362 // Add new tool call if not found
360− return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
363+ return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
361364}
362365
363366// handleNewAssistantMessage processes new assistant messages and their tool calls.
internal/tui/components/chat/editor/editor.go+3
@@ -130,6 +130,9 @@ func (m *editorCmp) Init() tea.Cmd {
130130}
131131
132132func (m *editorCmp) send() tea.Cmd {
133+ if m.app.CoderAgent == nil {
134+ return util.ReportError(fmt.Errorf("coder agent is not initialized"))
135+ }
133136 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
134137 return util.ReportWarn("Agent is working, please wait...")
135138 }
internal/tui/components/chat/header/header.go+6
@@ -21,6 +21,7 @@ type Header interface {
2121 SetSession(session session.Session) tea.Cmd
2222 SetWidth(width int) tea.Cmd
2323 SetDetailsOpen(open bool)
24+ ShowingDetails() bool
2425}
2526
2627type header struct {
@@ -137,3 +138,8 @@ func (h *header) SetWidth(width int) tea.Cmd {
137138 h.width = width
138139 return nil
139140}
141+
142+// ShowingDetails implements Header.
143+func (h *header) ShowingDetails() bool {
144+ return h.detailsOpen
145+}
internal/tui/components/chat/messages/messages.go+8
@@ -8,6 +8,7 @@ import (
88
99 tea "github.com/charmbracelet/bubbletea/v2"
1010 "github.com/charmbracelet/lipgloss/v2"
11+ "github.com/charmbracelet/x/ansi"
1112
1213 "github.com/charmbracelet/crush/internal/config"
1314 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -184,6 +185,7 @@ func (m *messageCmp) toMarkdown(content string) string {
184185// markdownContent processes the message content and handles special states.
185186// Returns appropriate content for thinking, finished, and error states.
186187func (m *messageCmp) markdownContent() string {
188+ t := styles.CurrentTheme()
187189 content := m.message.Content().String()
188190 if m.message.Role == message.Assistant {
189191 thinking := m.message.IsThinking()
@@ -199,6 +201,12 @@ func (m *messageCmp) markdownContent() string {
199201 content = ""
200202 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
201203 content = "*Canceled*"
204+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
205+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
206+ truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
207+ title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
208+ details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
209+ return fmt.Sprintf("%s\n\n%s", title, details)
202210 }
203211 }
204212 return m.toMarkdown(content)
internal/tui/components/chat/sidebar/sidebar.go+427−11
@@ -33,6 +33,16 @@ type FileHistory struct {
3333 latestVersion history.File
3434}
3535
36+const LogoHeightBreakpoint = 40
37+
38+// Default maximum number of items to show in each section
39+const (
40+ DefaultMaxFilesShown = 10
41+ DefaultMaxLSPsShown = 8
42+ DefaultMaxMCPsShown = 8
43+ MinItemsPerSection = 2 // Minimum items to show per section
44+)
45+
3646type SessionFile struct {
3747 History FileHistory
3848 FilePath string
@@ -100,8 +110,14 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
100110func (m *sidebarCmp) View() string {
101111 t := styles.CurrentTheme()
102112 parts := []string{}
113+
103114 if !m.compactMode {
104− parts = append(parts, m.logo)
115+ if m.height > LogoHeightBreakpoint {
116+ parts = append(parts, m.logo)
117+ } else {
118+ // Use a smaller logo for smaller screens
119+ parts = append(parts, m.smallerScreenLogo(), "")
120+ }
105121 }
106122
107123 if !m.compactMode && m.session.ID != "" {
@@ -119,15 +135,26 @@ func (m *sidebarCmp) View() string {
119135 parts = append(parts,
120136 m.currentModelBlock(),
121137 )
122− if m.session.ID != "" {
123− parts = append(parts, "", m.filesBlock())
138+
139+ // Check if we should use horizontal layout for sections
140+ if m.compactMode && m.width > m.height {
141+ // Horizontal layout for compact mode when width > height
142+ sectionsContent := m.renderSectionsHorizontal()
143+ if sectionsContent != "" {
144+ parts = append(parts, "", sectionsContent)
145+ }
146+ } else {
147+ // Vertical layout (default)
148+ if m.session.ID != "" {
149+ parts = append(parts, "", m.filesBlock())
150+ }
151+ parts = append(parts,
152+ "",
153+ m.lspBlock(),
154+ "",
155+ m.mcpBlock(),
156+ )
124157 }
125− parts = append(parts,
126− "",
127− m.lspBlock(),
128− "",
129− m.mcpBlock(),
130− )
131158
132159 style := t.S().Base.
133160 Width(m.width).
@@ -258,6 +285,328 @@ func (m *sidebarCmp) getMaxWidth() int {
258285 return min(m.width-2, 58) // -2 for padding
259286}
260287
288+// calculateAvailableHeight estimates how much height is available for dynamic content
289+func (m *sidebarCmp) calculateAvailableHeight() int {
290+ usedHeight := 0
291+
292+ if !m.compactMode {
293+ if m.height > LogoHeightBreakpoint {
294+ usedHeight += 7 // Approximate logo height
295+ } else {
296+ usedHeight += 2 // Smaller logo height
297+ }
298+ usedHeight += 1 // Empty line after logo
299+ }
300+
301+ if m.session.ID != "" {
302+ usedHeight += 1 // Title line
303+ usedHeight += 1 // Empty line after title
304+ }
305+
306+ if !m.compactMode {
307+ usedHeight += 1 // CWD line
308+ usedHeight += 1 // Empty line after CWD
309+ }
310+
311+ usedHeight += 2 // Model info
312+
313+ usedHeight += 6 // 3 sections × 2 lines each (header + empty line)
314+
315+ // Base padding
316+ usedHeight += 2 // Top and bottom padding
317+
318+ return max(0, m.height-usedHeight)
319+}
320+
321+// getDynamicLimits calculates how many items to show in each section based on available height
322+func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
323+ availableHeight := m.calculateAvailableHeight()
324+
325+ // If we have very little space, use minimum values
326+ if availableHeight < 10 {
327+ return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
328+ }
329+
330+ // Distribute available height among the three sections
331+ // Give priority to files, then LSPs, then MCPs
332+ totalSections := 3
333+ heightPerSection := availableHeight / totalSections
334+
335+ // Calculate limits for each section, ensuring minimums
336+ maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
337+ maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
338+ maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
339+
340+ // If we have extra space, give it to files first
341+ remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
342+ if remainingHeight > 0 {
343+ extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
344+ maxFiles += extraForFiles
345+ remainingHeight -= extraForFiles
346+
347+ if remainingHeight > 0 {
348+ extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
349+ maxLSPs += extraForLSPs
350+ remainingHeight -= extraForLSPs
351+
352+ if remainingHeight > 0 {
353+ maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
354+ }
355+ }
356+ }
357+
358+ return maxFiles, maxLSPs, maxMCPs
359+}
360+
361+// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
362+func (m *sidebarCmp) renderSectionsHorizontal() string {
363+ // Calculate available width for each section
364+ totalWidth := m.width - 4 // Account for padding and spacing
365+ sectionWidth := min(50, totalWidth/3)
366+
367+ // Get the sections content with limited height
368+ var filesContent, lspContent, mcpContent string
369+
370+ filesContent = m.filesBlockCompact(sectionWidth)
371+ lspContent = m.lspBlockCompact(sectionWidth)
372+ mcpContent = m.mcpBlockCompact(sectionWidth)
373+
374+ return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
375+}
376+
377+// filesBlockCompact renders the files block with limited width and height for horizontal layout
378+func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
379+ t := styles.CurrentTheme()
380+
381+ section := t.S().Subtle.Render("Modified Files")
382+
383+ files := make([]SessionFile, 0)
384+ m.files.Range(func(key, value any) bool {
385+ file := value.(SessionFile)
386+ files = append(files, file)
387+ return true
388+ })
389+
390+ if len(files) == 0 {
391+ content := lipgloss.JoinVertical(
392+ lipgloss.Left,
393+ section,
394+ "",
395+ t.S().Base.Foreground(t.Border).Render("None"),
396+ )
397+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
398+ }
399+
400+ fileList := []string{section, ""}
401+ sort.Slice(files, func(i, j int) bool {
402+ return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
403+ })
404+
405+ // Limit items for horizontal layout - use less space
406+ maxItems := min(5, len(files))
407+ availableHeight := m.height - 8 // Reserve space for header and other content
408+ if availableHeight > 0 {
409+ maxItems = min(maxItems, availableHeight)
410+ }
411+
412+ filesShown := 0
413+ for _, file := range files {
414+ if file.Additions == 0 && file.Deletions == 0 {
415+ continue
416+ }
417+ if filesShown >= maxItems {
418+ break
419+ }
420+
421+ var statusParts []string
422+ if file.Additions > 0 {
423+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
424+ }
425+ if file.Deletions > 0 {
426+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
427+ }
428+
429+ extraContent := strings.Join(statusParts, " ")
430+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
431+ filePath := file.FilePath
432+ filePath = strings.TrimPrefix(filePath, cwd)
433+ filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
434+ filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "…")
435+
436+ fileList = append(fileList,
437+ core.Status(
438+ core.StatusOpts{
439+ IconColor: t.FgMuted,
440+ NoIcon: true,
441+ Title: filePath,
442+ ExtraContent: extraContent,
443+ },
444+ maxWidth,
445+ ),
446+ )
447+ filesShown++
448+ }
449+
450+ // Add "..." indicator if there are more files
451+ totalFilesWithChanges := 0
452+ for _, file := range files {
453+ if file.Additions > 0 || file.Deletions > 0 {
454+ totalFilesWithChanges++
455+ }
456+ }
457+ if totalFilesWithChanges > maxItems {
458+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("…"))
459+ }
460+
461+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
462+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
463+}
464+
465+// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
466+func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
467+ t := styles.CurrentTheme()
468+
469+ section := t.S().Subtle.Render("LSPs")
470+
471+ lspList := []string{section, ""}
472+
473+ lsp := config.Get().LSP.Sorted()
474+ if len(lsp) == 0 {
475+ content := lipgloss.JoinVertical(
476+ lipgloss.Left,
477+ section,
478+ "",
479+ t.S().Base.Foreground(t.Border).Render("None"),
480+ )
481+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
482+ }
483+
484+ // Limit items for horizontal layout
485+ maxItems := min(5, len(lsp))
486+ availableHeight := m.height - 8
487+ if availableHeight > 0 {
488+ maxItems = min(maxItems, availableHeight)
489+ }
490+
491+ for i, l := range lsp {
492+ if i >= maxItems {
493+ break
494+ }
495+
496+ iconColor := t.Success
497+ if l.LSP.Disabled {
498+ iconColor = t.FgMuted
499+ }
500+
501+ lspErrs := map[protocol.DiagnosticSeverity]int{
502+ protocol.SeverityError: 0,
503+ protocol.SeverityWarning: 0,
504+ protocol.SeverityHint: 0,
505+ protocol.SeverityInformation: 0,
506+ }
507+ if client, ok := m.lspClients[l.Name]; ok {
508+ for _, diagnostics := range client.GetDiagnostics() {
509+ for _, diagnostic := range diagnostics {
510+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
511+ lspErrs[diagnostic.Severity] = severity + 1
512+ }
513+ }
514+ }
515+ }
516+
517+ errs := []string{}
518+ if lspErrs[protocol.SeverityError] > 0 {
519+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
520+ }
521+ if lspErrs[protocol.SeverityWarning] > 0 {
522+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
523+ }
524+ if lspErrs[protocol.SeverityHint] > 0 {
525+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
526+ }
527+ if lspErrs[protocol.SeverityInformation] > 0 {
528+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
529+ }
530+
531+ lspList = append(lspList,
532+ core.Status(
533+ core.StatusOpts{
534+ IconColor: iconColor,
535+ Title: l.Name,
536+ Description: l.LSP.Command,
537+ ExtraContent: strings.Join(errs, " "),
538+ },
539+ maxWidth,
540+ ),
541+ )
542+ }
543+
544+ // Add "..." indicator if there are more LSPs
545+ if len(lsp) > maxItems {
546+ lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("…"))
547+ }
548+
549+ content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
550+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
551+}
552+
553+// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
554+func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
555+ t := styles.CurrentTheme()
556+
557+ section := t.S().Subtle.Render("MCPs")
558+
559+ mcpList := []string{section, ""}
560+
561+ mcps := config.Get().MCP.Sorted()
562+ if len(mcps) == 0 {
563+ content := lipgloss.JoinVertical(
564+ lipgloss.Left,
565+ section,
566+ "",
567+ t.S().Base.Foreground(t.Border).Render("None"),
568+ )
569+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
570+ }
571+
572+ // Limit items for horizontal layout
573+ maxItems := min(5, len(mcps))
574+ availableHeight := m.height - 8
575+ if availableHeight > 0 {
576+ maxItems = min(maxItems, availableHeight)
577+ }
578+
579+ for i, l := range mcps {
580+ if i >= maxItems {
581+ break
582+ }
583+
584+ iconColor := t.Success
585+ if l.MCP.Disabled {
586+ iconColor = t.FgMuted
587+ }
588+
589+ mcpList = append(mcpList,
590+ core.Status(
591+ core.StatusOpts{
592+ IconColor: iconColor,
593+ Title: l.Name,
594+ Description: l.MCP.Command,
595+ },
596+ maxWidth,
597+ ),
598+ )
599+ }
600+
601+ // Add "..." indicator if there are more MCPs
602+ if len(mcps) > maxItems {
603+ mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("…"))
604+ }
605+
606+ content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
607+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
608+}
609+
261610func (m *sidebarCmp) filesBlock() string {
262611 t := styles.CurrentTheme()
263612
@@ -286,10 +635,19 @@ func (m *sidebarCmp) filesBlock() string {
286635 return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
287636 })
288637
638+ // Limit the number of files shown
639+ maxFiles, _, _ := m.getDynamicLimits()
640+ maxFiles = min(len(files), maxFiles)
641+ filesShown := 0
642+
289643 for _, file := range files {
290644 if file.Additions == 0 && file.Deletions == 0 {
291645 continue // skip files with no changes
292646 }
647+ if filesShown >= maxFiles {
648+ break
649+ }
650+
293651 var statusParts []string
294652 if file.Additions > 0 {
295653 statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -315,6 +673,21 @@ func (m *sidebarCmp) filesBlock() string {
315673 m.getMaxWidth(),
316674 ),
317675 )
676+ filesShown++
677+ }
678+
679+ // Add indicator if there are more files
680+ totalFilesWithChanges := 0
681+ for _, file := range files {
682+ if file.Additions > 0 || file.Deletions > 0 {
683+ totalFilesWithChanges++
684+ }
685+ }
686+ if totalFilesWithChanges > maxFiles {
687+ remaining := totalFilesWithChanges - maxFiles
688+ fileList = append(fileList,
689+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
690+ )
318691 }
319692
320693 return lipgloss.JoinVertical(
@@ -342,7 +715,14 @@ func (m *sidebarCmp) lspBlock() string {
342715 )
343716 }
344717
345− for _, l := range lsp {
718+ // Limit the number of LSPs shown
719+ _, maxLSPs, _ := m.getDynamicLimits()
720+ maxLSPs = min(len(lsp), maxLSPs)
721+ for i, l := range lsp {
722+ if i >= maxLSPs {
723+ break
724+ }
725+
346726 iconColor := t.Success
347727 if l.LSP.Disabled {
348728 iconColor = t.FgMuted
@@ -390,6 +770,14 @@ func (m *sidebarCmp) lspBlock() string {
390770 )
391771 }
392772
773+ // Add indicator if there are more LSPs
774+ if len(lsp) > maxLSPs {
775+ remaining := len(lsp) - maxLSPs
776+ lspList = append(lspList,
777+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
778+ )
779+ }
780+
393781 return lipgloss.JoinVertical(
394782 lipgloss.Left,
395783 lspList...,
@@ -415,7 +803,14 @@ func (m *sidebarCmp) mcpBlock() string {
415803 )
416804 }
417805
418− for _, l := range mcps {
806+ // Limit the number of MCPs shown
807+ _, _, maxMCPs := m.getDynamicLimits()
808+ maxMCPs = min(len(mcps), maxMCPs)
809+ for i, l := range mcps {
810+ if i >= maxMCPs {
811+ break
812+ }
813+
419814 iconColor := t.Success
420815 if l.MCP.Disabled {
421816 iconColor = t.FgMuted
@@ -432,6 +827,14 @@ func (m *sidebarCmp) mcpBlock() string {
432827 )
433828 }
434829
830+ // Add indicator if there are more MCPs
831+ if len(mcps) > maxMCPs {
832+ remaining := len(mcps) - maxMCPs
833+ mcpList = append(mcpList,
834+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("… and %d more", remaining)),
835+ )
836+ }
837+
435838 return lipgloss.JoinVertical(
436839 lipgloss.Left,
437840 mcpList...,
@@ -504,6 +907,19 @@ func (s *sidebarCmp) currentModelBlock() string {
504907 )
505908}
506909
910+func (m *sidebarCmp) smallerScreenLogo() string {
911+ t := styles.CurrentTheme()
912+ title := t.S().Base.Foreground(t.Secondary).Render("Charm™")
913+ title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
914+ remainingWidth := m.width - lipgloss.Width(title) - 3
915+ if remainingWidth > 0 {
916+ char := "╱"
917+ lines := strings.Repeat(char, remainingWidth)
918+ title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
919+ }
920+ return title
921+}
922+
507923// SetSession implements Sidebar.
508924func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
509925 m.session = session
internal/tui/components/chat/splash/splash.go+2−4
@@ -192,10 +192,8 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
192192 return s, s.initializeProject()
193193 }
194194 case key.Matches(msg, s.keyMap.No):
195− if s.needsProjectInit {
196− s.needsProjectInit = false
197− return s, util.CmdHandler(OnboardingCompleteMsg{})
198− }
195+ s.selectedNo = true
196+ return s, s.initializeProject()
199197 default:
200198 if s.needsAPIKey {
201199 u, cmd := s.apiKeyInput.Update(msg)
internal/tui/components/core/status_test.gonew file+147
@@ -0,0 +1,147 @@
1+package core_test
2+
3+import (
4+ "fmt"
5+ "image/color"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/tui/components/core"
9+ "github.com/charmbracelet/x/exp/golden"
10+)
11+
12+func TestStatus(t *testing.T) {
13+ t.Parallel()
14+
15+ tests := []struct {
16+ name string
17+ opts core.StatusOpts
18+ width int
19+ }{
20+ {
21+ name: "Default",
22+ opts: core.StatusOpts{
23+ Title: "Status",
24+ Description: "Everything is working fine",
25+ },
26+ width: 80,
27+ },
28+ {
29+ name: "WithCustomIcon",
30+ opts: core.StatusOpts{
31+ Icon: "✓",
32+ Title: "Success",
33+ Description: "Operation completed successfully",
34+ },
35+ width: 80,
36+ },
37+ {
38+ name: "NoIcon",
39+ opts: core.StatusOpts{
40+ NoIcon: true,
41+ Title: "Info",
42+ Description: "This status has no icon",
43+ },
44+ width: 80,
45+ },
46+ {
47+ name: "WithColors",
48+ opts: core.StatusOpts{
49+ Icon: "⚠",
50+ IconColor: color.RGBA{255, 165, 0, 255}, // Orange
51+ Title: "Warning",
52+ TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
53+ Description: "This is a warning message",
54+ DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
55+ },
56+ width: 80,
57+ },
58+ {
59+ name: "WithExtraContent",
60+ opts: core.StatusOpts{
61+ Title: "Build",
62+ Description: "Building project",
63+ ExtraContent: "[2/5]",
64+ },
65+ width: 80,
66+ },
67+ {
68+ name: "LongDescription",
69+ opts: core.StatusOpts{
70+ Title: "Processing",
71+ Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
72+ },
73+ width: 60,
74+ },
75+ {
76+ name: "NarrowWidth",
77+ opts: core.StatusOpts{
78+ Icon: "●",
79+ Title: "Status",
80+ Description: "Short message",
81+ },
82+ width: 30,
83+ },
84+ {
85+ name: "VeryNarrowWidth",
86+ opts: core.StatusOpts{
87+ Icon: "●",
88+ Title: "Test",
89+ Description: "This will be truncated",
90+ },
91+ width: 20,
92+ },
93+ {
94+ name: "EmptyDescription",
95+ opts: core.StatusOpts{
96+ Icon: "●",
97+ Title: "Title Only",
98+ },
99+ width: 80,
100+ },
101+ {
102+ name: "AllFieldsWithExtraContent",
103+ opts: core.StatusOpts{
104+ Icon: "🚀",
105+ IconColor: color.RGBA{0, 255, 0, 255}, // Green
106+ Title: "Deployment",
107+ TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
108+ Description: "Deploying to production environment",
109+ DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
110+ ExtraContent: "v1.2.3",
111+ },
112+ width: 80,
113+ },
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ t.Parallel()
119+
120+ output := core.Status(tt.opts, tt.width)
121+ golden.RequireEqual(t, []byte(output))
122+ })
123+ }
124+}
125+
126+func TestStatusTruncation(t *testing.T) {
127+ t.Parallel()
128+
129+ opts := core.StatusOpts{
130+ Icon: "●",
131+ Title: "Very Long Title",
132+ Description: "This is an extremely long description that definitely needs to be truncated",
133+ ExtraContent: "[extra]",
134+ }
135+
136+ // Test different widths to ensure truncation works correctly
137+ widths := []int{20, 30, 40, 50, 60}
138+
139+ for _, width := range widths {
140+ t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
141+ t.Parallel()
142+
143+ output := core.Status(opts, width)
144+ golden.RequireEqual(t, []byte(output))
145+ })
146+ }
147+}
internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.goldennew file+1
@@ -0,0 +1 @@
1+🚀 Deployment Deploying to production environment v1.2.3
internal/tui/components/core/testdata/TestStatus/Default.goldennew file+1
@@ -0,0 +1 @@
1+● Status Everything is working fine
internal/tui/components/core/testdata/TestStatus/EmptyDescription.goldennew file+1
@@ -0,0 +1 @@
1+● Title Only 
internal/tui/components/core/testdata/TestStatus/LongDescription.goldennew file+1
@@ -0,0 +1 @@
1+● Processing This is a very long description that should be…
internal/tui/components/core/testdata/TestStatus/NarrowWidth.goldennew file+1
@@ -0,0 +1 @@
1+● Status Short message
internal/tui/components/core/testdata/TestStatus/NoIcon.goldennew file+1
@@ -0,0 +1 @@
1+Info This status has no icon
internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.goldennew file+1
@@ -0,0 +1 @@
1+● Test This will be…
internal/tui/components/core/testdata/TestStatus/WithColors.goldennew file+1
@@ -0,0 +1 @@
1+⚠ Warning This is a warning message
internal/tui/components/core/testdata/TestStatus/WithCustomIcon.goldennew file+1
@@ -0,0 +1 @@
1+✓ Success Operation completed successfully
internal/tui/components/core/testdata/TestStatus/WithExtraContent.goldennew file+1
@@ -0,0 +1 @@
1+● Build Building project [2/5]
internal/tui/components/core/testdata/TestStatusTruncation/Width20.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title  [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width30.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title Thi… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width40.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an ex… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width50.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an extremely lo… [extra]
internal/tui/components/core/testdata/TestStatusTruncation/Width60.goldennew file+1
@@ -0,0 +1 @@
1+● Very Long Title This is an extremely long descrip… [extra]
internal/tui/components/dialogs/permissions/permissions.go+13−6
@@ -60,6 +60,9 @@ type permissionDialogCmp struct {
6060 cachedContent string
6161 contentDirty bool
6262
63+ positionRow int // Row position for dialog
64+ positionCol int // Column position for dialog
65+
6366 keyMap KeyMap
6467}
6568
@@ -446,6 +449,10 @@ func (p *permissionDialogCmp) render() string {
446449 p.contentViewPort.SetHeight(contentHeight)
447450 p.contentViewPort.SetContent(contentFinal)
448451
452+ p.positionRow = p.wHeight / 2
453+ p.positionRow -= (contentHeight + 9) / 2
454+ p.positionRow -= 3 // Move dialog slightly higher than middle
455+
449456 var contentHelp string
450457 if p.supportsDiffView() {
451458 contentHelp = help.New().View(p.keyMap)
@@ -509,7 +516,11 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
509516 if oldWidth != p.width || oldHeight != p.height {
510517 p.contentDirty = true
511518 }
512−
519+ p.positionRow = p.wHeight / 2
520+ p.positionRow -= p.height / 2
521+ p.positionRow -= 3 // Move dialog slightly higher than middle
522+ p.positionCol = p.wWidth / 2
523+ p.positionCol -= p.width / 2
513524 return nil
514525}
515526
@@ -529,9 +540,5 @@ func (p *permissionDialogCmp) ID() dialogs.DialogID {
529540
530541// Position implements PermissionDialogCmp.
531542func (p *permissionDialogCmp) Position() (int, int) {
532− row := (p.wHeight / 2) - 2 // Just a bit above the center
533− row -= p.height / 2
534− col := p.wWidth / 2
535− col -= p.width / 2
536− return row, col
543+ return p.positionRow, p.positionCol
537544}
internal/tui/page/chat/chat.go+14−10
@@ -51,11 +51,12 @@ const (
5151)
5252
5353const (
54− CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
55− EditorHeight = 5 // Height of the editor input area including padding
56− SideBarWidth = 31 // Width of the sidebar
57− SideBarDetailsPadding = 1 // Padding for the sidebar details section
58− HeaderHeight = 1 // Height of the header
54+ CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
55+ CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
56+ EditorHeight = 5 // Height of the editor input area including padding
57+ SideBarWidth = 31 // Width of the sidebar
58+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
59+ HeaderHeight = 1 // Height of the header
5960
6061 // Layout constants for borders and padding
6162 BorderWidth = 1 // Width of component borders
@@ -177,7 +178,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
177178 if p.forceCompact {
178179 p.setCompactMode(true)
179180 cmd = p.updateCompactConfig(true)
180− } else if p.width >= CompactModeBreakpoint {
181+ } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
181182 p.setCompactMode(false)
182183 cmd = p.updateCompactConfig(false)
183184 }
@@ -313,6 +314,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
313314}
314315
315316func (p *chatPage) Cursor() *tea.Cursor {
317+ if p.header.ShowingDetails() {
318+ return nil
319+ }
316320 switch p.focusedPane {
317321 case PanelTypeEditor:
318322 return p.editor.Cursor()
@@ -420,20 +424,20 @@ func (p *chatPage) setCompactMode(compact bool) {
420424 }
421425}
422426
423−func (p *chatPage) handleCompactMode(newWidth int) {
427+func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
424428 if p.forceCompact {
425429 return
426430 }
427− if newWidth < CompactModeBreakpoint && !p.compact {
431+ if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
428432 p.setCompactMode(true)
429433 }
430− if newWidth >= CompactModeBreakpoint && p.compact {
434+ if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
431435 p.setCompactMode(false)
432436 }
433437}
434438
435439func (p *chatPage) SetSize(width, height int) tea.Cmd {
436− p.handleCompactMode(width)
440+ p.handleCompactMode(width, height)
437441 p.width = width
438442 p.height = height
439443 var cmds []tea.Cmd
internal/tui/tui.go+4−4
@@ -315,7 +315,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
315315 // dialogs
316316 case key.Matches(msg, a.keyMap.Quit):
317317 if a.dialog.ActiveDialogID() == quit.QuitDialogID {
318− // if the quit dialog is already open, close the app
319318 return tea.Quit
320319 }
321320 return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -324,20 +323,21 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
324323
325324 case key.Matches(msg, a.keyMap.Commands):
326325 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
327− // If the commands dialog is already open, close it
328326 return util.CmdHandler(dialogs.CloseDialogMsg{})
329327 }
330328 if a.dialog.HasDialogs() {
331− return nil // Don't open commands dialog if another dialog is active
329+ return nil
332330 }
333331 return util.CmdHandler(dialogs.OpenDialogMsg{
334332 Model: commands.NewCommandDialog(a.selectedSessionID),
335333 })
336334 case key.Matches(msg, a.keyMap.Sessions):
337335 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
338− // If the sessions dialog is already open, close it
339336 return util.CmdHandler(dialogs.CloseDialogMsg{})
340337 }
338+ if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
339+ return nil
340+ }
341341 var cmds []tea.Cmd
342342 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
343343 // If the commands dialog is open, close it first