Detailed changes
@@ -4,7 +4,7 @@
- **Build**: `go build .` or `go run .`
- **Test**: `task test` or `go test ./...` (run single test: `go test ./internal/llm/prompt -run TestGetContextFromPaths`)
-- **Lint**: `task lint` (golangci-lint run) or `task lint-fix` (with --fix)
+- **Lint**: `task lint-fix`
- **Format**: `task fmt` (gofumpt -w .)
- **Dev**: `task dev` (runs with profiling enabled)
@@ -1,11 +1,14 @@
package cmd
import (
+ "bufio"
"encoding/json"
"fmt"
+ "io"
"os"
"path/filepath"
"slices"
+ "strings"
"time"
"github.com/charmbracelet/crush/internal/config"
@@ -14,20 +17,30 @@ import (
"github.com/spf13/cobra"
)
-func init() {
- rootCmd.AddCommand(logsCmd)
-}
-
var logsCmd = &cobra.Command{
Use: "logs",
Short: "View crush logs",
- Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
+ Long: `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring.`,
RunE: func(cmd *cobra.Command, args []string) error {
cwd, err := cmd.Flags().GetString("cwd")
if err != nil {
return fmt.Errorf("failed to get current working directory: %v", err)
}
+
+ follow, err := cmd.Flags().GetBool("follow")
+ if err != nil {
+ return fmt.Errorf("failed to get follow flag: %v", err)
+ }
+
+ tailLines, err := cmd.Flags().GetInt("tail")
+ if err != nil {
+ return fmt.Errorf("failed to get tail flag: %v", err)
+ }
+
log.SetLevel(log.DebugLevel)
+ // Configure log to output to stdout instead of stderr
+ log.SetOutput(os.Stdout)
+
cfg, err := config.Load(cwd, false)
if err != nil {
return fmt.Errorf("failed to load configuration: %v", err)
@@ -38,62 +51,155 @@ var logsCmd = &cobra.Command{
log.Warn("Looks like you are not in a crush project. No logs found.")
return nil
}
- t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
- if err != nil {
- return fmt.Errorf("failed to tail log file: %v", err)
- }
- // Print the text of each received line
- for line := range t.Lines {
- var data map[string]any
- if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
- continue
+ if follow {
+ // Follow mode - tail the file continuously
+ t, err := tail.TailFile(logsFile, tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
+ if err != nil {
+ return fmt.Errorf("failed to tail log file: %v", err)
}
- msg := data["msg"]
- level := data["level"]
- otherData := []any{}
- keys := []string{}
- for k := range data {
- keys = append(keys, k)
- }
- slices.Sort(keys)
- for _, k := range keys {
- switch k {
- case "msg", "level", "time":
- continue
- case "source":
- source, ok := data[k].(map[string]any)
- if !ok {
- continue
- }
- sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
- otherData = append(otherData, "source", sourceFile)
- default:
- otherData = append(otherData, k, data[k])
- }
+ // Print the text of each received line
+ for line := range t.Lines {
+ printLogLine(line.Text)
}
- log.SetTimeFunction(func(_ time.Time) time.Time {
- // parse the timestamp from the log line if available
- t, err := time.Parse(time.RFC3339, data["time"].(string))
+ } else if tailLines > 0 {
+ // Tail mode - show last N lines
+ lines, err := readLastNLines(logsFile, tailLines)
+ if err != nil {
+ return fmt.Errorf("failed to read last %d lines: %v", tailLines, err)
+ }
+ for _, line := range lines {
+ printLogLine(line)
+ }
+ } else {
+ // Oneshot mode - read the entire file once
+ file, err := os.Open(logsFile)
+ if err != nil {
+ return fmt.Errorf("failed to open log file: %v", err)
+ }
+ defer file.Close()
+
+ reader := bufio.NewReader(file)
+ for {
+ line, err := reader.ReadString('\n')
if err != nil {
- return time.Now() // fallback to current time if parsing fails
+ if err == io.EOF && line != "" {
+ // Handle last line without newline
+ printLogLine(line)
+ }
+ break
}
- return t
- })
- switch level {
- case "INFO":
- log.Info(msg, otherData...)
- case "DEBUG":
- log.Debug(msg, otherData...)
- case "ERROR":
- log.Error(msg, otherData...)
- case "WARN":
- log.Warn(msg, otherData...)
- default:
- log.Info(msg, otherData...)
+ // Remove trailing newline
+ line = strings.TrimSuffix(line, "\n")
+ printLogLine(line)
}
}
+
return nil
},
}
+
+func init() {
+ logsCmd.Flags().BoolP("follow", "f", false, "Follow log output")
+ logsCmd.Flags().IntP("tail", "t", 0, "Show only the last N lines")
+ rootCmd.AddCommand(logsCmd)
+}
+
+// readLastNLines reads the last N lines from a file using a simple circular buffer approach
+func readLastNLines(filename string, n int) ([]string, error) {
+ file, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ // Use a circular buffer to keep only the last N lines
+ lines := make([]string, n)
+ count := 0
+ index := 0
+
+ reader := bufio.NewReader(file)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF && line != "" {
+ // Handle last line without newline
+ line = strings.TrimSuffix(line, "\n")
+ lines[index] = line
+ count++
+ index = (index + 1) % n
+ }
+ break
+ }
+ // Remove trailing newline
+ line = strings.TrimSuffix(line, "\n")
+ lines[index] = line
+ count++
+ index = (index + 1) % n
+ }
+
+ // Extract the last N lines in correct order
+ if count <= n {
+ // We have fewer lines than requested, return them all
+ return lines[:count], nil
+ }
+
+ // We have more lines than requested, extract from circular buffer
+ result := make([]string, n)
+ for i := range n {
+ result[i] = lines[(index+i)%n]
+ }
+ return result, nil
+}
+
+func printLogLine(lineText string) {
+ var data map[string]any
+ if err := json.Unmarshal([]byte(lineText), &data); err != nil {
+ return
+ }
+ msg := data["msg"]
+ level := data["level"]
+ otherData := []any{}
+ keys := []string{}
+ for k := range data {
+ keys = append(keys, k)
+ }
+ slices.Sort(keys)
+ for _, k := range keys {
+ switch k {
+ case "msg", "level", "time":
+ continue
+ case "source":
+ source, ok := data[k].(map[string]any)
+ if !ok {
+ continue
+ }
+ sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
+ otherData = append(otherData, "source", sourceFile)
+
+ default:
+ otherData = append(otherData, k, data[k])
+ }
+ }
+ log.SetTimeFunction(func(_ time.Time) time.Time {
+ // parse the timestamp from the log line if available
+ t, err := time.Parse(time.RFC3339, data["time"].(string))
+ if err != nil {
+ return time.Now() // fallback to current time if parsing fails
+ }
+ return t
+ })
+ switch level {
+ case "INFO":
+ log.Info(msg, otherData...)
+ case "DEBUG":
+ log.Debug(msg, otherData...)
+ case "ERROR":
+ log.Error(msg, otherData...)
+ case "WARN":
+ log.Warn(msg, otherData...)
+ default:
+ log.Info(msg, otherData...)
+ }
+}
@@ -85,7 +85,6 @@ to assist developers in writing, debugging, and understanding code directly from
slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
return err
}
- // Defer shutdown here so it runs for both interactive and non-interactive modes
defer app.Shutdown()
// Initialize MCP tools early for both modes
@@ -107,6 +106,7 @@ to assist developers in writing, debugging, and understanding code directly from
program := tea.NewProgram(
tui.New(app),
tea.WithAltScreen(),
+ tea.WithContext(ctx),
tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
)
@@ -117,7 +117,6 @@ to assist developers in writing, debugging, and understanding code directly from
slog.Error(fmt.Sprintf("TUI run error: %v", err))
return fmt.Errorf("TUI error: %v", err)
}
- app.Shutdown()
return nil
},
}
@@ -282,6 +282,9 @@ func (app *App) Subscribe(program *tea.Program) {
// Shutdown performs a clean shutdown of the application
func (app *App) Shutdown() {
+ if app.CoderAgent != nil {
+ app.CoderAgent.CancelAll()
+ }
app.cancelFuncsMutex.Lock()
for _, cancel := range app.watcherCancelFuncs {
cancel()
@@ -301,9 +304,6 @@ func (app *App) Shutdown() {
}
cancel()
}
- if app.CoderAgent != nil {
- app.CoderAgent.CancelAll()
- }
for _, cleanup := range app.cleanupFuncs {
if cleanup != nil {
@@ -301,6 +301,7 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
defaultSmallModel := cfg.GetModel(string(p.ID), p.DefaultSmallModelID)
if defaultSmallModel == nil {
err = fmt.Errorf("default small model %s not found for provider %s", p.DefaultSmallModelID, p.ID)
+ return
}
smallModel = SelectedModel{
Provider: string(p.ID),
@@ -403,7 +403,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
agentMessage, toolResults, err := a.streamAndHandleEvents(ctx, sessionID, msgHistory)
if err != nil {
if errors.Is(err, context.Canceled) {
- agentMessage.AddFinish(message.FinishReasonCanceled)
+ agentMessage.AddFinish(message.FinishReasonCanceled, "Request cancelled", "")
a.messages.Update(context.Background(), agentMessage)
return a.err(ErrRequestCancelled)
}
@@ -454,11 +454,15 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
// Process each event in the stream.
for event := range eventChan {
if processErr := a.processEvent(ctx, sessionID, &assistantMsg, event); processErr != nil {
- a.finishMessage(ctx, &assistantMsg, message.FinishReasonCanceled)
+ if errors.Is(processErr, context.Canceled) {
+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
+ } else {
+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonError, "API Error", processErr.Error())
+ }
return assistantMsg, nil, processErr
}
if ctx.Err() != nil {
- a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
return assistantMsg, nil, ctx.Err()
}
}
@@ -468,7 +472,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
for i, toolCall := range toolCalls {
select {
case <-ctx.Done():
- a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled)
+ a.finishMessage(context.Background(), &assistantMsg, message.FinishReasonCanceled, "Request cancelled", "")
// Make all future tool calls cancelled
for j := i; j < len(toolCalls); j++ {
toolResults[j] = message.ToolResult{
@@ -516,7 +520,7 @@ func (a *agent) streamAndHandleEvents(ctx context.Context, sessionID string, msg
IsError: true,
}
}
- a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied)
+ a.finishMessage(ctx, &assistantMsg, message.FinishReasonPermissionDenied, "Permission denied", "")
break
}
}
@@ -548,8 +552,8 @@ out:
return assistantMsg, &msg, err
}
-func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason) {
- msg.AddFinish(finishReson)
+func (a *agent) finishMessage(ctx context.Context, msg *message.Message, finishReson message.FinishReason, message, details string) {
+ msg.AddFinish(finishReson, message, details)
_ = a.messages.Update(ctx, *msg)
}
@@ -580,15 +584,10 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
assistantMsg.FinishToolCall(event.ToolCall.ID)
return a.messages.Update(ctx, *assistantMsg)
case provider.EventError:
- if errors.Is(event.Error, context.Canceled) {
- slog.Info(fmt.Sprintf("Event processing canceled for session: %s", sessionID))
- return context.Canceled
- }
- slog.Error(event.Error.Error())
return event.Error
case provider.EventComplete:
assistantMsg.SetToolCalls(event.Response.ToolCalls)
- assistantMsg.AddFinish(event.Response.FinishReason)
+ assistantMsg.AddFinish(event.Response.FinishReason, "", "")
if err := a.messages.Update(ctx, *assistantMsg); err != nil {
return fmt.Errorf("failed to update message: %w", err)
}
@@ -801,6 +800,16 @@ func (a *agent) CancelAll() {
a.Cancel(key.(string)) // key is sessionID
return true
})
+
+ timeout := time.After(5 * time.Second)
+ for a.IsBusy() {
+ select {
+ case <-timeout:
+ return
+ default:
+ time.Sleep(200 * time.Millisecond)
+ }
+ }
}
func (a *agent) UpdateModel() error {
@@ -102,8 +102,10 @@ type ToolResult struct {
func (ToolResult) isPart() {}
type Finish struct {
- Reason FinishReason `json:"reason"`
- Time int64 `json:"time"`
+ Reason FinishReason `json:"reason"`
+ Time int64 `json:"time"`
+ Message string `json:"message,omitempty"`
+ Details string `json:"details,omitempty"`
}
func (Finish) isPart() {}
@@ -308,7 +310,7 @@ func (m *Message) SetToolResults(tr []ToolResult) {
}
}
-func (m *Message) AddFinish(reason FinishReason) {
+func (m *Message) AddFinish(reason FinishReason, message, details string) {
// remove any existing finish part
for i, part := range m.Parts {
if _, ok := part.(Finish); ok {
@@ -316,7 +318,7 @@ func (m *Message) AddFinish(reason FinishReason) {
break
}
}
- m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix()})
+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
}
func (m *Message) AddImageURL(url, detail string) {
@@ -337,7 +337,7 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
var cmds []tea.Cmd
for _, tc := range msg.ToolCalls() {
- if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
+ if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
cmds = append(cmds, cmd)
}
}
@@ -346,18 +346,21 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
}
// updateOrAddToolCall updates an existing tool call or adds a new one.
-func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
+func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
// Try to find existing tool call
for index, existingTC := range existingToolCalls {
if tc.ID == existingTC.GetToolCall().ID {
existingTC.SetToolCall(tc)
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+ existingTC.SetCancelled()
+ }
m.listCmp.UpdateItem(index, existingTC)
return nil
}
}
// Add new tool call if not found
- return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
+ return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
}
// handleNewAssistantMessage processes new assistant messages and their tool calls.
@@ -130,6 +130,9 @@ func (m *editorCmp) Init() tea.Cmd {
}
func (m *editorCmp) send() tea.Cmd {
+ if m.app.CoderAgent == nil {
+ return util.ReportError(fmt.Errorf("coder agent is not initialized"))
+ }
if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
return util.ReportWarn("Agent is working, please wait...")
}
@@ -21,6 +21,7 @@ type Header interface {
SetSession(session session.Session) tea.Cmd
SetWidth(width int) tea.Cmd
SetDetailsOpen(open bool)
+ ShowingDetails() bool
}
type header struct {
@@ -137,3 +138,8 @@ func (h *header) SetWidth(width int) tea.Cmd {
h.width = width
return nil
}
+
+// ShowingDetails implements Header.
+func (h *header) ShowingDetails() bool {
+ return h.detailsOpen
+}
@@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/fur/provider"
@@ -184,6 +185,7 @@ func (m *messageCmp) toMarkdown(content string) string {
// markdownContent processes the message content and handles special states.
// Returns appropriate content for thinking, finished, and error states.
func (m *messageCmp) markdownContent() string {
+ t := styles.CurrentTheme()
content := m.message.Content().String()
if m.message.Role == message.Assistant {
thinking := m.message.IsThinking()
@@ -199,6 +201,12 @@ func (m *messageCmp) markdownContent() string {
content = ""
} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
content = "*Canceled*"
+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonError {
+ errTag := t.S().Base.Padding(0, 1).Background(t.Red).Foreground(t.White).Render("ERROR")
+ truncated := ansi.Truncate(finishedData.Message, m.textWidth()-2-lipgloss.Width(errTag), "...")
+ title := fmt.Sprintf("%s %s", errTag, t.S().Base.Foreground(t.FgHalfMuted).Render(truncated))
+ details := t.S().Base.Foreground(t.FgSubtle).Width(m.textWidth() - 2).Render(finishedData.Details)
+ return fmt.Sprintf("%s\n\n%s", title, details)
}
}
return m.toMarkdown(content)
@@ -33,6 +33,16 @@ type FileHistory struct {
latestVersion history.File
}
+const LogoHeightBreakpoint = 40
+
+// Default maximum number of items to show in each section
+const (
+ DefaultMaxFilesShown = 10
+ DefaultMaxLSPsShown = 8
+ DefaultMaxMCPsShown = 8
+ MinItemsPerSection = 2 // Minimum items to show per section
+)
+
type SessionFile struct {
History FileHistory
FilePath string
@@ -100,8 +110,14 @@ func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *sidebarCmp) View() string {
t := styles.CurrentTheme()
parts := []string{}
+
if !m.compactMode {
- parts = append(parts, m.logo)
+ if m.height > LogoHeightBreakpoint {
+ parts = append(parts, m.logo)
+ } else {
+ // Use a smaller logo for smaller screens
+ parts = append(parts, m.smallerScreenLogo(), "")
+ }
}
if !m.compactMode && m.session.ID != "" {
@@ -119,15 +135,26 @@ func (m *sidebarCmp) View() string {
parts = append(parts,
m.currentModelBlock(),
)
- if m.session.ID != "" {
- parts = append(parts, "", m.filesBlock())
+
+ // Check if we should use horizontal layout for sections
+ if m.compactMode && m.width > m.height {
+ // Horizontal layout for compact mode when width > height
+ sectionsContent := m.renderSectionsHorizontal()
+ if sectionsContent != "" {
+ parts = append(parts, "", sectionsContent)
+ }
+ } else {
+ // Vertical layout (default)
+ if m.session.ID != "" {
+ parts = append(parts, "", m.filesBlock())
+ }
+ parts = append(parts,
+ "",
+ m.lspBlock(),
+ "",
+ m.mcpBlock(),
+ )
}
- parts = append(parts,
- "",
- m.lspBlock(),
- "",
- m.mcpBlock(),
- )
style := t.S().Base.
Width(m.width).
@@ -258,6 +285,328 @@ func (m *sidebarCmp) getMaxWidth() int {
return min(m.width-2, 58) // -2 for padding
}
+// calculateAvailableHeight estimates how much height is available for dynamic content
+func (m *sidebarCmp) calculateAvailableHeight() int {
+ usedHeight := 0
+
+ if !m.compactMode {
+ if m.height > LogoHeightBreakpoint {
+ usedHeight += 7 // Approximate logo height
+ } else {
+ usedHeight += 2 // Smaller logo height
+ }
+ usedHeight += 1 // Empty line after logo
+ }
+
+ if m.session.ID != "" {
+ usedHeight += 1 // Title line
+ usedHeight += 1 // Empty line after title
+ }
+
+ if !m.compactMode {
+ usedHeight += 1 // CWD line
+ usedHeight += 1 // Empty line after CWD
+ }
+
+ usedHeight += 2 // Model info
+
+ usedHeight += 6 // 3 sections Γ 2 lines each (header + empty line)
+
+ // Base padding
+ usedHeight += 2 // Top and bottom padding
+
+ return max(0, m.height-usedHeight)
+}
+
+// getDynamicLimits calculates how many items to show in each section based on available height
+func (m *sidebarCmp) getDynamicLimits() (maxFiles, maxLSPs, maxMCPs int) {
+ availableHeight := m.calculateAvailableHeight()
+
+ // If we have very little space, use minimum values
+ if availableHeight < 10 {
+ return MinItemsPerSection, MinItemsPerSection, MinItemsPerSection
+ }
+
+ // Distribute available height among the three sections
+ // Give priority to files, then LSPs, then MCPs
+ totalSections := 3
+ heightPerSection := availableHeight / totalSections
+
+ // Calculate limits for each section, ensuring minimums
+ maxFiles = max(MinItemsPerSection, min(DefaultMaxFilesShown, heightPerSection))
+ maxLSPs = max(MinItemsPerSection, min(DefaultMaxLSPsShown, heightPerSection))
+ maxMCPs = max(MinItemsPerSection, min(DefaultMaxMCPsShown, heightPerSection))
+
+ // If we have extra space, give it to files first
+ remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+ if remainingHeight > 0 {
+ extraForFiles := min(remainingHeight, DefaultMaxFilesShown-maxFiles)
+ maxFiles += extraForFiles
+ remainingHeight -= extraForFiles
+
+ if remainingHeight > 0 {
+ extraForLSPs := min(remainingHeight, DefaultMaxLSPsShown-maxLSPs)
+ maxLSPs += extraForLSPs
+ remainingHeight -= extraForLSPs
+
+ if remainingHeight > 0 {
+ maxMCPs += min(remainingHeight, DefaultMaxMCPsShown-maxMCPs)
+ }
+ }
+ }
+
+ return maxFiles, maxLSPs, maxMCPs
+}
+
+// renderSectionsHorizontal renders the files, LSPs, and MCPs sections horizontally
+func (m *sidebarCmp) renderSectionsHorizontal() string {
+ // Calculate available width for each section
+ totalWidth := m.width - 4 // Account for padding and spacing
+ sectionWidth := min(50, totalWidth/3)
+
+ // Get the sections content with limited height
+ var filesContent, lspContent, mcpContent string
+
+ filesContent = m.filesBlockCompact(sectionWidth)
+ lspContent = m.lspBlockCompact(sectionWidth)
+ mcpContent = m.mcpBlockCompact(sectionWidth)
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, filesContent, " ", lspContent, " ", mcpContent)
+}
+
+// filesBlockCompact renders the files block with limited width and height for horizontal layout
+func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
+ t := styles.CurrentTheme()
+
+ section := t.S().Subtle.Render("Modified Files")
+
+ files := make([]SessionFile, 0)
+ m.files.Range(func(key, value any) bool {
+ file := value.(SessionFile)
+ files = append(files, file)
+ return true
+ })
+
+ if len(files) == 0 {
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Base.Foreground(t.Border).Render("None"),
+ )
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+ }
+
+ fileList := []string{section, ""}
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
+ })
+
+ // Limit items for horizontal layout - use less space
+ maxItems := min(5, len(files))
+ availableHeight := m.height - 8 // Reserve space for header and other content
+ if availableHeight > 0 {
+ maxItems = min(maxItems, availableHeight)
+ }
+
+ filesShown := 0
+ for _, file := range files {
+ if file.Additions == 0 && file.Deletions == 0 {
+ continue
+ }
+ if filesShown >= maxItems {
+ break
+ }
+
+ var statusParts []string
+ if file.Additions > 0 {
+ statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
+ }
+ if file.Deletions > 0 {
+ statusParts = append(statusParts, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("-%d", file.Deletions)))
+ }
+
+ extraContent := strings.Join(statusParts, " ")
+ cwd := config.Get().WorkingDir() + string(os.PathSeparator)
+ filePath := file.FilePath
+ filePath = strings.TrimPrefix(filePath, cwd)
+ filePath = fsext.DirTrim(fsext.PrettyPath(filePath), 2)
+ filePath = ansi.Truncate(filePath, maxWidth-lipgloss.Width(extraContent)-2, "β¦")
+
+ fileList = append(fileList,
+ core.Status(
+ core.StatusOpts{
+ IconColor: t.FgMuted,
+ NoIcon: true,
+ Title: filePath,
+ ExtraContent: extraContent,
+ },
+ maxWidth,
+ ),
+ )
+ filesShown++
+ }
+
+ // Add "..." indicator if there are more files
+ totalFilesWithChanges := 0
+ for _, file := range files {
+ if file.Additions > 0 || file.Deletions > 0 {
+ totalFilesWithChanges++
+ }
+ }
+ if totalFilesWithChanges > maxItems {
+ fileList = append(fileList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
+ }
+
+ content := lipgloss.JoinVertical(lipgloss.Left, fileList...)
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// lspBlockCompact renders the LSP block with limited width and height for horizontal layout
+func (m *sidebarCmp) lspBlockCompact(maxWidth int) string {
+ t := styles.CurrentTheme()
+
+ section := t.S().Subtle.Render("LSPs")
+
+ lspList := []string{section, ""}
+
+ lsp := config.Get().LSP.Sorted()
+ if len(lsp) == 0 {
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Base.Foreground(t.Border).Render("None"),
+ )
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+ }
+
+ // Limit items for horizontal layout
+ maxItems := min(5, len(lsp))
+ availableHeight := m.height - 8
+ if availableHeight > 0 {
+ maxItems = min(maxItems, availableHeight)
+ }
+
+ for i, l := range lsp {
+ if i >= maxItems {
+ break
+ }
+
+ iconColor := t.Success
+ if l.LSP.Disabled {
+ iconColor = t.FgMuted
+ }
+
+ lspErrs := map[protocol.DiagnosticSeverity]int{
+ protocol.SeverityError: 0,
+ protocol.SeverityWarning: 0,
+ protocol.SeverityHint: 0,
+ protocol.SeverityInformation: 0,
+ }
+ if client, ok := m.lspClients[l.Name]; ok {
+ for _, diagnostics := range client.GetDiagnostics() {
+ for _, diagnostic := range diagnostics {
+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
+ lspErrs[diagnostic.Severity] = severity + 1
+ }
+ }
+ }
+ }
+
+ errs := []string{}
+ if lspErrs[protocol.SeverityError] > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+ }
+ if lspErrs[protocol.SeverityWarning] > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+ }
+ if lspErrs[protocol.SeverityHint] > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+ }
+ if lspErrs[protocol.SeverityInformation] > 0 {
+ errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+ }
+
+ lspList = append(lspList,
+ core.Status(
+ core.StatusOpts{
+ IconColor: iconColor,
+ Title: l.Name,
+ Description: l.LSP.Command,
+ ExtraContent: strings.Join(errs, " "),
+ },
+ maxWidth,
+ ),
+ )
+ }
+
+ // Add "..." indicator if there are more LSPs
+ if len(lsp) > maxItems {
+ lspList = append(lspList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
+ }
+
+ content := lipgloss.JoinVertical(lipgloss.Left, lspList...)
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
+// mcpBlockCompact renders the MCP block with limited width and height for horizontal layout
+func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
+ t := styles.CurrentTheme()
+
+ section := t.S().Subtle.Render("MCPs")
+
+ mcpList := []string{section, ""}
+
+ mcps := config.Get().MCP.Sorted()
+ if len(mcps) == 0 {
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ section,
+ "",
+ t.S().Base.Foreground(t.Border).Render("None"),
+ )
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+ }
+
+ // Limit items for horizontal layout
+ maxItems := min(5, len(mcps))
+ availableHeight := m.height - 8
+ if availableHeight > 0 {
+ maxItems = min(maxItems, availableHeight)
+ }
+
+ for i, l := range mcps {
+ if i >= maxItems {
+ break
+ }
+
+ iconColor := t.Success
+ if l.MCP.Disabled {
+ iconColor = t.FgMuted
+ }
+
+ mcpList = append(mcpList,
+ core.Status(
+ core.StatusOpts{
+ IconColor: iconColor,
+ Title: l.Name,
+ Description: l.MCP.Command,
+ },
+ maxWidth,
+ ),
+ )
+ }
+
+ // Add "..." indicator if there are more MCPs
+ if len(mcps) > maxItems {
+ mcpList = append(mcpList, t.S().Base.Foreground(t.FgMuted).Render("β¦"))
+ }
+
+ content := lipgloss.JoinVertical(lipgloss.Left, mcpList...)
+ return lipgloss.NewStyle().Width(maxWidth).Render(content)
+}
+
func (m *sidebarCmp) filesBlock() string {
t := styles.CurrentTheme()
@@ -286,10 +635,19 @@ func (m *sidebarCmp) filesBlock() string {
return files[i].History.latestVersion.CreatedAt > files[j].History.latestVersion.CreatedAt
})
+ // Limit the number of files shown
+ maxFiles, _, _ := m.getDynamicLimits()
+ maxFiles = min(len(files), maxFiles)
+ filesShown := 0
+
for _, file := range files {
if file.Additions == 0 && file.Deletions == 0 {
continue // skip files with no changes
}
+ if filesShown >= maxFiles {
+ break
+ }
+
var statusParts []string
if file.Additions > 0 {
statusParts = append(statusParts, t.S().Base.Foreground(t.Success).Render(fmt.Sprintf("+%d", file.Additions)))
@@ -315,6 +673,21 @@ func (m *sidebarCmp) filesBlock() string {
m.getMaxWidth(),
),
)
+ filesShown++
+ }
+
+ // Add indicator if there are more files
+ totalFilesWithChanges := 0
+ for _, file := range files {
+ if file.Additions > 0 || file.Deletions > 0 {
+ totalFilesWithChanges++
+ }
+ }
+ if totalFilesWithChanges > maxFiles {
+ remaining := totalFilesWithChanges - maxFiles
+ fileList = append(fileList,
+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
+ )
}
return lipgloss.JoinVertical(
@@ -342,7 +715,14 @@ func (m *sidebarCmp) lspBlock() string {
)
}
- for _, l := range lsp {
+ // Limit the number of LSPs shown
+ _, maxLSPs, _ := m.getDynamicLimits()
+ maxLSPs = min(len(lsp), maxLSPs)
+ for i, l := range lsp {
+ if i >= maxLSPs {
+ break
+ }
+
iconColor := t.Success
if l.LSP.Disabled {
iconColor = t.FgMuted
@@ -390,6 +770,14 @@ func (m *sidebarCmp) lspBlock() string {
)
}
+ // Add indicator if there are more LSPs
+ if len(lsp) > maxLSPs {
+ remaining := len(lsp) - maxLSPs
+ lspList = append(lspList,
+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
+ )
+ }
+
return lipgloss.JoinVertical(
lipgloss.Left,
lspList...,
@@ -415,7 +803,14 @@ func (m *sidebarCmp) mcpBlock() string {
)
}
- for _, l := range mcps {
+ // Limit the number of MCPs shown
+ _, _, maxMCPs := m.getDynamicLimits()
+ maxMCPs = min(len(mcps), maxMCPs)
+ for i, l := range mcps {
+ if i >= maxMCPs {
+ break
+ }
+
iconColor := t.Success
if l.MCP.Disabled {
iconColor = t.FgMuted
@@ -432,6 +827,14 @@ func (m *sidebarCmp) mcpBlock() string {
)
}
+ // Add indicator if there are more MCPs
+ if len(mcps) > maxMCPs {
+ remaining := len(mcps) - maxMCPs
+ mcpList = append(mcpList,
+ t.S().Base.Foreground(t.FgMuted).Render(fmt.Sprintf("β¦ and %d more", remaining)),
+ )
+ }
+
return lipgloss.JoinVertical(
lipgloss.Left,
mcpList...,
@@ -504,6 +907,19 @@ func (s *sidebarCmp) currentModelBlock() string {
)
}
+func (m *sidebarCmp) smallerScreenLogo() string {
+ t := styles.CurrentTheme()
+ title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
+ title += " " + styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary)
+ remainingWidth := m.width - lipgloss.Width(title) - 3
+ if remainingWidth > 0 {
+ char := "β±"
+ lines := strings.Repeat(char, remainingWidth)
+ title += " " + t.S().Base.Foreground(t.Primary).Render(lines)
+ }
+ return title
+}
+
// SetSession implements Sidebar.
func (m *sidebarCmp) SetSession(session session.Session) tea.Cmd {
m.session = session
@@ -192,10 +192,8 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, s.initializeProject()
}
case key.Matches(msg, s.keyMap.No):
- if s.needsProjectInit {
- s.needsProjectInit = false
- return s, util.CmdHandler(OnboardingCompleteMsg{})
- }
+ s.selectedNo = true
+ return s, s.initializeProject()
default:
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
@@ -0,0 +1,147 @@
+package core_test
+
+import (
+ "fmt"
+ "image/color"
+ "testing"
+
+ "github.com/charmbracelet/crush/internal/tui/components/core"
+ "github.com/charmbracelet/x/exp/golden"
+)
+
+func TestStatus(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ opts core.StatusOpts
+ width int
+ }{
+ {
+ name: "Default",
+ opts: core.StatusOpts{
+ Title: "Status",
+ Description: "Everything is working fine",
+ },
+ width: 80,
+ },
+ {
+ name: "WithCustomIcon",
+ opts: core.StatusOpts{
+ Icon: "β",
+ Title: "Success",
+ Description: "Operation completed successfully",
+ },
+ width: 80,
+ },
+ {
+ name: "NoIcon",
+ opts: core.StatusOpts{
+ NoIcon: true,
+ Title: "Info",
+ Description: "This status has no icon",
+ },
+ width: 80,
+ },
+ {
+ name: "WithColors",
+ opts: core.StatusOpts{
+ Icon: "β ",
+ IconColor: color.RGBA{255, 165, 0, 255}, // Orange
+ Title: "Warning",
+ TitleColor: color.RGBA{255, 255, 0, 255}, // Yellow
+ Description: "This is a warning message",
+ DescriptionColor: color.RGBA{255, 0, 0, 255}, // Red
+ },
+ width: 80,
+ },
+ {
+ name: "WithExtraContent",
+ opts: core.StatusOpts{
+ Title: "Build",
+ Description: "Building project",
+ ExtraContent: "[2/5]",
+ },
+ width: 80,
+ },
+ {
+ name: "LongDescription",
+ opts: core.StatusOpts{
+ Title: "Processing",
+ Description: "This is a very long description that should be truncated when the width is too small to display it completely without wrapping",
+ },
+ width: 60,
+ },
+ {
+ name: "NarrowWidth",
+ opts: core.StatusOpts{
+ Icon: "β",
+ Title: "Status",
+ Description: "Short message",
+ },
+ width: 30,
+ },
+ {
+ name: "VeryNarrowWidth",
+ opts: core.StatusOpts{
+ Icon: "β",
+ Title: "Test",
+ Description: "This will be truncated",
+ },
+ width: 20,
+ },
+ {
+ name: "EmptyDescription",
+ opts: core.StatusOpts{
+ Icon: "β",
+ Title: "Title Only",
+ },
+ width: 80,
+ },
+ {
+ name: "AllFieldsWithExtraContent",
+ opts: core.StatusOpts{
+ Icon: "π",
+ IconColor: color.RGBA{0, 255, 0, 255}, // Green
+ Title: "Deployment",
+ TitleColor: color.RGBA{0, 0, 255, 255}, // Blue
+ Description: "Deploying to production environment",
+ DescriptionColor: color.RGBA{128, 128, 128, 255}, // Gray
+ ExtraContent: "v1.2.3",
+ },
+ width: 80,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ output := core.Status(tt.opts, tt.width)
+ golden.RequireEqual(t, []byte(output))
+ })
+ }
+}
+
+func TestStatusTruncation(t *testing.T) {
+ t.Parallel()
+
+ opts := core.StatusOpts{
+ Icon: "β",
+ Title: "Very Long Title",
+ Description: "This is an extremely long description that definitely needs to be truncated",
+ ExtraContent: "[extra]",
+ }
+
+ // Test different widths to ensure truncation works correctly
+ widths := []int{20, 30, 40, 50, 60}
+
+ for _, width := range widths {
+ t.Run(fmt.Sprintf("Width%d", width), func(t *testing.T) {
+ t.Parallel()
+
+ output := core.Status(opts, width)
+ golden.RequireEqual(t, []byte(output))
+ })
+ }
+}
@@ -0,0 +1 @@
+[38;2;0;255;0mπ[m [38;2;0;0;255mDeployment[m [38;2;128;128;128mDeploying to production environment[m v1.2.3
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mStatus[m [38;2;96;95;107mEverything is working fine[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mTitle Only[m [38;2;96;95;107m[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mProcessing[m [38;2;96;95;107mThis is a very long description that should beβ¦[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mStatus[m [38;2;96;95;107mShort message[m
@@ -0,0 +1 @@
+[38;2;133;131;146mInfo[m [38;2;96;95;107mThis status has no icon[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mTest[m [38;2;96;95;107mThis will beβ¦[m
@@ -0,0 +1 @@
+[38;2;255;165;0mβ [m [38;2;255;255;0mWarning[m [38;2;255;0;0mThis is a warning message[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mSuccess[m [38;2;96;95;107mOperation completed successfully[m
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mBuild[m [38;2;96;95;107mBuilding project[m [2/5]
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mVery Long Title[m [38;2;96;95;107m[m [extra]
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThiβ¦[m [extra]
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an exβ¦[m [extra]
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an extremely loβ¦[m [extra]
@@ -0,0 +1 @@
+[38;2;18;199;143mβ[m [38;2;133;131;146mVery Long Title[m [38;2;96;95;107mThis is an extremely long descripβ¦[m [extra]
@@ -60,6 +60,9 @@ type permissionDialogCmp struct {
cachedContent string
contentDirty bool
+ positionRow int // Row position for dialog
+ positionCol int // Column position for dialog
+
keyMap KeyMap
}
@@ -446,6 +449,10 @@ func (p *permissionDialogCmp) render() string {
p.contentViewPort.SetHeight(contentHeight)
p.contentViewPort.SetContent(contentFinal)
+ p.positionRow = p.wHeight / 2
+ p.positionRow -= (contentHeight + 9) / 2
+ p.positionRow -= 3 // Move dialog slightly higher than middle
+
var contentHelp string
if p.supportsDiffView() {
contentHelp = help.New().View(p.keyMap)
@@ -509,7 +516,11 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd {
if oldWidth != p.width || oldHeight != p.height {
p.contentDirty = true
}
-
+ p.positionRow = p.wHeight / 2
+ p.positionRow -= p.height / 2
+ p.positionRow -= 3 // Move dialog slightly higher than middle
+ p.positionCol = p.wWidth / 2
+ p.positionCol -= p.width / 2
return nil
}
@@ -529,9 +540,5 @@ func (p *permissionDialogCmp) ID() dialogs.DialogID {
// Position implements PermissionDialogCmp.
func (p *permissionDialogCmp) Position() (int, int) {
- row := (p.wHeight / 2) - 2 // Just a bit above the center
- row -= p.height / 2
- col := p.wWidth / 2
- col -= p.width / 2
- return row, col
+ return p.positionRow, p.positionCol
}
@@ -51,11 +51,12 @@ const (
)
const (
- CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
- EditorHeight = 5 // Height of the editor input area including padding
- SideBarWidth = 31 // Width of the sidebar
- SideBarDetailsPadding = 1 // Padding for the sidebar details section
- HeaderHeight = 1 // Height of the header
+ CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
+ CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
+ EditorHeight = 5 // Height of the editor input area including padding
+ SideBarWidth = 31 // Width of the sidebar
+ SideBarDetailsPadding = 1 // Padding for the sidebar details section
+ HeaderHeight = 1 // Height of the header
// Layout constants for borders and padding
BorderWidth = 1 // Width of component borders
@@ -177,7 +178,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if p.forceCompact {
p.setCompactMode(true)
cmd = p.updateCompactConfig(true)
- } else if p.width >= CompactModeBreakpoint {
+ } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
p.setCompactMode(false)
cmd = p.updateCompactConfig(false)
}
@@ -313,6 +314,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (p *chatPage) Cursor() *tea.Cursor {
+ if p.header.ShowingDetails() {
+ return nil
+ }
switch p.focusedPane {
case PanelTypeEditor:
return p.editor.Cursor()
@@ -420,20 +424,20 @@ func (p *chatPage) setCompactMode(compact bool) {
}
}
-func (p *chatPage) handleCompactMode(newWidth int) {
+func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
if p.forceCompact {
return
}
- if newWidth < CompactModeBreakpoint && !p.compact {
+ if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
p.setCompactMode(true)
}
- if newWidth >= CompactModeBreakpoint && p.compact {
+ if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
p.setCompactMode(false)
}
}
func (p *chatPage) SetSize(width, height int) tea.Cmd {
- p.handleCompactMode(width)
+ p.handleCompactMode(width, height)
p.width = width
p.height = height
var cmds []tea.Cmd
@@ -315,7 +315,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
// dialogs
case key.Matches(msg, a.keyMap.Quit):
if a.dialog.ActiveDialogID() == quit.QuitDialogID {
- // if the quit dialog is already open, close the app
return tea.Quit
}
return util.CmdHandler(dialogs.OpenDialogMsg{
@@ -324,20 +323,21 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
case key.Matches(msg, a.keyMap.Commands):
if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
- // If the commands dialog is already open, close it
return util.CmdHandler(dialogs.CloseDialogMsg{})
}
if a.dialog.HasDialogs() {
- return nil // Don't open commands dialog if another dialog is active
+ return nil
}
return util.CmdHandler(dialogs.OpenDialogMsg{
Model: commands.NewCommandDialog(a.selectedSessionID),
})
case key.Matches(msg, a.keyMap.Sessions):
if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
- // If the sessions dialog is already open, close it
return util.CmdHandler(dialogs.CloseDialogMsg{})
}
+ if a.dialog.HasDialogs() && a.dialog.ActiveDialogID() != commands.CommandsDialogID {
+ return nil
+ }
var cmds []tea.Cmd
if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
// If the commands dialog is open, close it first