feat: add touch tool for empty files

vorticalbox created

Change summary

internal/agent/coordinator.go      |   1 
internal/agent/tools/touch.go      | 126 ++++++++++++++++++++++++++++++++
internal/agent/tools/touch.md      |  27 ++++++
internal/agent/tools/touch_test.go |  94 +++++++++++++++++++++++
internal/agent/tools/write.go      |   2 
internal/config/config.go          |   1 
internal/config/load_test.go       |   4 
internal/proto/permission.go       |   6 +
internal/proto/tools.go            |  19 ++++
internal/ui/chat/file.go           |  49 ++++++++++++
internal/ui/chat/tools.go          |  25 ++++++
internal/ui/dialog/permissions.go  |  16 +++
12 files changed, 365 insertions(+), 5 deletions(-)

Detailed changes

internal/agent/coordinator.go 🔗

@@ -488,6 +488,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent, isSubA
 		tools.NewLsTool(c.permissions, c.cfg.WorkingDir(), c.cfg.Config().Tools.Ls),
 		tools.NewSourcegraphTool(nil),
 		tools.NewTodosTool(c.sessions),
+		tools.NewTouchTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 		tools.NewViewTool(c.lspManager, c.permissions, c.filetracker, c.skillTracker, c.cfg.WorkingDir(), c.cfg.Config().Options.SkillsPaths...),
 		tools.NewWriteTool(c.lspManager, c.permissions, c.history, c.filetracker, c.cfg.WorkingDir()),
 	)

internal/agent/tools/touch.go 🔗

@@ -0,0 +1,126 @@
+package tools
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"charm.land/fantasy"
+	"github.com/charmbracelet/crush/internal/filepathext"
+	"github.com/charmbracelet/crush/internal/filetracker"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/permission"
+)
+
+//go:embed touch.md
+var touchDescription []byte
+
+type TouchParams struct {
+	FilePath string `json:"file_path" description:"The path to the empty file to create"`
+}
+
+type TouchPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+type TouchResponseMetadata struct {
+	FilePath string `json:"file_path"`
+}
+
+const TouchToolName = "touch"
+
+func NewTouchTool(
+	lspManager *lsp.Manager,
+	permissions permission.Service,
+	files history.Service,
+	filetracker filetracker.Service,
+	workingDir string,
+) fantasy.AgentTool {
+	return fantasy.NewAgentTool(
+		TouchToolName,
+		FirstLineDescription(touchDescription),
+		func(ctx context.Context, params TouchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
+			if params.FilePath == "" {
+				return fantasy.NewTextErrorResponse("file_path is required"), nil
+			}
+
+			sessionID := GetSessionFromContext(ctx)
+			if sessionID == "" {
+				return fantasy.ToolResponse{}, fmt.Errorf("session_id is required")
+			}
+
+			filePath := filepathext.SmartJoin(workingDir, params.FilePath)
+
+			fileInfo, err := os.Stat(filePath)
+			if err == nil {
+				if fileInfo.IsDir() {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("Path is a directory, not a file: %s", filePath)), nil
+				}
+				return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil
+			} else if !os.IsNotExist(err) {
+				return fantasy.ToolResponse{}, fmt.Errorf("error checking file: %w", err)
+			}
+
+			dir := filepath.Dir(filePath)
+			if err = os.MkdirAll(dir, 0o755); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating directory: %w", err)
+			}
+
+			p, err := permissions.Request(ctx,
+				permission.CreatePermissionRequest{
+					SessionID:   sessionID,
+					Path:        fsext.PathOrPrefix(filePath, workingDir),
+					ToolCallID:  call.ID,
+					ToolName:    TouchToolName,
+					Action:      "write",
+					Description: fmt.Sprintf("Create empty file %s", filePath),
+					Params: TouchPermissionsParams{
+						FilePath:   filePath,
+						OldContent: "",
+						NewContent: "",
+					},
+				},
+			)
+			if err != nil {
+				return fantasy.ToolResponse{}, err
+			}
+			if !p {
+				return NewPermissionDeniedResponse(), nil
+			}
+
+			file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o644)
+			if err != nil {
+				if os.IsExist(err) {
+					return fantasy.NewTextErrorResponse(fmt.Sprintf("File already exists: %s", filePath)), nil
+				}
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating file: %w", err)
+			}
+			if err = file.Close(); err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error closing file: %w", err)
+			}
+
+			_, err = files.Create(ctx, sessionID, filePath, "")
+			if err != nil {
+				return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+			}
+
+			filetracker.RecordRead(ctx, sessionID, filePath)
+
+			notifyLSPs(ctx, lspManager, filePath)
+
+			result := fmt.Sprintf("Empty file successfully created: %s", filePath)
+			result = fmt.Sprintf("<result>\n%s\n</result>", result)
+			result += getDiagnostics(filePath, lspManager)
+			return fantasy.WithResponseMetadata(fantasy.NewTextResponse(result),
+				TouchResponseMetadata{
+					FilePath: filePath,
+				},
+			), nil
+		})
+}

internal/agent/tools/touch.md 🔗

@@ -0,0 +1,27 @@
+Create an empty file; auto-creates parent dirs. Fails if the file already exists.
+
+<usage>
+- Provide file path to create
+- Tool creates necessary parent directories automatically
+</usage>
+
+<features>
+- Creates new empty files
+- Auto-creates parent directories if missing
+- Refuses to overwrite existing files
+</features>
+
+<limitations>
+- Cannot write content
+- Cannot update modification times for existing files
+- Cannot create directories
+</limitations>
+
+<cross_platform>
+- Use forward slashes (/) for compatibility
+</cross_platform>
+
+<tips>
+- Use Write tool when the file should contain content
+- Use LS tool to verify location when creating new files
+</tips>

internal/agent/tools/touch_test.go 🔗

@@ -0,0 +1,94 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"charm.land/fantasy"
+	"github.com/stretchr/testify/require"
+)
+
+type mockFileTrackerService struct{}
+
+func (m mockFileTrackerService) RecordRead(ctx context.Context, sessionID, path string) {}
+
+func (m mockFileTrackerService) LastReadTime(ctx context.Context, sessionID, path string) time.Time {
+	return time.Now()
+}
+
+func (m mockFileTrackerService) ListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+	return nil, nil
+}
+
+func TestTouchToolCreatesEmptyFile(t *testing.T) {
+	t.Parallel()
+
+	workingDir := t.TempDir()
+	tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
+	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
+
+	resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "nested/empty.txt"})
+	require.False(t, resp.IsError)
+
+	filePath := filepath.Join(workingDir, "nested", "empty.txt")
+	info, err := os.Stat(filePath)
+	require.NoError(t, err)
+	require.False(t, info.IsDir())
+	require.Zero(t, info.Size())
+}
+
+func TestTouchToolRefusesExistingFile(t *testing.T) {
+	t.Parallel()
+
+	workingDir := t.TempDir()
+	filePath := filepath.Join(workingDir, "existing.txt")
+	require.NoError(t, os.WriteFile(filePath, []byte("content"), 0o644))
+
+	tool := NewTouchTool(nil, &mockPermissionService{}, &mockHistoryService{}, mockFileTrackerService{}, workingDir)
+	ctx := context.WithValue(context.Background(), SessionIDContextKey, "test-session")
+
+	resp := runTouchTool(t, tool, ctx, TouchParams{FilePath: "existing.txt"})
+	require.True(t, resp.IsError)
+	require.Contains(t, resp.Content, "File already exists")
+
+	content, err := os.ReadFile(filePath)
+	require.NoError(t, err)
+	require.Equal(t, "content", string(content))
+}
+
+func TestWriteToolEmptyContentPointsToTouch(t *testing.T) {
+	t.Parallel()
+
+	tool := NewWriteTool(nil, nil, nil, nil, t.TempDir())
+
+	input, err := json.Marshal(WriteParams{FilePath: "empty.txt"})
+	require.NoError(t, err)
+
+	resp, err := tool.Run(context.Background(), fantasy.ToolCall{
+		ID:    "test-call",
+		Name:  WriteToolName,
+		Input: string(input),
+	})
+	require.NoError(t, err)
+	require.True(t, resp.IsError)
+	require.Equal(t, `content is required. use the "touch" tool to create an empty file`, resp.Content)
+}
+
+func runTouchTool(t *testing.T, tool fantasy.AgentTool, ctx context.Context, params TouchParams) fantasy.ToolResponse {
+	t.Helper()
+
+	input, err := json.Marshal(params)
+	require.NoError(t, err)
+
+	resp, err := tool.Run(ctx, fantasy.ToolCall{
+		ID:    "test-call",
+		Name:  TouchToolName,
+		Input: string(input),
+	})
+	require.NoError(t, err)
+	return resp
+}

internal/agent/tools/write.go 🔗

@@ -59,7 +59,7 @@ func NewWriteTool(
 			}
 
 			if params.Content == "" {
-				return fantasy.NewTextErrorResponse("content is required"), nil
+				return fantasy.NewTextErrorResponse(`content is required. use the "touch" tool to create an empty file`), nil
 			}
 
 			sessionID := GetSessionFromContext(ctx)

internal/config/config.go 🔗

@@ -674,6 +674,7 @@ func allToolNames() []string {
 		"ls",
 		"sourcegraph",
 		"todos",
+		"touch",
 		"view",
 		"write",
 		"list_mcp_resources",

internal/config/load_test.go 🔗

@@ -490,7 +490,7 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
 
-	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "touch", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)
@@ -513,7 +513,7 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 	cfg.SetupAgents()
 	coderAgent, ok := cfg.Agents[AgentCoder]
 	require.True(t, ok)
-	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
+	assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "touch", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
 
 	taskAgent, ok := cfg.Agents[AgentTask]
 	require.True(t, ok)

internal/proto/permission.go 🔗

@@ -100,6 +100,12 @@ func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
 			return nil, err
 		}
 		return params, nil
+	case TouchToolName:
+		var params TouchPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
 	case WriteToolName:
 		var params WritePermissionsParams
 		if err := json.Unmarshal(raw, &params); err != nil {

internal/proto/tools.go 🔗

@@ -227,6 +227,25 @@ type ViewResponseMetadata struct {
 	Content  string `json:"content"`
 }
 
+const TouchToolName = "touch"
+
+// TouchParams represents the parameters for the touch tool.
+type TouchParams struct {
+	FilePath string `json:"file_path"`
+}
+
+// TouchPermissionsParams represents the permission parameters for the touch tool.
+type TouchPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+// TouchResponseMetadata represents the metadata for the touch tool response.
+type TouchResponseMetadata struct {
+	FilePath string `json:"file_path"`
+}
+
 const WriteToolName = "write"
 
 // WriteParams represents the parameters for the write tool.

internal/ui/chat/file.go 🔗

@@ -97,6 +97,55 @@ func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *
 	return joinToolParts(header, body)
 }
 
+// -----------------------------------------------------------------------------
+// Touch Tool
+// -----------------------------------------------------------------------------
+
+// TouchToolMessageItem is a message item that represents a touch tool call.
+type TouchToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*TouchToolMessageItem)(nil)
+
+// NewTouchToolMessageItem creates a new [TouchToolMessageItem].
+func NewTouchToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(sty, toolCall, result, &TouchToolRenderContext{}, canceled)
+}
+
+// TouchToolRenderContext renders touch tool messages.
+type TouchToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (t *TouchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	if opts.IsPending() {
+		return pendingTool(sty, "Touch", opts.Anim, opts.Compact)
+	}
+
+	var params tools.TouchParams
+	if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
+		return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
+	}
+
+	file := fsext.PrettyPath(params.FilePath)
+	header := toolHeader(sty, opts.Status, "Touch", cappedWidth, opts.Compact, file)
+	if opts.Compact {
+		return header
+	}
+
+	if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
+		return joinToolParts(header, earlyState)
+	}
+
+	return header
+}
+
 // -----------------------------------------------------------------------------
 // Write Tool
 // -----------------------------------------------------------------------------

internal/ui/chat/tools.go 🔗

@@ -221,6 +221,8 @@ func NewToolMessageItem(
 		item = NewJobKillToolMessageItem(sty, toolCall, result, canceled)
 	case tools.ViewToolName:
 		item = NewViewToolMessageItem(sty, toolCall, result, canceled)
+	case tools.TouchToolName:
+		item = NewTouchToolMessageItem(sty, toolCall, result, canceled)
 	case tools.WriteToolName:
 		item = NewWriteToolMessageItem(sty, toolCall, result, canceled)
 	case tools.EditToolName:
@@ -1076,6 +1078,11 @@ func (t *baseToolMessageItem) formatParametersForCopy() string {
 			parts = append(parts, fmt.Sprintf("**Edits:** %d", len(params.Edits)))
 			return strings.Join(parts, "\n")
 		}
+	case tools.TouchToolName:
+		var params tools.TouchParams
+		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
+			return fmt.Sprintf("**File:** %s", fsext.PrettyPath(params.FilePath))
+		}
 	case tools.WriteToolName:
 		var params tools.WriteParams
 		if json.Unmarshal([]byte(t.toolCall.Input), &params) == nil {
@@ -1217,6 +1224,8 @@ func (t *baseToolMessageItem) formatResultForCopy() string {
 		return t.formatEditResultForCopy()
 	case tools.MultiEditToolName:
 		return t.formatMultiEditResultForCopy()
+	case tools.TouchToolName:
+		return t.formatTouchResultForCopy()
 	case tools.WriteToolName:
 		return t.formatWriteResultForCopy()
 	case tools.FetchToolName:
@@ -1390,6 +1399,20 @@ func (t *baseToolMessageItem) formatMultiEditResultForCopy() string {
 	return result.String()
 }
 
+// formatTouchResultForCopy formats touch tool results for clipboard.
+func (t *baseToolMessageItem) formatTouchResultForCopy() string {
+	if t.result == nil {
+		return ""
+	}
+
+	var params tools.TouchParams
+	if json.Unmarshal([]byte(t.toolCall.Input), &params) != nil {
+		return t.result.Content
+	}
+
+	return fmt.Sprintf("File: %s\n```\n```", fsext.PrettyPath(params.FilePath))
+}
+
 // formatWriteResultForCopy formats write tool results for clipboard.
 func (t *baseToolMessageItem) formatWriteResultForCopy() string {
 	if t.result == nil {
@@ -1575,6 +1598,8 @@ func prettifyToolName(name string) string {
 		return "Sourcegraph"
 	case tools.TodosToolName:
 		return "To-Do"
+	case tools.TouchToolName:
+		return "Touch"
 	case tools.ViewToolName:
 		return "View"
 	case tools.WriteToolName:

internal/ui/dialog/permissions.go 🔗

@@ -316,7 +316,7 @@ func (p *Permissions) respond(action PermissionAction) tea.Msg {
 
 func (p *Permissions) hasDiffView() bool {
 	switch p.permission.ToolName {
-	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName:
+	case tools.EditToolName, tools.TouchToolName, tools.WriteToolName, tools.MultiEditToolName:
 		return true
 	}
 	return false
@@ -463,11 +463,13 @@ func (p *Permissions) renderHeader(contentWidth int) string {
 			lines = append(lines, p.renderKeyValue("URL", params.URL, contentWidth))
 			lines = append(lines, p.renderKeyValue("File", fsext.PrettyPath(params.FilePath), contentWidth))
 		}
-	case tools.EditToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
+	case tools.EditToolName, tools.TouchToolName, tools.WriteToolName, tools.MultiEditToolName, tools.ViewToolName:
 		var filePath string
 		switch params := p.permission.Params.(type) {
 		case tools.EditPermissionsParams:
 			filePath = params.FilePath
+		case tools.TouchPermissionsParams:
+			filePath = params.FilePath
 		case tools.WritePermissionsParams:
 			filePath = params.FilePath
 		case tools.MultiEditPermissionsParams:
@@ -527,6 +529,8 @@ func (p *Permissions) renderContent(width int) string {
 		return p.renderBashContent(width)
 	case tools.EditToolName:
 		return p.renderEditContent(width)
+	case tools.TouchToolName:
+		return p.renderTouchContent(width)
 	case tools.WriteToolName:
 		return p.renderWriteContent(width)
 	case tools.MultiEditToolName:
@@ -563,6 +567,14 @@ func (p *Permissions) renderEditContent(contentWidth int) string {
 	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
 }
 
+func (p *Permissions) renderTouchContent(contentWidth int) string {
+	params, ok := p.permission.Params.(tools.TouchPermissionsParams)
+	if !ok {
+		return ""
+	}
+	return p.renderDiff(params.FilePath, params.OldContent, params.NewContent, contentWidth)
+}
+
 func (p *Permissions) renderWriteContent(contentWidth int) string {
 	params, ok := p.permission.Params.(tools.WritePermissionsParams)
 	if !ok {