Detailed changes
@@ -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
}
]
}
@@ -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>
@@ -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{},
@@ -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{
{
@@ -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
@@ -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.
@@ -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
@@ -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...)
}
@@ -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(),
}
}
@@ -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 {
@@ -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": {