Merge branch 'main' into ui

Ayman Bagabas created

Change summary

go.mod                                                |  14 
go.sum                                                |  32 +-
internal/agent/tools/download.go                      |  20 -
internal/agent/tools/search.go                        |  10 
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                               |   7 
internal/projects/projects.go                         | 126 +++++++++
internal/projects/projects_test.go                    | 180 +++++++++++++
internal/tui/components/chat/messages/messages.go     |   2 
internal/tui/components/dialogs/commands/arguments.go |  15 
internal/tui/components/dialogs/commands/commands.go  |  19 
internal/uicmd/uicmd.go                               |  37 ++
15 files changed, 543 insertions(+), 68 deletions(-)

Detailed changes

go.mod 🔗

@@ -55,10 +55,10 @@ require (
 	github.com/stretchr/testify v1.11.1
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.0.2
-	golang.org/x/mod v0.30.0
-	golang.org/x/net v0.47.0
-	golang.org/x/sync v0.18.0
-	golang.org/x/text v0.31.0
+	golang.org/x/mod v0.31.0
+	golang.org/x/net v0.48.0
+	golang.org/x/sync v0.19.0
+	golang.org/x/text v0.32.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5
 	mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5
@@ -163,12 +163,12 @@ require (
 	go.opentelemetry.io/otel/trace v1.37.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
 	go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
-	golang.org/x/crypto v0.45.0 // indirect
+	golang.org/x/crypto v0.46.0 // indirect
 	golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
 	golang.org/x/image v0.27.0 // indirect
 	golang.org/x/oauth2 v0.33.0 // indirect
-	golang.org/x/sys v0.38.0 // indirect
-	golang.org/x/term v0.37.0 // indirect
+	golang.org/x/sys v0.39.0 // indirect
+	golang.org/x/term v0.38.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
 	google.golang.org/api v0.239.0 // indirect
 	google.golang.org/genai v1.37.0 // indirect

go.sum 🔗

@@ -370,8 +370,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
 golang.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=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
+golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
+golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.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=
 golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
 golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
 golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
 golang.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=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.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=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.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=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
+golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.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=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.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
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
 google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=

internal/agent/tools/download.go 🔗

@@ -114,12 +114,6 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
 				return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil
 			}
 
-			// Check content length if available
-			maxSize := int64(100 * 1024 * 1024) // 100MB
-			if resp.ContentLength > maxSize {
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil
-			}
-
 			// Create parent directories if they don't exist
 			if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
@@ -132,20 +126,14 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
 			}
 			defer outFile.Close()
 
-			// Copy data with size limit
-			limitedReader := io.LimitReader(resp.Body, maxSize)
-			bytesWritten, err := io.Copy(outFile, limitedReader)
+			// Copy data without an explicit size limit.
+			// The overall download is still constrained by the HTTP client's timeout
+			// and any upstream server limits.
+			bytesWritten, err := io.Copy(outFile, resp.Body)
 			if err != nil {
 				return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
 			}
 
-			// Check if we hit the size limit
-			if bytesWritten == maxSize {
-				// Clean up the file since it might be incomplete
-				os.Remove(filePath)
-				return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil
-			}
-
 			contentType := resp.Header.Get("Content-Type")
 			responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, relPath)
 			if contentType != "" {

internal/agent/tools/search.go 🔗

@@ -38,6 +38,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
 
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("User-Agent", BrowserUserAgent)
+	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+	req.Header.Set("Accept-Language", "en-US,en;q=0.5")
+	req.Header.Set("Accept-Encoding", "gzip, deflate")
+	req.Header.Set("Referer", "https://duckduckgo.com/")
 
 	resp, err := client.Do(req)
 	if err != nil {
@@ -45,8 +49,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma
 	}
 	defer resp.Body.Close()
 
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode)
+	// Accept both 200 (OK) and 202 (Accepted).
+	// DuckDuckGo may still return 202 for rate limiting or bot detection.
+	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
+		return nil, fmt.Errorf("search failed with status code: %d (DuckDuckGo may be rate limiting requests)", resp.StatusCode)
 	}
 
 	body, err := io.ReadAll(resp.Body)

internal/cmd/projects.go 🔗

@@ -0,0 +1,78 @@
+package cmd
+
+import (
+	"encoding/json"
+	"os"
+
+	"charm.land/lipgloss/v2"
+	"charm.land/lipgloss/v2/table"
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/charmbracelet/x/term"
+	"github.com/spf13/cobra"
+)
+
+var projectsCmd = &cobra.Command{
+	Use:   "projects",
+	Short: "List all tracked project directories",
+	Long: `List all directories where Crush has been used.
+This includes the working directory, data directory path, and last accessed time.`,
+	Example: `
+# List all projects in a table
+crush projects
+
+# Output as JSON
+crush projects --json
+  `,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		jsonOutput, _ := cmd.Flags().GetBool("json")
+
+		projectList, err := projects.List()
+		if err != nil {
+			return err
+		}
+
+		if jsonOutput {
+			output := struct {
+				Projects []projects.Project `json:"projects"`
+			}{Projects: projectList}
+
+			data, err := json.Marshal(output)
+			if err != nil {
+				return err
+			}
+			cmd.Println(string(data))
+			return nil
+		}
+
+		if len(projectList) == 0 {
+			cmd.Println("No projects tracked yet.")
+			return nil
+		}
+
+		if term.IsTerminal(os.Stdout.Fd()) {
+			// We're in a TTY: make it fancy.
+			t := table.New().
+				Border(lipgloss.RoundedBorder()).
+				StyleFunc(func(row, col int) lipgloss.Style {
+					return lipgloss.NewStyle().Padding(0, 2)
+				}).
+				Headers("Path", "Data Dir", "Last Accessed")
+
+			for _, p := range projectList {
+				t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04"))
+			}
+			lipgloss.Println(t)
+			return nil
+		}
+
+		// Not a TTY: plain output
+		for _, p := range projectList {
+			cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00"))
+		}
+		return nil
+	},
+}
+
+func init() {
+	projectsCmd.Flags().Bool("json", false, "Output as JSON")
+}

internal/cmd/projects_test.go 🔗

@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"bytes"
+	"encoding/json"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/stretchr/testify/require"
+)
+
+func TestProjectsEmpty(t *testing.T) {
+	// Use a temp directory for projects.json
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+	err := projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+	require.Equal(t, "No projects tracked yet.\n", b.String())
+}
+
+func TestProjectsJSON(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := projects.Register("/test/project", "/test/project/.crush")
+	require.NoError(t, err)
+
+	var b bytes.Buffer
+	projectsCmd.SetOut(&b)
+	projectsCmd.SetErr(&b)
+	projectsCmd.SetIn(bytes.NewReader(nil))
+
+	// Set the --json flag
+	projectsCmd.Flags().Set("json", "true")
+	defer projectsCmd.Flags().Set("json", "false")
+
+	err = projectsCmd.RunE(projectsCmd, nil)
+	require.NoError(t, err)
+
+	// Parse the JSON output
+	var result struct {
+		Projects []projects.Project `json:"projects"`
+	}
+	err = json.Unmarshal(b.Bytes(), &result)
+	require.NoError(t, err)
+
+	require.Len(t, result.Projects, 1)
+	require.Equal(t, "/test/project", result.Projects[0].Path)
+	require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir)
+}

internal/cmd/root.go 🔗

@@ -19,6 +19,7 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/projects"
 	"github.com/charmbracelet/crush/internal/stringext"
 	"github.com/charmbracelet/crush/internal/tui"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -42,6 +43,7 @@ func init() {
 	rootCmd.AddCommand(
 		runCmd,
 		dirsCmd,
+		projectsCmd,
 		updateProvidersCmd,
 		logsCmd,
 		schemaCmd,
@@ -201,6 +203,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 		return nil, err
 	}
 
+	// Register this project in the centralized projects list.
+	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
+		slog.Warn("Failed to register project", "error", err)
+		// Non-fatal: continue even if registration fails
+	}
+
 	// Connect to DB; this will also run migrations.
 	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 	if err != nil {

internal/cmd/run.go 🔗

@@ -8,6 +8,7 @@ import (
 	"os/signal"
 	"strings"
 
+	"github.com/charmbracelet/crush/internal/event"
 	"github.com/spf13/cobra"
 )
 
@@ -58,8 +59,14 @@ crush run --quiet "Generate a README for this project"
 			return fmt.Errorf("no prompt provided")
 		}
 
+		event.SetInteractive(true)
+		event.AppInitialized()
+
 		return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
 	},
+	PostRun: func(cmd *cobra.Command, args []string) {
+		event.AppExited()
+	},
 }
 
 func init() {

internal/event/event.go 🔗

@@ -26,9 +26,14 @@ var (
 			Set("TERM", os.Getenv("TERM")).
 			Set("SHELL", filepath.Base(os.Getenv("SHELL"))).
 			Set("Version", version.Version).
-			Set("GoVersion", runtime.Version())
+			Set("GoVersion", runtime.Version()).
+			Set("Interactive", false)
 )
 
+func SetInteractive(interactive bool) {
+	baseProps = baseProps.Set("interactive", interactive)
+}
+
 func Init() {
 	c, err := posthog.NewWithConfig(key, posthog.Config{
 		Endpoint: endpoint,

internal/projects/projects.go 🔗

@@ -0,0 +1,126 @@
+package projects
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+const projectsFileName = "projects.json"
+
+// Project represents a tracked project directory.
+type Project struct {
+	Path         string    `json:"path"`
+	DataDir      string    `json:"data_dir"`
+	LastAccessed time.Time `json:"last_accessed"`
+}
+
+// ProjectList holds the list of tracked projects.
+type ProjectList struct {
+	Projects []Project `json:"projects"`
+}
+
+var mu sync.Mutex
+
+// projectsFilePath returns the path to the projects.json file.
+func projectsFilePath() string {
+	return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName)
+}
+
+// Load reads the projects list from disk.
+func Load() (*ProjectList, error) {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return &ProjectList{Projects: []Project{}}, nil
+		}
+		return nil, err
+	}
+
+	var list ProjectList
+	if err := json.Unmarshal(data, &list); err != nil {
+		return nil, err
+	}
+
+	return &list, nil
+}
+
+// Save writes the projects list to disk.
+func Save(list *ProjectList) error {
+	mu.Lock()
+	defer mu.Unlock()
+
+	path := projectsFilePath()
+
+	// Ensure directory exists
+	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+		return err
+	}
+
+	data, err := json.MarshalIndent(list, "", "  ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(path, data, 0o600)
+}
+
+// Register adds or updates a project in the list.
+func Register(workingDir, dataDir string) error {
+	list, err := Load()
+	if err != nil {
+		return err
+	}
+
+	now := time.Now().UTC()
+
+	// Check if project already exists
+	found := false
+	for i, p := range list.Projects {
+		if p.Path == workingDir {
+			list.Projects[i].DataDir = dataDir
+			list.Projects[i].LastAccessed = now
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		list.Projects = append(list.Projects, Project{
+			Path:         workingDir,
+			DataDir:      dataDir,
+			LastAccessed: now,
+		})
+	}
+
+	// Sort by last accessed (most recent first)
+	slices.SortFunc(list.Projects, func(a, b Project) int {
+		if a.LastAccessed.After(b.LastAccessed) {
+			return -1
+		}
+		if a.LastAccessed.Before(b.LastAccessed) {
+			return 1
+		}
+		return 0
+	})
+
+	return Save(list)
+}
+
+// List returns all tracked projects sorted by last accessed.
+func List() ([]Project, error) {
+	list, err := Load()
+	if err != nil {
+		return nil, err
+	}
+	return list.Projects, nil
+}

internal/projects/projects_test.go 🔗

@@ -0,0 +1,180 @@
+package projects
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+func TestRegisterAndList(t *testing.T) {
+	// Create a temporary directory for the test
+	tmpDir := t.TempDir()
+
+	// Override the projects file path for testing
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Test registering a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	// List projects
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project1" {
+		t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush" {
+		t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir)
+	}
+
+	// Register another project
+	err = Register("/home/user/project2", "/home/user/project2/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err = List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 2 {
+		t.Fatalf("Expected 2 projects, got %d", len(projects))
+	}
+
+	// Most recent should be first
+	if projects[0].Path != "/home/user/project2" {
+		t.Errorf("Expected most recent project first, got %s", projects[0].Path)
+	}
+}
+
+func TestRegisterUpdatesExisting(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project
+	err := Register("/home/user/project1", "/home/user/project1/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ := List()
+	firstAccess := projects[0].LastAccessed
+
+	// Wait a bit and re-register
+	time.Sleep(10 * time.Millisecond)
+
+	err = Register("/home/user/project1", "/home/user/project1/.crush-new")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, _ = List()
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project after update, got %d", len(projects))
+	}
+
+	if projects[0].DataDir != "/home/user/project1/.crush-new" {
+		t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir)
+	}
+
+	if !projects[0].LastAccessed.After(firstAccess) {
+		t.Error("Expected LastAccessed to be updated")
+	}
+}
+
+func TestLoadEmptyFile(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// List before any projects exist
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 0 {
+		t.Errorf("Expected 0 projects, got %d", len(projects))
+	}
+}
+
+func TestProjectsFilePath(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	expected := filepath.Join(tmpDir, "crush", "projects.json")
+	actual := projectsFilePath()
+
+	if actual != expected {
+		t.Errorf("Expected %s, got %s", expected, actual)
+	}
+}
+
+func TestRegisterWithParentDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a parent directory.
+	// e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush
+	err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/monorepo/packages/app" {
+		t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/home/user/monorepo/.crush" {
+		t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir)
+	}
+}
+
+func TestRegisterWithExternalDataDir(t *testing.T) {
+	tmpDir := t.TempDir()
+	t.Setenv("XDG_DATA_HOME", tmpDir)
+
+	// Register a project where .crush is in a completely different location.
+	// e.g., project at /home/user/project but data stored at /var/data/crush/myproject
+	err := Register("/home/user/project", "/var/data/crush/myproject")
+	if err != nil {
+		t.Fatalf("Register failed: %v", err)
+	}
+
+	projects, err := List()
+	if err != nil {
+		t.Fatalf("List failed: %v", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("Expected 1 project, got %d", len(projects))
+	}
+
+	if projects[0].Path != "/home/user/project" {
+		t.Errorf("Expected path /home/user/project, got %s", projects[0].Path)
+	}
+
+	if projects[0].DataDir != "/var/data/crush/myproject" {
+		t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir)
+	}
+}

internal/tui/components/chat/messages/messages.go 🔗

@@ -311,7 +311,7 @@ func (m *messageCmp) renderThinkingContent() string {
 		}
 	}
 	lineStyle := t.S().Subtle.Background(t.BgBaseLighter)
-	result := lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View())
+	result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View())
 	if footer != "" {
 		result += "\n\n" + footer
 	}

internal/tui/components/dialogs/commands/arguments.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/crush/internal/uicmd"
 )
 
 const (
@@ -18,20 +19,10 @@ const (
 )
 
 // ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
-type ShowArgumentsDialogMsg struct {
-	CommandID   string
-	Description string
-	ArgNames    []string
-	OnSubmit    func(args map[string]string) tea.Cmd
-}
+type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg
 
 // CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
-type CloseArgumentsDialogMsg struct {
-	Submit    bool
-	CommandID string
-	Content   string
-	Args      map[string]string
-}
+type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg
 
 // CommandArgumentsDialog represents the commands dialog.
 type CommandArgumentsDialog interface {

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -23,6 +23,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/crush/internal/uicmd"
 )
 
 const (
@@ -44,13 +45,11 @@ const (
 type listModel = list.FilterableList[list.CompletionItem[Command]]
 
 // Command represents a command that can be executed
-type Command struct {
-	ID          string
-	Title       string
-	Description string
-	Shortcut    string // Optional shortcut for the command
-	Handler     func(cmd Command) tea.Cmd
-}
+type (
+	Command                         = uicmd.Command
+	CommandRunCustomMsg             = uicmd.CommandRunCustomMsg
+	ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
+)
 
 // CommandsDialog represents the commands dialog.
 type CommandsDialog interface {
@@ -121,12 +120,12 @@ func NewCommandDialog(sessionID string) CommandsDialog {
 }
 
 func (c *commandDialogCmp) Init() tea.Cmd {
-	commands, err := LoadCustomCommands()
+	commands, err := uicmd.LoadCustomCommands()
 	if err != nil {
 		return util.ReportError(err)
 	}
 	c.userCommands = commands
-	c.mcpPrompts.SetSlice(loadMCPPrompts())
+	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
 	return c.setCommandType(c.selected)
 }
 
@@ -142,7 +141,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	case pubsub.Event[mcp.Event]:
 		// Reload MCP prompts when MCP state changes
 		if msg.Type == pubsub.UpdatedEvent {
-			c.mcpPrompts.SetSlice(loadMCPPrompts())
+			c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
 			// If we're currently viewing MCP prompts, refresh the list
 			if c.selected == MCPPrompts {
 				return c, c.setCommandType(MCPPrompts)

internal/tui/components/dialogs/commands/loader.go → internal/uicmd/uicmd.go 🔗

@@ -1,4 +1,7 @@
-package commands
+// Package uicmd provides functionality to load and handle custom commands
+// from markdown files and MCP prompts.
+// TODO: Move this into internal/ui after refactoring.
+package uicmd
 
 import (
 	"cmp"
@@ -18,6 +21,31 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
 
+// Command represents a command that can be executed
+type Command struct {
+	ID          string
+	Title       string
+	Description string
+	Shortcut    string // Optional shortcut for the command
+	Handler     func(cmd Command) tea.Cmd
+}
+
+// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
+type ShowArgumentsDialogMsg struct {
+	CommandID   string
+	Description string
+	ArgNames    []string
+	OnSubmit    func(args map[string]string) tea.Cmd
+}
+
+// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
+type CloseArgumentsDialogMsg struct {
+	Submit    bool
+	CommandID string
+	Content   string
+	Args      map[string]string
+}
+
 const (
 	userCommandPrefix    = "user:"
 	projectCommandPrefix = "project:"
@@ -35,7 +63,10 @@ type commandSource struct {
 }
 
 func LoadCustomCommands() ([]Command, error) {
-	cfg := config.Get()
+	return LoadCustomCommandsFromConfig(config.Get())
+}
+
+func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) {
 	if cfg == nil {
 		return nil, fmt.Errorf("config not loaded")
 	}
@@ -221,7 +252,7 @@ type CommandRunCustomMsg struct {
 	Content string
 }
 
-func loadMCPPrompts() []Command {
+func LoadMCPPrompts() []Command {
 	var commands []Command
 	for mcpName, prompts := range mcp.Prompts() {
 		for _, prompt := range prompts {