diff --git a/CRUSH.md b/CRUSH.md index c308db631e006dd1c3834b6b470a02f4c41ff53b..06c9b99e593596029212b8d4d95024b310a2d003 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -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) diff --git a/cmd/logs.go b/cmd/logs.go index d690625de90d8a0f7ec42d366f5d414c21dbebf0..bb0aaf9d7b8c2cbcf1da8823c2848002f6d2e252 100644 --- a/cmd/logs.go +++ b/cmd/logs.go @@ -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...) + } +} diff --git a/cmd/root.go b/cmd/root.go index c02dea5d2b7882291fa78f2bb7346a0cc3fefe82..6926255481d796b367a8678926511db8a25e74b7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 }, } diff --git a/internal/app/app.go b/internal/app/app.go index c3dae3d88a2be7c4cd5491e089b97695b08a7a23..c3469c8cceb1bb91ff5bb1566738ea02079053a1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/config/load.go b/internal/config/load.go index d80a1ebfccddc509e983a6bc17084931c2a7dec3..81cb4398e5b3a7a2147ab5388b37088788ea041b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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), diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index fbb5b4fd8c6390ff0dfad0e072af35342355ba41..56fb431b3b705a656cdfbf9df426b8ce8c7298c4 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -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 { diff --git a/internal/message/content.go b/internal/message/content.go index 3ab53e381aaf7755c141985ebe740dbc44356471..b8d2c1aa370559977f4c8eb80803ab5fbfe83cf9 100644 --- a/internal/message/content.go +++ b/internal/message/content.go @@ -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) { diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 0e6a95937476de9f33b1c5c0dd15e0489c645c43..71f6e1e66ed7d6d1ad80486c1017d02af14b11f4 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -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. diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 67ba67f5e6c40f16a89f7bc4fe1b6932c9989754..9a0f5d3d191d722f4be2e48a40b730b255bf01d1 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/internal/tui/components/chat/editor/editor.go @@ -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...") } diff --git a/internal/tui/components/chat/header/header.go b/internal/tui/components/chat/header/header.go index 5d27cc14fdf341ea3f201876f80f7edd7f1ce328..4eac0c2444321a59c06d2e83d328fd1ea9e8512c 100644 --- a/internal/tui/components/chat/header/header.go +++ b/internal/tui/components/chat/header/header.go @@ -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 +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index e8ae97056728a0377ddcad179ecae1246f2da662..bfb8af47b6bd13eb2e1e9fb844b1935a6fccbd4d 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -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) diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index 3fa08ce021d0fcac1ce7dc9668d46198f6d08055..cf1fd12dff512475fa77f0cd9fb657646c0cc2fd 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -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 diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 5b343e6c5538cc17b476e521e6f2bfaf6b3490cb..c05e2d9947222298dd141c9e762d33f99883b467 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -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) diff --git a/internal/tui/components/core/status_test.go b/internal/tui/components/core/status_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0b24dc321d8863c8bad2bc4fc38e38020230a7f5 --- /dev/null +++ b/internal/tui/components/core/status_test.go @@ -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)) + }) + } +} diff --git a/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden new file mode 100644 index 0000000000000000000000000000000000000000..e6f7fb0be25997b79c3d39bddedee2f2d7b11b72 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden @@ -0,0 +1 @@ +🚀 Deployment Deploying to production environment v1.2.3 \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/Default.golden b/internal/tui/components/core/testdata/TestStatus/Default.golden new file mode 100644 index 0000000000000000000000000000000000000000..a0066dedd418dafe54757dc3159b3a6b11d106ca --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/Default.golden @@ -0,0 +1 @@ +● Status Everything is working fine \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden new file mode 100644 index 0000000000000000000000000000000000000000..f9c4d759b50d02598791a6462f8e9cab2e0a0b6d --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden @@ -0,0 +1 @@ +● Title Only  \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/LongDescription.golden b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden new file mode 100644 index 0000000000000000000000000000000000000000..f008176649f7941b9f1ee6276f6e65fea36d4c52 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/LongDescription.golden @@ -0,0 +1 @@ +● Processing This is a very long description that should be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden new file mode 100644 index 0000000000000000000000000000000000000000..5b9efd7dbb74dcf56344567c1918b470f90eace7 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden @@ -0,0 +1 @@ +● Status Short message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/NoIcon.golden b/internal/tui/components/core/testdata/TestStatus/NoIcon.golden new file mode 100644 index 0000000000000000000000000000000000000000..09e14574c853264a4b18dfafcfac256b38045a02 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/NoIcon.golden @@ -0,0 +1 @@ +Info This status has no icon \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden new file mode 100644 index 0000000000000000000000000000000000000000..26628ae3bc28acd49e8f30e60f65912fe563c0e6 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden @@ -0,0 +1 @@ +● Test This will be… \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithColors.golden b/internal/tui/components/core/testdata/TestStatus/WithColors.golden new file mode 100644 index 0000000000000000000000000000000000000000..ff0e3a6ec4847c4786387d26c9752f664d78cd51 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/WithColors.golden @@ -0,0 +1 @@ +⚠ Warning This is a warning message \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden new file mode 100644 index 0000000000000000000000000000000000000000..6857f0d29dd58886308e15ea50c7e0822834f2ee --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden @@ -0,0 +1 @@ +✓ Success Operation completed successfully \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden new file mode 100644 index 0000000000000000000000000000000000000000..47b02e81b5ec4fc0d0c5dd54545d9634811b1636 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden @@ -0,0 +1 @@ +● Build Building project [2/5] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden new file mode 100644 index 0000000000000000000000000000000000000000..4437cba67aa068c2597e558000b9b3005478b378 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden @@ -0,0 +1 @@ +● Very Long Title  [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden new file mode 100644 index 0000000000000000000000000000000000000000..b09cc983c97382e4d92719bb5606d22f9dc2301f --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden @@ -0,0 +1 @@ +● Very Long Title Thi… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden new file mode 100644 index 0000000000000000000000000000000000000000..5113ce07a0b07d1cfddbcbae0c14046546308f2a --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden @@ -0,0 +1 @@ +● Very Long Title This is an ex… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden new file mode 100644 index 0000000000000000000000000000000000000000..25bd8723b0cd461311364ecaac92a2b93f00ecd9 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden @@ -0,0 +1 @@ +● Very Long Title This is an extremely lo… [extra] \ No newline at end of file diff --git a/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden new file mode 100644 index 0000000000000000000000000000000000000000..0152f1c2d0ac9e011d744e0cd02283c18edc8d03 --- /dev/null +++ b/internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden @@ -0,0 +1 @@ +● Very Long Title This is an extremely long descrip… [extra] \ No newline at end of file diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 6bac6e58b37a99b376ad936bbf19f541b999eb4b..0bbaa034ed2357cc4643ad92c0a680bb01cf61ff 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -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 } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 5c4b7738580db046920ac7812c7a493c21e996ee..07fac7133a6003ad951962c8dd5ad55c52bcb67f 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -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 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 365db72299865897feb94879f837baa93bff5e43..0b10b74792c5cc6c91dc285d42a7d9a6736c2b90 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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