fix: render permission dialog content in client/server mode (#2877)

Christian Rocha and Charm Crush created

Co-authored-by: Charm Crush <crush@charm.land>

Change summary

internal/proto/permission.go      |   6 +
internal/proto/permission_test.go | 186 +++++++++++++++++++++++++++++++++
internal/proto/tools.go           |  60 +++------
3 files changed, 214 insertions(+), 38 deletions(-)

Detailed changes

internal/proto/permission.go 🔗

@@ -118,6 +118,12 @@ func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
 			return nil, err
 		}
 		return params, nil
+	case AgenticFetchToolName:
+		var params AgenticFetchPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
 	case ViewToolName:
 		var params ViewPermissionsParams
 		if err := json.Unmarshal(raw, &params); err != nil {

internal/proto/permission_test.go 🔗

@@ -0,0 +1,186 @@
+package proto_test
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/stretchr/testify/require"
+)
+
+// TestPermissionRequestParamsTypeAssertable guards the permission
+// dialog's type assertions across the client/server boundary. The TUI
+// asserts PermissionRequest.Params to tools.*PermissionsParams; when
+// the request round-trips over the SSE wire (server → client), the
+// decoded value must be the same Go type, otherwise the dialog
+// renders empty content.
+func TestPermissionRequestParamsTypeAssertable(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		toolName string
+		params   any
+		assert   func(t *testing.T, got any)
+	}{
+		{
+			name:     "bash",
+			toolName: tools.BashToolName,
+			params: tools.BashPermissionsParams{
+				Description:     "list files",
+				Command:         "ls -la",
+				WorkingDir:      "/tmp",
+				RunInBackground: false,
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.BashPermissionsParams)
+				require.True(t, ok, "params must decode as tools.BashPermissionsParams, got %T", got)
+				require.Equal(t, "list files", v.Description)
+				require.Equal(t, "ls -la", v.Command)
+				require.Equal(t, "/tmp", v.WorkingDir)
+			},
+		},
+		{
+			name:     "edit",
+			toolName: tools.EditToolName,
+			params: tools.EditPermissionsParams{
+				FilePath:   "/tmp/x.go",
+				OldContent: "old",
+				NewContent: "new",
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.EditPermissionsParams)
+				require.True(t, ok, "params must decode as tools.EditPermissionsParams, got %T", got)
+				require.Equal(t, "/tmp/x.go", v.FilePath)
+				require.Equal(t, "old", v.OldContent)
+				require.Equal(t, "new", v.NewContent)
+			},
+		},
+		{
+			name:     "write",
+			toolName: tools.WriteToolName,
+			params: tools.WritePermissionsParams{
+				FilePath:   "/tmp/x.go",
+				NewContent: "new",
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.WritePermissionsParams)
+				require.True(t, ok, "params must decode as tools.WritePermissionsParams, got %T", got)
+				require.Equal(t, "/tmp/x.go", v.FilePath)
+				require.Equal(t, "new", v.NewContent)
+			},
+		},
+		{
+			name:     "multiedit",
+			toolName: tools.MultiEditToolName,
+			params: tools.MultiEditPermissionsParams{
+				FilePath:   "/tmp/x.go",
+				OldContent: "old",
+				NewContent: "new",
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.MultiEditPermissionsParams)
+				require.True(t, ok, "params must decode as tools.MultiEditPermissionsParams, got %T", got)
+				require.Equal(t, "/tmp/x.go", v.FilePath)
+			},
+		},
+		{
+			name:     "ls",
+			toolName: tools.LSToolName,
+			params: tools.LSPermissionsParams{
+				Path:   "/tmp",
+				Ignore: []string{".git"},
+				Depth:  2,
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.LSPermissionsParams)
+				require.True(t, ok, "params must decode as tools.LSPermissionsParams, got %T", got)
+				require.Equal(t, "/tmp", v.Path)
+				require.Equal(t, []string{".git"}, v.Ignore)
+				require.Equal(t, 2, v.Depth)
+			},
+		},
+		{
+			name:     "view",
+			toolName: tools.ViewToolName,
+			params: tools.ViewPermissionsParams{
+				FilePath: "/tmp/x.go",
+				Offset:   10,
+				Limit:    100,
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.ViewPermissionsParams)
+				require.True(t, ok, "params must decode as tools.ViewPermissionsParams, got %T", got)
+				require.Equal(t, "/tmp/x.go", v.FilePath)
+			},
+		},
+		{
+			name:     "fetch",
+			toolName: tools.FetchToolName,
+			params: tools.FetchPermissionsParams{
+				URL:    "https://example.com",
+				Format: "text",
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.FetchPermissionsParams)
+				require.True(t, ok, "params must decode as tools.FetchPermissionsParams, got %T", got)
+				require.Equal(t, "https://example.com", v.URL)
+			},
+		},
+		{
+			name:     "download",
+			toolName: tools.DownloadToolName,
+			params: tools.DownloadPermissionsParams{
+				URL:      "https://example.com/x.zip",
+				FilePath: "/tmp/x.zip",
+				Timeout:  30,
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.DownloadPermissionsParams)
+				require.True(t, ok, "params must decode as tools.DownloadPermissionsParams, got %T", got)
+				require.Equal(t, "https://example.com/x.zip", v.URL)
+				require.Equal(t, "/tmp/x.zip", v.FilePath)
+			},
+		},
+		{
+			name:     "agentic_fetch",
+			toolName: tools.AgenticFetchToolName,
+			params: tools.AgenticFetchPermissionsParams{
+				URL:    "https://example.com",
+				Prompt: "summarize this page",
+			},
+			assert: func(t *testing.T, got any) {
+				v, ok := got.(tools.AgenticFetchPermissionsParams)
+				require.True(t, ok, "params must decode as tools.AgenticFetchPermissionsParams, got %T", got)
+				require.Equal(t, "https://example.com", v.URL)
+				require.Equal(t, "summarize this page", v.Prompt)
+			},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			// Build a server-side request with the tool's concrete
+			// params type, marshal to JSON (the wire path), then
+			// decode back through proto.PermissionRequest.
+			outbound := proto.PermissionRequest{
+				ID:         "perm-1",
+				SessionID:  "sess-1",
+				ToolCallID: "call-1",
+				ToolName:   tc.toolName,
+				Path:       "/tmp",
+				Params:     tc.params,
+			}
+			data, err := json.Marshal(outbound)
+			require.NoError(t, err)
+
+			var inbound proto.PermissionRequest
+			require.NoError(t, json.Unmarshal(data, &inbound))
+
+			tc.assert(t, inbound.Params)
+		})
+	}
+}

internal/proto/tools.go 🔗

@@ -1,5 +1,12 @@
 package proto
 
+// The wire schema for per-tool permission parameters is owned by the
+// tool itself, not duplicated here. We alias the canonical types so
+// there is exactly one source of truth and so values survive a
+// round-trip across the client/server boundary as the same Go type
+// the UI asserts on.
+import "github.com/charmbracelet/crush/internal/agent/tools"
+
 // ToolResponseType represents the type of tool response.
 type ToolResponseType string
 
@@ -25,10 +32,7 @@ type BashParams struct {
 }
 
 // BashPermissionsParams represents the permission parameters for the bash tool.
-type BashPermissionsParams struct {
-	Command string `json:"command"`
-	Timeout int    `json:"timeout"`
-}
+type BashPermissionsParams = tools.BashPermissionsParams
 
 // BashResponseMetadata represents the metadata for a bash tool response.
 type BashResponseMetadata struct {
@@ -53,11 +57,7 @@ type DownloadParams struct {
 }
 
 // DownloadPermissionsParams represents the permission parameters for the download tool.
-type DownloadPermissionsParams struct {
-	URL      string `json:"url"`
-	FilePath string `json:"file_path"`
-	Timeout  int    `json:"timeout,omitempty"`
-}
+type DownloadPermissionsParams = tools.DownloadPermissionsParams
 
 const EditToolName = "edit"
 
@@ -70,11 +70,7 @@ type EditParams struct {
 }
 
 // EditPermissionsParams represents the permission parameters for the edit tool.
-type EditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
+type EditPermissionsParams = tools.EditPermissionsParams
 
 // EditResponseMetadata represents the metadata for an edit tool response.
 type EditResponseMetadata struct {
@@ -94,11 +90,14 @@ type FetchParams struct {
 }
 
 // FetchPermissionsParams represents the permission parameters for the fetch tool.
-type FetchPermissionsParams struct {
-	URL     string `json:"url"`
-	Format  string `json:"format"`
-	Timeout int    `json:"timeout,omitempty"`
-}
+type FetchPermissionsParams = tools.FetchPermissionsParams
+
+// AgenticFetchToolName is the name of the agentic_fetch tool.
+const AgenticFetchToolName = tools.AgenticFetchToolName
+
+// AgenticFetchPermissionsParams represents the permission parameters for the
+// agentic_fetch tool.
+type AgenticFetchPermissionsParams = tools.AgenticFetchPermissionsParams
 
 const GlobToolName = "glob"
 
@@ -139,10 +138,7 @@ type LSParams struct {
 }
 
 // LSPermissionsParams represents the permission parameters for the ls tool.
-type LSPermissionsParams struct {
-	Path   string   `json:"path"`
-	Ignore []string `json:"ignore"`
-}
+type LSPermissionsParams = tools.LSPermissionsParams
 
 // TreeNode represents a node in a directory tree.
 type TreeNode struct {
@@ -174,11 +170,7 @@ type MultiEditParams struct {
 }
 
 // MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
-type MultiEditPermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
+type MultiEditPermissionsParams = tools.MultiEditPermissionsParams
 
 // MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
 type MultiEditResponseMetadata struct {
@@ -215,11 +207,7 @@ type ViewParams struct {
 }
 
 // ViewPermissionsParams represents the permission parameters for the view tool.
-type ViewPermissionsParams struct {
-	FilePath string `json:"file_path"`
-	Offset   int    `json:"offset"`
-	Limit    int    `json:"limit"`
-}
+type ViewPermissionsParams = tools.ViewPermissionsParams
 
 // ViewResponseMetadata represents the metadata for a view tool response.
 type ViewResponseMetadata struct {
@@ -236,11 +224,7 @@ type WriteParams struct {
 }
 
 // WritePermissionsParams represents the permission parameters for the write tool.
-type WritePermissionsParams struct {
-	FilePath   string `json:"file_path"`
-	OldContent string `json:"old_content,omitempty"`
-	NewContent string `json:"new_content,omitempty"`
-}
+type WritePermissionsParams = tools.WritePermissionsParams
 
 // WriteResponseMetadata represents the metadata for a write tool response.
 type WriteResponseMetadata struct {