Merge branch 'main' into feat/attach-image-on-path-auto-complete-selection

tauraamui created

Change summary

.github/cla-signatures.json               |   8 +
README.md                                 |   2 
internal/config/config.go                 |  55 ++++++++-
internal/config/load_test.go              |  39 ++++++
internal/llm/agent/agent.go               |   7 
internal/lsp/protocol/tsjson.go           |   9 
internal/tui/exp/list/filterable.go       |   8 -
internal/tui/exp/list/filterable_group.go | 141 ++++++++++++++++++++++--
internal/tui/exp/list/items.go            |   4 
internal/tui/tui.go                       |  18 +-
schema.json                               |  12 +
11 files changed, 253 insertions(+), 50 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -591,6 +591,14 @@
       "created_at": "2025-09-11T09:31:52Z",
       "repoId": 987670088,
       "pullRequestNo": 1016
+    },
+    {
+      "name": "kim0",
+      "id": 59667,
+      "comment_id": 3282025022,
+      "created_at": "2025-09-11T17:37:57Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1017
     }
   ]
 }

README.md 🔗

@@ -3,7 +3,7 @@
 <p align="center">
     <a href="https://stuff.charm.sh/crush/charm-crush.png"><img width="450" alt="Charm Crush Logo" src="https://github.com/user-attachments/assets/adc1a6f4-b284-4603-836c-59038caa2e8b" /></a><br />
     <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
-    <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/workflows/build/badge.svg" alt="Build Status"></a>
+    <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/actions/workflows/build.yml/badge.svg" alt="Build Status"></a>
 </p>
 
 <p align="center">Your new coding bestie, now available in your favourite terminal.<br />Your tools, your code, and your workflows, wired into your LLM of choice.</p>

internal/config/config.go 🔗

@@ -143,6 +143,7 @@ type Options struct {
 	DebugLSP             bool        `json:"debug_lsp,omitempty" jsonschema:"description=Enable debug logging for LSP servers,default=false"`
 	DisableAutoSummarize bool        `json:"disable_auto_summarize,omitempty" jsonschema:"description=Disable automatic conversation summarization,default=false"`
 	DataDirectory        string      `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
+	DisabledTools        []string    `json:"disabled_tools" jsonschema:"description=Tools to disable"`
 }
 
 type MCPs map[string]MCPConfig
@@ -415,7 +416,51 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
 	return nil
 }
 
+func allToolNames() []string {
+	return []string{
+		"bash",
+		"download",
+		"edit",
+		"multiedit",
+		"fetch",
+		"glob",
+		"grep",
+		"ls",
+		"sourcegraph",
+		"view",
+		"write",
+	}
+}
+
+func resolveAllowedTools(allTools []string, disabledTools []string) []string {
+	if disabledTools == nil {
+		return allTools
+	}
+	// filter out disabled tools (exclude mode)
+	return filterSlice(allTools, disabledTools, false)
+}
+
+func resolveReadOnlyTools(tools []string) []string {
+	readOnlyTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
+	// filter to only include tools that are in allowedtools (include mode)
+	return filterSlice(tools, readOnlyTools, true)
+}
+
+func filterSlice(data []string, mask []string, include bool) []string {
+	filtered := []string{}
+	for _, s := range data {
+		// if include is true, we include items that ARE in the mask
+		// if include is false, we include items that are NOT in the mask
+		if include == slices.Contains(mask, s) {
+			filtered = append(filtered, s)
+		}
+	}
+	return filtered
+}
+
 func (c *Config) SetupAgents() {
+	allowedTools := resolveAllowedTools(allToolNames(), c.Options.DisabledTools)
+
 	agents := map[string]Agent{
 		"coder": {
 			ID:           "coder",
@@ -423,7 +468,7 @@ func (c *Config) SetupAgents() {
 			Description:  "An agent that helps with executing coding tasks.",
 			Model:        SelectedModelTypeLarge,
 			ContextPaths: c.Options.ContextPaths,
-			// All tools allowed
+			AllowedTools: allowedTools,
 		},
 		"task": {
 			ID:           "task",
@@ -431,13 +476,7 @@ func (c *Config) SetupAgents() {
 			Description:  "An agent that helps with searching for context and finding implementation details.",
 			Model:        SelectedModelTypeLarge,
 			ContextPaths: c.Options.ContextPaths,
-			AllowedTools: []string{
-				"glob",
-				"grep",
-				"ls",
-				"sourcegraph",
-				"view",
-			},
+			AllowedTools: resolveReadOnlyTools(allowedTools),
 			// NO MCPs or LSPs by default
 			AllowedMCP: map[string][]string{},
 			AllowedLSP: []string{},

internal/config/load_test.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/env"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -453,6 +454,44 @@ func TestConfig_IsConfigured(t *testing.T) {
 	})
 }
 
+func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
+	cfg := &Config{
+		Options: &Options{
+			DisabledTools: []string{},
+		},
+	}
+
+	cfg.SetupAgents()
+	coderAgent, ok := cfg.Agents["coder"]
+	require.True(t, ok)
+	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
+
+	taskAgent, ok := cfg.Agents["task"]
+	require.True(t, ok)
+	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
+}
+
+func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
+	cfg := &Config{
+		Options: &Options{
+			DisabledTools: []string{
+				"edit",
+				"download",
+				"grep",
+			},
+		},
+	}
+
+	cfg.SetupAgents()
+	coderAgent, ok := cfg.Agents["coder"]
+	require.True(t, ok)
+	assert.Equal(t, []string{"bash", "multiedit", "fetch", "glob", "ls", "sourcegraph", "view", "write"}, coderAgent.AllowedTools)
+
+	taskAgent, ok := cfg.Agents["task"]
+	require.True(t, ok)
+	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
+}
+
 func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
 	knownProviders := []catwalk.Provider{
 		{

internal/llm/agent/agent.go 🔗

@@ -343,7 +343,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 	if !a.Model().SupportsImages && attachments != nil {
 		attachments = nil
 	}
-	events := make(chan AgentEvent)
+	events := make(chan AgentEvent, 1)
 	if a.IsSessionBusy(sessionID) {
 		existing, ok := a.promptQueue.Get(sessionID)
 		if !ok {
@@ -374,10 +374,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
 		a.activeRequests.Del(sessionID)
 		cancel()
 		a.Publish(pubsub.CreatedEvent, result)
-		select {
-		case events <- result:
-		case <-genCtx.Done():
-		}
+		events <- result
 		close(events)
 	}()
 	return events, nil

internal/lsp/protocol/tsjson.go 🔗

@@ -10,10 +10,11 @@ package protocol
 // https://github.com/microsoft/vscode-languageserver-node/blob/release/protocol/3.17.6-next.9/protocol/metaModel.json
 // LSP metaData.version = 3.17.0.
 
-import "bytes"
-import "encoding/json"
-
-import "fmt"
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+)
 
 // UnmarshalError indicates that a JSON value did not conform to
 // one of the expected cases of an LSP union type.

internal/tui/exp/list/filterable.go 🔗

@@ -15,12 +15,8 @@ import (
 	"github.com/sahilm/fuzzy"
 )
 
-var (
-	// Pre-compiled regex for checking if a string contains alphabetic characters.
-	alphaRegex = regexp.MustCompile(`[a-zA-Z]`)
-	// Pre-compiled regex for checking if a string is alphanumeric.
-	alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
-)
+// Pre-compiled regex for checking if a string is alphanumeric.
+var alphanumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]*$`)
 
 type FilterableItem interface {
 	Item

internal/tui/exp/list/filterable_group.go 🔗

@@ -180,7 +180,12 @@ func (f *filterableGroupList[T]) inputHeight() int {
 	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
 }
 
-func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
+type groupMatch[T FilterableItem] struct {
+	group Group[T]
+	score int
+}
+
+func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
 	var cmds []tea.Cmd
 	for _, item := range slices.Collect(f.items.Seq()) {
 		if i, ok := any(item).(layout.Focusable); ok {
@@ -190,33 +195,82 @@ func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
 			i.MatchIndexes(make([]int, 0))
 		}
 	}
+	return cmds
+}
 
-	f.selectedItem = ""
-	if query == "" {
-		return f.groupedList.SetGroups(f.groups)
+func (f *filterableGroupList[T]) getGroupName(g Group[T]) string {
+	if section, ok := g.Section.(*itemSectionModel); ok {
+		return strings.ToLower(section.title)
 	}
+	return strings.ToLower(g.Section.ID())
+}
 
-	var newGroups []Group[T]
+func (f *filterableGroupList[T]) setMatchIndexes(item T, indexes []int) {
+	if i, ok := any(item).(HasMatchIndexes); ok {
+		i.MatchIndexes(indexes)
+	}
+}
+
+func (f *filterableGroupList[T]) findMatchingGroups(firstWord string) []groupMatch[T] {
+	var matchedGroups []groupMatch[T]
 	for _, g := range f.groups {
-		words := make([]string, len(g.Items))
-		for i, item := range g.Items {
-			words[i] = strings.ToLower(item.FilterValue())
+		groupName := f.getGroupName(g)
+		matches := fuzzy.Find(firstWord, []string{groupName})
+		if len(matches) > 0 && matches[0].Score > 0 {
+			matchedGroups = append(matchedGroups, groupMatch[T]{
+				group: g,
+				score: matches[0].Score,
+			})
 		}
+	}
+	// Sort by score (higher scores first - exact matches will have higher scores)
+	sort.SliceStable(matchedGroups, func(i, j int) bool {
+		return matchedGroups[i].score > matchedGroups[j].score
+	})
+	return matchedGroups
+}
 
-		matches := fuzzy.Find(query, words)
+func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string) []T {
+	if query == "" {
+		// No query, return all items with cleared match indexes
+		var items []T
+		for _, item := range group.Items {
+			f.setMatchIndexes(item, make([]int, 0))
+			items = append(items, item)
+		}
+		return items
+	}
+
+	// Build search words
+	words := make([]string, len(group.Items))
+	for i, item := range group.Items {
+		words[i] = strings.ToLower(item.FilterValue())
+	}
 
-		sort.SliceStable(matches, func(i, j int) bool {
-			return matches[i].Score > matches[j].Score
-		})
+	// Perform fuzzy search
+	matches := fuzzy.Find(query, words)
+	sort.SliceStable(matches, func(i, j int) bool {
+		return matches[i].Score > matches[j].Score
+	})
 
+	if len(matches) > 0 {
+		// Found matches, return only those with highlights
 		var matchedItems []T
 		for _, match := range matches {
-			item := g.Items[match.Index]
-			if i, ok := any(item).(HasMatchIndexes); ok {
-				i.MatchIndexes(match.MatchedIndexes)
-			}
+			item := group.Items[match.Index]
+			f.setMatchIndexes(item, match.MatchedIndexes)
 			matchedItems = append(matchedItems, item)
 		}
+		return matchedItems
+	}
+
+	return []T{}
+}
+
+func (f *filterableGroupList[T]) searchAllGroups(query string) []Group[T] {
+	var newGroups []Group[T]
+	for _, g := range f.groups {
+		matchedItems := f.filterItemsInGroup(g, query)
 		if len(matchedItems) > 0 {
 			newGroups = append(newGroups, Group[T]{
 				Section: g.Section,
@@ -224,6 +278,61 @@ func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
 			})
 		}
 	}
+	return newGroups
+}
+
+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
+	cmds := f.clearItemState()
+	f.selectedItem = ""
+
+	if query == "" {
+		return f.groupedList.SetGroups(f.groups)
+	}
+
+	lowerQuery := strings.ToLower(query)
+	queryWords := strings.Fields(lowerQuery)
+	firstWord := queryWords[0]
+
+	// Find groups that match the first word
+	matchedGroups := f.findMatchingGroups(firstWord)
+
+	var newGroups []Group[T]
+	if len(matchedGroups) > 0 {
+		// Filter within matched groups using remaining words
+		remainingQuery := ""
+		if len(queryWords) > 1 {
+			remainingQuery = strings.Join(queryWords[1:], " ")
+		}
+
+		for _, matchedGroup := range matchedGroups {
+			matchedItems := f.filterItemsInGroup(matchedGroup.group, remainingQuery)
+			if len(matchedItems) > 0 {
+				newGroups = append(newGroups, Group[T]{
+					Section: matchedGroup.group.Section,
+					Items:   matchedItems,
+				})
+			}
+		}
+
+		// add any matching items from other groups
+		allGroups := f.searchAllGroups(lowerQuery)
+		for _, g := range allGroups {
+			exists := false
+			for _, existing := range newGroups {
+				if existing.Section.ID() == g.Section.ID() {
+					exists = true
+					break
+				}
+			}
+			if !exists {
+				newGroups = append(newGroups, g)
+			}
+		}
+	} else {
+		// No group matches, search all groups
+		newGroups = f.searchAllGroups(lowerQuery)
+	}
+
 	cmds = append(cmds, f.groupedList.SetGroups(newGroups))
 	return tea.Batch(cmds...)
 }

internal/tui/exp/list/items.go 🔗

@@ -327,18 +327,20 @@ type itemSectionModel struct {
 	width int
 	title string
 	inx   int
+	id    string
 	info  string
 }
 
 // ID implements ItemSection.
 func (m *itemSectionModel) ID() string {
-	return uuid.NewString()
+	return m.id
 }
 
 func NewItemSection(title string) ItemSection {
 	return &itemSectionModel{
 		title: title,
 		inx:   -1,
+		id:    uuid.NewString(),
 	}
 }
 

internal/tui/tui.go 🔗

@@ -406,6 +406,16 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
 
 // handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
 func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
+	// Check this first as the user should be able to quit no matter what.
+	if key.Matches(msg, a.keyMap.Quit) {
+		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
+			return tea.Quit
+		}
+		return util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: quit.NewQuitDialog(),
+		})
+	}
+
 	if a.completions.Open() {
 		// completions
 		keyMap := a.completions.KeyMap()
@@ -430,14 +440,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		a.showingFullHelp = !a.showingFullHelp
 		return a.handleWindowResize(a.wWidth, a.wHeight)
 	// dialogs
-	case key.Matches(msg, a.keyMap.Quit):
-		if a.dialog.ActiveDialogID() == quit.QuitDialogID {
-			return tea.Quit
-		}
-		return util.CmdHandler(dialogs.OpenDialogMsg{
-			Model: quit.NewQuitDialog(),
-		})
-
 	case key.Matches(msg, a.keyMap.Commands):
 		// if the app is not configured show no commands
 		if !a.isConfigured {

schema.json 🔗

@@ -271,10 +271,20 @@
           "examples": [
             ".crush"
           ]
+        },
+        "disabled_tools": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array",
+          "description": "Tools to disable"
         }
       },
       "additionalProperties": false,
-      "type": "object"
+      "type": "object",
+      "required": [
+        "disabled_tools"
+      ]
     },
     "Permissions": {
       "properties": {