From fbfa0f55fb25791ae009fae17a35e0e802b8efdc Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 10:58:03 +0200 Subject: [PATCH 01/12] chore: correctly mark project as initialized --- internal/tui/components/chat/splash/splash.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 722aaea6f75c6ef0bef7e0a9ec2de319c6d71bfb..98318c86a13124e9b9edc523a7601a81ff728afd 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) From 01afa93dc9f5d26d1c0808f38551af53ca3109d3 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 11:07:27 +0200 Subject: [PATCH 02/12] chore: fix panic on message send --- internal/tui/components/chat/editor/editor.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 2185715c813dbdcb288bddde0fe70d63046cf731..116260c9d35dbe0539f303745b9c3b19e202e92c 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...") } From 104514804c322138f3d6e5f7a7d4a94ebe198ca1 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 11 Jul 2025 18:43:02 +0200 Subject: [PATCH 03/12] chore: center permissions --- .../dialogs/permissions/permissions.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 6bac6e58b37a99b376ad936bbf19f541b999eb4b..1bfb31186a596741da25566fcd15ad9fdf9c64eb 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,9 @@ func (p *permissionDialogCmp) render() string { p.contentViewPort.SetHeight(contentHeight) p.contentViewPort.SetContent(contentFinal) + p.positionRow = p.wHeight / 2 + p.positionRow -= (contentHeight + 9) / 2 + var contentHelp string if p.supportsDiffView() { contentHelp = help.New().View(p.keyMap) @@ -509,7 +515,10 @@ 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.positionCol = p.wWidth / 2 + p.positionCol -= p.width / 2 return nil } @@ -529,9 +538,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 } From a28244948f413b333ebd84315c68008eb78d658f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 11:34:21 +0200 Subject: [PATCH 04/12] chore: prevent multiple dialogs from opening --- internal/tui/tui.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From 31a3c05a541522fe6f6d65ac1622846e2286ab8a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 12:14:18 +0200 Subject: [PATCH 05/12] chore: fix cancellation --- internal/app/app.go | 6 +++--- internal/llm/agent/agent.go | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 9d0e6f176b14df0b15fd90f4b3651cdefafd6826..4054287fd721a4e169faaff4bc224a8dec13e106 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -279,6 +279,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() @@ -298,9 +301,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/llm/agent/agent.go b/internal/llm/agent/agent.go index fbb5b4fd8c6390ff0dfad0e072af35342355ba41..2ea3f23423cb4f620d5a58eda63dd71fb991ea46 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -801,6 +801,13 @@ func (a *agent) CancelAll() { a.Cancel(key.(string)) // key is sessionID return true }) + for { + if a.IsBusy() { + time.Sleep(200 * time.Millisecond) + } else { + break + } + } } func (a *agent) UpdateModel() error { From 2f9aa8d4bdeff47197c5b1e291b89b6fd3934c4a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 12:27:49 +0200 Subject: [PATCH 06/12] chore: handle cancel when panic --- cmd/root.go | 30 ++++++++++++++++++++++-------- main.go | 18 +++++++++++++++++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3a8f4fba0fe759a42ef1e7647223b2b3b11fbc65..4c179c8295f3532a59f2efed7bf17606a3c50304 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os" + "sync" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -72,9 +73,8 @@ to assist developers in writing, debugging, and understanding code directly from return err } - // Create main context for the application - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + // Use the context from the command which includes signal handling + ctx := cmd.Context() // Connect DB, this will also run migrations conn, err := db.Connect(ctx, cfg.Options.DataDirectory) @@ -87,8 +87,23 @@ 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() + + // Set up shutdown handling that works for both normal exit and signal interruption + var shutdownOnce sync.Once + shutdown := func() { + shutdownOnce.Do(func() { + slog.Info("Shutting down application") + app.Shutdown() + }) + } + defer shutdown() + + // Handle context cancellation (from signals) in a goroutine + go func() { + <-ctx.Done() + slog.Info("Context cancelled, initiating shutdown") + shutdown() + }() // Initialize MCP tools early for both modes initMCPTools(ctx, app, cfg) @@ -121,7 +136,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 }, } @@ -140,9 +154,9 @@ func initMCPTools(ctx context.Context, app *app.App, cfg *config.Config) { }() } -func Execute() { +func Execute(ctx context.Context) { if err := fang.Execute( - context.Background(), + ctx, rootCmd, fang.WithVersion(version.Version), ); err != nil { diff --git a/main.go b/main.go index 7715d5e4f7023b48cf242dc5b559554a2a63be28..b8fa1f57d42955fb29ce65bcaf47cd44f6c0e17b 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "context" "fmt" "log/slog" "net/http" "os" + "os/signal" + "syscall" _ "net/http/pprof" // profiling @@ -19,6 +22,19 @@ func main() { slog.Error("Application terminated due to unhandled panic") }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) + + // Start signal handler in a goroutine + go func() { + sig := <-sigChan + slog.Info("Received signal, initiating graceful shutdown", "signal", sig) + cancel() + }() + if os.Getenv("CRUSH_PROFILE") != "" { go func() { slog.Info("Serving pprof at localhost:6060") @@ -28,5 +44,5 @@ func main() { }() } - cmd.Execute() + cmd.Execute(ctx) } From febf526d03c8628504295767339fb3f99653926b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 12:33:21 +0200 Subject: [PATCH 07/12] chore: handle tools cancelled --- internal/tui/components/chat/chat.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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. From df84bdf59cebddb4f1659b7cabc93eec860a4fdc Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 14:00:46 +0200 Subject: [PATCH 08/12] chore: show API errors --- internal/llm/agent/agent.go | 25 +++++++++---------- internal/message/content.go | 10 +++++--- .../tui/components/chat/messages/messages.go | 9 +++++++ 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 2ea3f23423cb4f620d5a58eda63dd71fb991ea46..6c7844eaa2811570824fba489a8c7a7581fa201f 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) } 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/messages/messages.go b/internal/tui/components/chat/messages/messages.go index e8ae97056728a0377ddcad179ecae1246f2da662..657533d9f0f13fcad404f76d8e19999710800050 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,13 @@ 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) From 01d43b2668705c03c0824313a3990074b6f5ffcf Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 14:05:26 +0200 Subject: [PATCH 09/12] chore: lint --- internal/tui/components/chat/messages/messages.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 657533d9f0f13fcad404f76d8e19999710800050..bfb8af47b6bd13eb2e1e9fb844b1935a6fccbd4d 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -207,7 +207,6 @@ func (m *messageCmp) markdownContent() string { 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) From 308ba1a1aff4331f794d73e1d122951e35ed4372 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 14 Jul 2025 14:19:06 +0200 Subject: [PATCH 10/12] chore: add some status tests --- internal/tui/components/core/status_test.go | 147 ++++++++++++++++++ .../AllFieldsWithExtraContent.golden | 1 + .../core/testdata/TestStatus/Default.golden | 1 + .../TestStatus/EmptyDescription.golden | 1 + .../TestStatus/LongDescription.golden | 1 + .../testdata/TestStatus/NarrowWidth.golden | 1 + .../core/testdata/TestStatus/NoIcon.golden | 1 + .../TestStatus/VeryNarrowWidth.golden | 1 + .../testdata/TestStatus/WithColors.golden | 1 + .../testdata/TestStatus/WithCustomIcon.golden | 1 + .../TestStatus/WithExtraContent.golden | 1 + .../TestStatusTruncation/Width20.golden | 1 + .../TestStatusTruncation/Width30.golden | 1 + .../TestStatusTruncation/Width40.golden | 1 + .../TestStatusTruncation/Width50.golden | 1 + .../TestStatusTruncation/Width60.golden | 1 + 16 files changed, 162 insertions(+) create mode 100644 internal/tui/components/core/status_test.go create mode 100644 internal/tui/components/core/testdata/TestStatus/AllFieldsWithExtraContent.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/Default.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/EmptyDescription.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/LongDescription.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/NarrowWidth.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/NoIcon.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/VeryNarrowWidth.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/WithColors.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/WithCustomIcon.golden create mode 100644 internal/tui/components/core/testdata/TestStatus/WithExtraContent.golden create mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width20.golden create mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width30.golden create mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width40.golden create mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width50.golden create mode 100644 internal/tui/components/core/testdata/TestStatusTruncation/Width60.golden 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 From e2bb9700c4e3b9b0cc283e97bcad1e9fd44d8249 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Mon, 14 Jul 2025 22:39:03 -0300 Subject: [PATCH 11/12] fix: timeout and context cancel (#180) * fix: timeout * fix: handle context cancel * chore: smaller diff Signed-off-by: Carlos Alexandro Becker * fix: once --------- Signed-off-by: Carlos Alexandro Becker --- cmd/root.go | 20 ++------------------ internal/llm/agent/agent.go | 11 +++++++---- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 24cde93e21c6c5f96abd0dc6259270b5987d8a25..8e1f0839323c29489031ef48567e31f105612ef3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "os" - "sync" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -86,23 +85,7 @@ to assist developers in writing, debugging, and understanding code directly from slog.Error(fmt.Sprintf("Failed to create app instance: %v", err)) return err } - - // Set up shutdown handling that works for both normal exit and signal interruption - var shutdownOnce sync.Once - shutdown := func() { - shutdownOnce.Do(func() { - slog.Info("Shutting down application") - app.Shutdown() - }) - } - defer shutdown() - - // Handle context cancellation (from signals) in a goroutine - go func() { - <-ctx.Done() - slog.Info("Context cancelled, initiating shutdown") - shutdown() - }() + defer app.Shutdown() // Initialize MCP tools early for both modes initMCPTools(ctx, app, cfg) @@ -125,6 +108,7 @@ to assist developers in writing, debugging, and understanding code directly from tea.WithAltScreen(), tea.WithKeyReleases(), tea.WithUniformKeyLayout(), + 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 ) diff --git a/internal/llm/agent/agent.go b/internal/llm/agent/agent.go index 6c7844eaa2811570824fba489a8c7a7581fa201f..56fb431b3b705a656cdfbf9df426b8ce8c7298c4 100644 --- a/internal/llm/agent/agent.go +++ b/internal/llm/agent/agent.go @@ -800,11 +800,14 @@ func (a *agent) CancelAll() { a.Cancel(key.(string)) // key is sessionID return true }) - for { - if a.IsBusy() { + + timeout := time.After(5 * time.Second) + for a.IsBusy() { + select { + case <-timeout: + return + default: time.Sleep(200 * time.Millisecond) - } else { - break } } } From 3dfac0bde03964bb7284803cf4d4021c09ec6878 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 15 Jul 2025 11:46:19 +0200 Subject: [PATCH 12/12] chore: move the permissions dialog a bit up --- internal/tui/components/dialogs/permissions/permissions.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 1bfb31186a596741da25566fcd15ad9fdf9c64eb..0bbaa034ed2357cc4643ad92c0a680bb01cf61ff 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -451,6 +451,7 @@ func (p *permissionDialogCmp) render() string { 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() { @@ -517,6 +518,7 @@ func (p *permissionDialogCmp) SetSize() tea.Cmd { } 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