Detailed changes
@@ -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
@@ -109,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
)
@@ -119,7 +119,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 {
@@ -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...")
}
@@ -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)
@@ -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
}
@@ -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