on main help sign in
Merge branch 'main' into ui
commitb9a0cdcc92e4e02fa29299b3e6dfb270e0dec168 authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 15 Dec 2025, 17:20 (1 year ago) parented7ef787918c90
15 files changed+543−68
go.mod+7−7 go.sum+16−16 internal/agent/tools/download.go+4−16 internal/agent/tools/search.go+8−2 internal/cmd/projects.go+78 internal/cmd/projects_test.go+56 internal/cmd/root.go+8 internal/cmd/run.go+7 internal/event/event.go+6−1 internal/projects/projects.go+126 internal/projects/projects_test.go+180 internal/tui/components/chat/messages/messages.go+1−1 internal/tui/components/dialogs/commands/arguments.go+3−12 internal/tui/components/dialogs/commands/commands.go+9−10 internal/tui/components/dialogs/commands/loader.go → internal/uicmd/uicmd.go+34−3
go.mod+7−7
@@ -55,10 +55,10 @@ require (
5555 github.com/stretchr/testify v1.11.1
5656 github.com/tidwall/sjson v1.2.5
5757 github.com/zeebo/xxh3 v1.0.2
58− golang.org/x/mod v0.30.0
59− golang.org/x/net v0.47.0
60− golang.org/x/sync v0.18.0
61− golang.org/x/text v0.31.0
58+ golang.org/x/mod v0.31.0
59+ golang.org/x/net v0.48.0
60+ golang.org/x/sync v0.19.0
61+ golang.org/x/text v0.32.0
6262 gopkg.in/natefinch/lumberjack.v2 v2.2.1
6363 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
6464 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
@@ -163,12 +163,12 @@ require (
163163 go.opentelemetry.io/otel/trace v1.37.0 // indirect
164164 go.uber.org/multierr v1.11.0 // indirect
165165 go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
166− golang.org/x/crypto v0.45.0 // indirect
166+ golang.org/x/crypto v0.46.0 // indirect
167167 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
168168 golang.org/x/image v0.27.0 // indirect
169169 golang.org/x/oauth2 v0.33.0 // indirect
170− golang.org/x/sys v0.38.0 // indirect
171− golang.org/x/term v0.37.0 // indirect
170+ golang.org/x/sys v0.39.0 // indirect
171+ golang.org/x/term v0.38.0 // indirect
172172 golang.org/x/time v0.12.0 // indirect
173173 google.golang.org/api v0.239.0 // indirect
174174 google.golang.org/genai v1.37.0 // indirect
go.sum+16−16
@@ -370,8 +370,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
370370golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
371371golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
372372golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
373−golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
374−golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
373+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
374+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
375375golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
376376golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
377377golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
@@ -381,8 +381,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
381381golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
382382golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
383383golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
384−golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
385−golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
384+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
385+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
386386golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
387387golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
388388golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -394,8 +394,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
394394golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
395395golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
396396golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
397−golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
398−golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
397+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
398+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
399399golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
400400golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
401401golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -405,8 +405,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
405405golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
406406golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
407407golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
408−golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
409−golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
408+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
409+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
410410golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
411411golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
412412golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -423,8 +423,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
423423golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
424424golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
425425golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
426−golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
427−golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
426+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
427+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
428428golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
429429golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
430430golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -436,8 +436,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
436436golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
437437golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
438438golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
439−golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
440−golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
439+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
440+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
441441golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
442442golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
443443golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -447,8 +447,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
447447golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
448448golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
449449golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
450−golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
451−golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
450+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
451+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
452452golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
453453golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
454454golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -457,8 +457,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
457457golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
458458golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
459459golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
460−golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
461−golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
460+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
461+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
462462golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
463463google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
464464google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
internal/agent/tools/download.go+4−16
@@ -114,12 +114,6 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
114114 return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
115115 }
116116
117− // Check content length if available
118− maxSize := int64(100 * 1024 * 1024) // 100MB
119− if resp.ContentLength > maxSize {
120− return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
121− }
122−
123117 // Create parent directories if they don't exist
124118 if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
125119 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
@@ -132,20 +126,14 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
132126 }
133127 defer outFile.Close()
134128
135− // Copy data with size limit
136− limitedReader := io.LimitReader(resp.Body, maxSize)
137− bytesWritten, err := io.Copy(outFile, limitedReader)
129+ // Copy data without an explicit size limit.
130+ // The overall download is still constrained by the HTTP client's timeout
131+ // and any upstream server limits.
132+ bytesWritten, err := io.Copy(outFile, resp.Body)
138133 if err != nil {
139134 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
140135 }
141136
142− // Check if we hit the size limit
143− if bytesWritten == maxSize {
144− // Clean up the file since it might be incomplete
145− os.Remove(filePath)
146− return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
147− }
148−
149137 contentType := resp.Header.Get("Content-Type")
150138 responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, relPath)
151139 if contentType != "" {
internal/agent/tools/search.go+8−2
@@ -38,6 +38,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
3838
3939 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
4040 req.Header.Set("User-Agent", BrowserUserAgent)
41+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
42+ req.Header.Set("Accept-Language", "en-US,en;q=0.5")
43+ req.Header.Set("Accept-Encoding", "gzip, deflate")
44+ req.Header.Set("Referer", "https://duckduckgo.com/")
4145
4246 resp, err := client.Do(req)
4347 if err != nil {
@@ -45,8 +49,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
4549 }
4650 defer resp.Body.Close()
4751
48− if resp.StatusCode != http.StatusOK {
49− return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode)
52+ // Accept both 200 (OK) and 202 (Accepted).
53+ // DuckDuckGo may still return 202 for rate limiting or bot detection.
54+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
55+ return nil, fmt.Errorf("search failed with status code: %d (DuckDuckGo may be rate limiting requests)", resp.StatusCode)
5056 }
5157
5258 body, err := io.ReadAll(resp.Body)
internal/cmd/projects.gonew file+78
@@ -0,0 +1,78 @@
1+package cmd
2+
3+import (
4+ "encoding/json"
5+ "os"
6+
7+ "charm.land/lipgloss/v2"
8+ "charm.land/lipgloss/v2/table"
9+ "github.com/charmbracelet/crush/internal/projects"
10+ "github.com/charmbracelet/x/term"
11+ "github.com/spf13/cobra"
12+)
13+
14+var projectsCmd = &cobra.Command{
15+ Use: "projects",
16+ Short: "List all tracked project directories",
17+ Long: `List all directories where Crush has been used.
18+This includes the working directory, data directory path, and last accessed time.`,
19+ Example: `
20+# List all projects in a table
21+crush projects
22+
23+# Output as JSON
24+crush projects --json
25+ `,
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ jsonOutput, _ := cmd.Flags().GetBool("json")
28+
29+ projectList, err := projects.List()
30+ if err != nil {
31+ return err
32+ }
33+
34+ if jsonOutput {
35+ output := struct {
36+ Projects []projects.Project `json:"projects"`
37+ }{Projects: projectList}
38+
39+ data, err := json.Marshal(output)
40+ if err != nil {
41+ return err
42+ }
43+ cmd.Println(string(data))
44+ return nil
45+ }
46+
47+ if len(projectList) == 0 {
48+ cmd.Println("No projects tracked yet.")
49+ return nil
50+ }
51+
52+ if term.IsTerminal(os.Stdout.Fd()) {
53+ // We're in a TTY: make it fancy.
54+ t := table.New().
55+ Border(lipgloss.RoundedBorder()).
56+ StyleFunc(func(row, col int) lipgloss.Style {
57+ return lipgloss.NewStyle().Padding(0, 2)
58+ }).
59+ Headers("Path", "Data Dir", "Last Accessed")
60+
61+ for _, p := range projectList {
62+ t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04"))
63+ }
64+ lipgloss.Println(t)
65+ return nil
66+ }
67+
68+ // Not a TTY: plain output
69+ for _, p := range projectList {
70+ cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00"))
71+ }
72+ return nil
73+ },
74+}
75+
76+func init() {
77+ projectsCmd.Flags().Bool("json", false, "Output as JSON")
78+}
internal/cmd/projects_test.gonew file+56
@@ -0,0 +1,56 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+
8+ "github.com/charmbracelet/crush/internal/projects"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestProjectsEmpty(t *testing.T) {
13+ // Use a temp directory for projects.json
14+ tmpDir := t.TempDir()
15+ t.Setenv("XDG_DATA_HOME", tmpDir)
16+
17+ var b bytes.Buffer
18+ projectsCmd.SetOut(&b)
19+ projectsCmd.SetErr(&b)
20+ projectsCmd.SetIn(bytes.NewReader(nil))
21+ err := projectsCmd.RunE(projectsCmd, nil)
22+ require.NoError(t, err)
23+ require.Equal(t, "No projects tracked yet.\n", b.String())
24+}
25+
26+func TestProjectsJSON(t *testing.T) {
27+ tmpDir := t.TempDir()
28+ t.Setenv("XDG_DATA_HOME", tmpDir)
29+
30+ // Register a project
31+ err := projects.Register("/test/project", "/test/project/.crush")
32+ require.NoError(t, err)
33+
34+ var b bytes.Buffer
35+ projectsCmd.SetOut(&b)
36+ projectsCmd.SetErr(&b)
37+ projectsCmd.SetIn(bytes.NewReader(nil))
38+
39+ // Set the --json flag
40+ projectsCmd.Flags().Set("json", "true")
41+ defer projectsCmd.Flags().Set("json", "false")
42+
43+ err = projectsCmd.RunE(projectsCmd, nil)
44+ require.NoError(t, err)
45+
46+ // Parse the JSON output
47+ var result struct {
48+ Projects []projects.Project `json:"projects"`
49+ }
50+ err = json.Unmarshal(b.Bytes(), &result)
51+ require.NoError(t, err)
52+
53+ require.Len(t, result.Projects, 1)
54+ require.Equal(t, "/test/project", result.Projects[0].Path)
55+ require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir)
56+}
internal/cmd/root.go+8
@@ -19,6 +19,7 @@ import (
1919 "github.com/charmbracelet/crush/internal/config"
2020 "github.com/charmbracelet/crush/internal/db"
2121 "github.com/charmbracelet/crush/internal/event"
22+ "github.com/charmbracelet/crush/internal/projects"
2223 "github.com/charmbracelet/crush/internal/stringext"
2324 "github.com/charmbracelet/crush/internal/tui"
2425 "github.com/charmbracelet/crush/internal/ui/common"
@@ -42,6 +43,7 @@ func init() {
4243 rootCmd.AddCommand(
4344 runCmd,
4445 dirsCmd,
46+ projectsCmd,
4547 updateProvidersCmd,
4648 logsCmd,
4749 schemaCmd,
@@ -201,6 +203,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
201203 return nil, err
202204 }
203205
206+ // Register this project in the centralized projects list.
207+ if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
208+ slog.Warn("Failed to register project", "error", err)
209+ // Non-fatal: continue even if registration fails
210+ }
211+
204212 // Connect to DB; this will also run migrations.
205213 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
206214 if err != nil {
internal/cmd/run.go+7
@@ -8,6 +8,7 @@ import (
88 "os/signal"
99 "strings"
1010
11+ "github.com/charmbracelet/crush/internal/event"
1112 "github.com/spf13/cobra"
1213)
1314
@@ -58,8 +59,14 @@ crush run --quiet "Generate a README for this project"
5859 return fmt.Errorf("no prompt provided")
5960 }
6061
62+ event.SetInteractive(true)
63+ event.AppInitialized()
64+
6165 return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
6266 },
67+ PostRun: func(cmd *cobra.Command, args []string) {
68+ event.AppExited()
69+ },
6370}
6471
6572func init() {
internal/event/event.go+6−1
@@ -26,9 +26,14 @@ var (
2626 Set("TERM", os.Getenv("TERM")).
2727 Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
2828 Set("Version", version.Version).
29− Set("GoVersion", runtime.Version())
29+ Set("GoVersion", runtime.Version()).
30+ Set("Interactive", false)
3031)
3132
33+func SetInteractive(interactive bool) {
34+ baseProps = baseProps.Set("interactive", interactive)
35+}
36+
3237func Init() {
3338 c, err := posthog.NewWithConfig(key, posthog.Config{
3439 Endpoint: endpoint,
internal/projects/projects.gonew file+126
@@ -0,0 +1,126 @@
1+package projects
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "github.com/charmbracelet/crush/internal/config"
12+)
13+
14+const projectsFileName = "projects.json"
15+
16+// Project represents a tracked project directory.
17+type Project struct {
18+ Path string `json:"path"`
19+ DataDir string `json:"data_dir"`
20+ LastAccessed time.Time `json:"last_accessed"`
21+}
22+
23+// ProjectList holds the list of tracked projects.
24+type ProjectList struct {
25+ Projects []Project `json:"projects"`
26+}
27+
28+var mu sync.Mutex
29+
30+// projectsFilePath returns the path to the projects.json file.
31+func projectsFilePath() string {
32+ return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName)
33+}
34+
35+// Load reads the projects list from disk.
36+func Load() (*ProjectList, error) {
37+ mu.Lock()
38+ defer mu.Unlock()
39+
40+ path := projectsFilePath()
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ if os.IsNotExist(err) {
44+ return &ProjectList{Projects: []Project{}}, nil
45+ }
46+ return nil, err
47+ }
48+
49+ var list ProjectList
50+ if err := json.Unmarshal(data, &list); err != nil {
51+ return nil, err
52+ }
53+
54+ return &list, nil
55+}
56+
57+// Save writes the projects list to disk.
58+func Save(list *ProjectList) error {
59+ mu.Lock()
60+ defer mu.Unlock()
61+
62+ path := projectsFilePath()
63+
64+ // Ensure directory exists
65+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
66+ return err
67+ }
68+
69+ data, err := json.MarshalIndent(list, "", " ")
70+ if err != nil {
71+ return err
72+ }
73+
74+ return os.WriteFile(path, data, 0o600)
75+}
76+
77+// Register adds or updates a project in the list.
78+func Register(workingDir, dataDir string) error {
79+ list, err := Load()
80+ if err != nil {
81+ return err
82+ }
83+
84+ now := time.Now().UTC()
85+
86+ // Check if project already exists
87+ found := false
88+ for i, p := range list.Projects {
89+ if p.Path == workingDir {
90+ list.Projects[i].DataDir = dataDir
91+ list.Projects[i].LastAccessed = now
92+ found = true
93+ break
94+ }
95+ }
96+
97+ if !found {
98+ list.Projects = append(list.Projects, Project{
99+ Path: workingDir,
100+ DataDir: dataDir,
101+ LastAccessed: now,
102+ })
103+ }
104+
105+ // Sort by last accessed (most recent first)
106+ slices.SortFunc(list.Projects, func(a, b Project) int {
107+ if a.LastAccessed.After(b.LastAccessed) {
108+ return -1
109+ }
110+ if a.LastAccessed.Before(b.LastAccessed) {
111+ return 1
112+ }
113+ return 0
114+ })
115+
116+ return Save(list)
117+}
118+
119+// List returns all tracked projects sorted by last accessed.
120+func List() ([]Project, error) {
121+ list, err := Load()
122+ if err != nil {
123+ return nil, err
124+ }
125+ return list.Projects, nil
126+}
internal/projects/projects_test.gonew file+180
@@ -0,0 +1,180 @@
1+package projects
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+ "time"
7+)
8+
9+func TestRegisterAndList(t *testing.T) {
10+ // Create a temporary directory for the test
11+ tmpDir := t.TempDir()
12+
13+ // Override the projects file path for testing
14+ t.Setenv("XDG_DATA_HOME", tmpDir)
15+
16+ // Test registering a project
17+ err := Register("/home/user/project1", "/home/user/project1/.crush")
18+ if err != nil {
19+ t.Fatalf("Register failed: %v", err)
20+ }
21+
22+ // List projects
23+ projects, err := List()
24+ if err != nil {
25+ t.Fatalf("List failed: %v", err)
26+ }
27+
28+ if len(projects) != 1 {
29+ t.Fatalf("Expected 1 project, got %d", len(projects))
30+ }
31+
32+ if projects[0].Path != "/home/user/project1" {
33+ t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path)
34+ }
35+
36+ if projects[0].DataDir != "/home/user/project1/.crush" {
37+ t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir)
38+ }
39+
40+ // Register another project
41+ err = Register("/home/user/project2", "/home/user/project2/.crush")
42+ if err != nil {
43+ t.Fatalf("Register failed: %v", err)
44+ }
45+
46+ projects, err = List()
47+ if err != nil {
48+ t.Fatalf("List failed: %v", err)
49+ }
50+
51+ if len(projects) != 2 {
52+ t.Fatalf("Expected 2 projects, got %d", len(projects))
53+ }
54+
55+ // Most recent should be first
56+ if projects[0].Path != "/home/user/project2" {
57+ t.Errorf("Expected most recent project first, got %s", projects[0].Path)
58+ }
59+}
60+
61+func TestRegisterUpdatesExisting(t *testing.T) {
62+ tmpDir := t.TempDir()
63+ t.Setenv("XDG_DATA_HOME", tmpDir)
64+
65+ // Register a project
66+ err := Register("/home/user/project1", "/home/user/project1/.crush")
67+ if err != nil {
68+ t.Fatalf("Register failed: %v", err)
69+ }
70+
71+ projects, _ := List()
72+ firstAccess := projects[0].LastAccessed
73+
74+ // Wait a bit and re-register
75+ time.Sleep(10 * time.Millisecond)
76+
77+ err = Register("/home/user/project1", "/home/user/project1/.crush-new")
78+ if err != nil {
79+ t.Fatalf("Register failed: %v", err)
80+ }
81+
82+ projects, _ = List()
83+
84+ if len(projects) != 1 {
85+ t.Fatalf("Expected 1 project after update, got %d", len(projects))
86+ }
87+
88+ if projects[0].DataDir != "/home/user/project1/.crush-new" {
89+ t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir)
90+ }
91+
92+ if !projects[0].LastAccessed.After(firstAccess) {
93+ t.Error("Expected LastAccessed to be updated")
94+ }
95+}
96+
97+func TestLoadEmptyFile(t *testing.T) {
98+ tmpDir := t.TempDir()
99+ t.Setenv("XDG_DATA_HOME", tmpDir)
100+
101+ // List before any projects exist
102+ projects, err := List()
103+ if err != nil {
104+ t.Fatalf("List failed: %v", err)
105+ }
106+
107+ if len(projects) != 0 {
108+ t.Errorf("Expected 0 projects, got %d", len(projects))
109+ }
110+}
111+
112+func TestProjectsFilePath(t *testing.T) {
113+ tmpDir := t.TempDir()
114+ t.Setenv("XDG_DATA_HOME", tmpDir)
115+
116+ expected := filepath.Join(tmpDir, "crush", "projects.json")
117+ actual := projectsFilePath()
118+
119+ if actual != expected {
120+ t.Errorf("Expected %s, got %s", expected, actual)
121+ }
122+}
123+
124+func TestRegisterWithParentDataDir(t *testing.T) {
125+ tmpDir := t.TempDir()
126+ t.Setenv("XDG_DATA_HOME", tmpDir)
127+
128+ // Register a project where .crush is in a parent directory.
129+ // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
130+ err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush")
131+ if err != nil {
132+ t.Fatalf("Register failed: %v", err)
133+ }
134+
135+ projects, err := List()
136+ if err != nil {
137+ t.Fatalf("List failed: %v", err)
138+ }
139+
140+ if len(projects) != 1 {
141+ t.Fatalf("Expected 1 project, got %d", len(projects))
142+ }
143+
144+ if projects[0].Path != "/home/user/monorepo/packages/app" {
145+ t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path)
146+ }
147+
148+ if projects[0].DataDir != "/home/user/monorepo/.crush" {
149+ t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir)
150+ }
151+}
152+
153+func TestRegisterWithExternalDataDir(t *testing.T) {
154+ tmpDir := t.TempDir()
155+ t.Setenv("XDG_DATA_HOME", tmpDir)
156+
157+ // Register a project where .crush is in a completely different location.
158+ // e.g., project at /home/user/project but data stored at /var/data/crush/myproject
159+ err := Register("/home/user/project", "/var/data/crush/myproject")
160+ if err != nil {
161+ t.Fatalf("Register failed: %v", err)
162+ }
163+
164+ projects, err := List()
165+ if err != nil {
166+ t.Fatalf("List failed: %v", err)
167+ }
168+
169+ if len(projects) != 1 {
170+ t.Fatalf("Expected 1 project, got %d", len(projects))
171+ }
172+
173+ if projects[0].Path != "/home/user/project" {
174+ t.Errorf("Expected path /home/user/project, got %s", projects[0].Path)
175+ }
176+
177+ if projects[0].DataDir != "/var/data/crush/myproject" {
178+ t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir)
179+ }
180+}
internal/tui/components/chat/messages/messages.go+1−1
@@ -311,7 +311,7 @@ func (m *messageCmp) renderThinkingContent() string {
311311 }
312312 }
313313 lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
314− result := lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View())
314+ result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
315315 if footer != "" {
316316 result += "\n\n" + footer
317317 }
internal/tui/components/dialogs/commands/arguments.go+3−12
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
1212 "github.com/charmbracelet/crush/internal/tui/styles"
1313 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/crush/internal/uicmd"
1415)
1516
1617const (
@@ -18,20 +19,10 @@ const (
1819)
1920
2021// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
21−type ShowArgumentsDialogMsg struct {
22− CommandID string
23− Description string
24− ArgNames []string
25− OnSubmit func(args map[string]string) tea.Cmd
26−}
22+type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg
2723
2824// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
29−type CloseArgumentsDialogMsg struct {
30− Submit bool
31− CommandID string
32− Content string
33− Args map[string]string
34−}
25+type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg
3526
3627// CommandArgumentsDialog represents the commands dialog.
3728type CommandArgumentsDialog interface {
internal/tui/components/dialogs/commands/commands.go+9−10
@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/tui/exp/list"
2424 "github.com/charmbracelet/crush/internal/tui/styles"
2525 "github.com/charmbracelet/crush/internal/tui/util"
26+ "github.com/charmbracelet/crush/internal/uicmd"
2627)
2728
2829const (
@@ -44,13 +45,11 @@ const (
4445type listModel = list.FilterableList[list.CompletionItem[Command]]
4546
4647// Command represents a command that can be executed
47−type Command struct {
48− ID string
49− Title string
50− Description string
51− Shortcut string // Optional shortcut for the command
52− Handler func(cmd Command) tea.Cmd
53−}
48+type (
49+ Command = uicmd.Command
50+ CommandRunCustomMsg = uicmd.CommandRunCustomMsg
51+ ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
52+)
5453
5554// CommandsDialog represents the commands dialog.
5655type CommandsDialog interface {
@@ -121,12 +120,12 @@ func NewCommandDialog(sessionID string) CommandsDialog {
121120}
122121
123122func (c *commandDialogCmp) Init() tea.Cmd {
124− commands, err := LoadCustomCommands()
123+ commands, err := uicmd.LoadCustomCommands()
125124 if err != nil {
126125 return util.ReportError(err)
127126 }
128127 c.userCommands = commands
129− c.mcpPrompts.SetSlice(loadMCPPrompts())
128+ c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
130129 return c.setCommandType(c.selected)
131130}
132131
@@ -142,7 +141,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
142141 case pubsub.Event[mcp.Event]:
143142 // Reload MCP prompts when MCP state changes
144143 if msg.Type == pubsub.UpdatedEvent {
145− c.mcpPrompts.SetSlice(loadMCPPrompts())
144+ c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
146145 // If we're currently viewing MCP prompts, refresh the list
147146 if c.selected == MCPPrompts {
148147 return c, c.setCommandType(MCPPrompts)
internal/tui/components/dialogs/commands/loader.go → internal/uicmd/uicmd.gorenamed+34−3
@@ -1,4 +1,7 @@
1−package commands
1+// Package uicmd provides functionality to load and handle custom commands
2+// from markdown files and MCP prompts.
3+// TODO: Move this into internal/ui after refactoring.
4+package uicmd
25
36import (
47 "cmp"
@@ -18,6 +21,31 @@ import (
1821 "github.com/charmbracelet/crush/internal/tui/util"
1922)
2023
24+// Command represents a command that can be executed
25+type Command struct {
26+ ID string
27+ Title string
28+ Description string
29+ Shortcut string // Optional shortcut for the command
30+ Handler func(cmd Command) tea.Cmd
31+}
32+
33+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
34+type ShowArgumentsDialogMsg struct {
35+ CommandID string
36+ Description string
37+ ArgNames []string
38+ OnSubmit func(args map[string]string) tea.Cmd
39+}
40+
41+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
42+type CloseArgumentsDialogMsg struct {
43+ Submit bool
44+ CommandID string
45+ Content string
46+ Args map[string]string
47+}
48+
2149const (
2250 userCommandPrefix = "user:"
2351 projectCommandPrefix = "project:"
@@ -35,7 +63,10 @@ type commandSource struct {
3563}
3664
3765func LoadCustomCommands() ([]Command, error) {
38− cfg := config.Get()
66+ return LoadCustomCommandsFromConfig(config.Get())
67+}
68+
69+func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) {
3970 if cfg == nil {
4071 return nil, fmt.Errorf("config not loaded")
4172 }
@@ -221,7 +252,7 @@ type CommandRunCustomMsg struct {
221252 Content string
222253}
223254
224−func loadMCPPrompts() []Command {
255+func LoadMCPPrompts() []Command {
225256 var commands []Command
226257 for mcpName, prompts := range mcp.Prompts() {
227258 for _, prompt := range prompts {