/ internal / tools / ghostty.go
ghostty.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"hash/fnv"
  8  	"math"
  9  	"strings"
 10  	"sync"
 11  	"time"
 12  
 13  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 14  )
 15  
 16  func agentColor(name string) string {
 17  	h := fnv.New32a()
 18  	h.Write([]byte(name))
 19  	hue := float64(h.Sum32() % 360)
 20  	r, g, b := hslToRGB(hue, 0.65, 0.45)
 21  	return fmt.Sprintf("#%02x%02x%02x", r, g, b)
 22  }
 23  
 24  func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
 25  	c := (1 - math.Abs(2*l-1)) * s
 26  	hPrime := h / 60
 27  	x := c * (1 - math.Abs(math.Mod(hPrime, 2)-1))
 28  	var r1, g1, b1 float64
 29  	switch {
 30  	case hPrime < 1:
 31  		r1, g1, b1 = c, x, 0
 32  	case hPrime < 2:
 33  		r1, g1, b1 = x, c, 0
 34  	case hPrime < 3:
 35  		r1, g1, b1 = 0, c, x
 36  	case hPrime < 4:
 37  		r1, g1, b1 = 0, x, c
 38  	case hPrime < 5:
 39  		r1, g1, b1 = x, 0, c
 40  	default:
 41  		r1, g1, b1 = c, 0, x
 42  	}
 43  	m := l - c/2
 44  	return uint8(math.Round((r1 + m) * 255)),
 45  		uint8(math.Round((g1 + m) * 255)),
 46  		uint8(math.Round((b1 + m) * 255))
 47  }
 48  
 49  // Tab registry
 50  
 51  type tabRef struct {
 52  	windowIndex int
 53  	tabIndex    int
 54  }
 55  
 56  type tabRegistry struct {
 57  	mu   sync.RWMutex
 58  	tabs map[string]tabRef
 59  }
 60  
 61  func newTabRegistry() *tabRegistry {
 62  	return &tabRegistry{tabs: make(map[string]tabRef)}
 63  }
 64  
 65  func (r *tabRegistry) add(title string, ref tabRef) {
 66  	r.mu.Lock()
 67  	defer r.mu.Unlock()
 68  	r.tabs[title] = ref
 69  }
 70  
 71  func (r *tabRegistry) lookup(title string) (tabRef, bool) {
 72  	r.mu.RLock()
 73  	defer r.mu.RUnlock()
 74  	ref, ok := r.tabs[title]
 75  	return ref, ok
 76  }
 77  
 78  func (r *tabRegistry) list() map[string]tabRef {
 79  	r.mu.RLock()
 80  	defer r.mu.RUnlock()
 81  	out := make(map[string]tabRef, len(r.tabs))
 82  	for k, v := range r.tabs {
 83  		out[k] = v
 84  	}
 85  	return out
 86  }
 87  
 88  // GhosttyTool exposes Ghostty terminal control as an agent tool.
 89  type GhosttyTool struct {
 90  	tabs *tabRegistry
 91  }
 92  
 93  type ghosttyArgs struct {
 94  	Action    string `json:"action"`
 95  	Command   string `json:"command,omitempty"`
 96  	Title     string `json:"title,omitempty"`
 97  	Direction string `json:"direction,omitempty"`
 98  	Target    string `json:"target,omitempty"`
 99  	Text      string `json:"text,omitempty"`
100  }
101  
102  func (t *GhosttyTool) Info() agent.ToolInfo {
103  	return agent.ToolInfo{
104  		Name: "ghostty",
105  		Description: "Open and control Ghostty terminal windows (macOS only). " +
106  			"Use this instead of bash when the user wants a visible terminal they can interact with — " +
107  			"e.g. running a dev server, tailing logs, monitoring processes, or setting up a multi-pane workspace. " +
108  			"Use bash for commands where you only need the output (grep, cat, go build, etc.). " +
109  			"Actions: new_tab (open a new terminal tab, optional command/title), " +
110  			"new_split (open a split pane, direction: right|down, optional command/title), " +
111  			"send_input (send keystrokes to a tracked tab by title), " +
112  			"list_tabs (show all tracked tabs).",
113  		Parameters: map[string]any{
114  			"type": "object",
115  			"properties": map[string]any{
116  				"action":    map[string]any{"type": "string", "description": "Action: new_tab, new_split, send_input, list_tabs"},
117  				"command":   map[string]any{"type": "string", "description": "Shell command to run in the new tab/split"},
118  				"title":     map[string]any{"type": "string", "description": "Tab title (defaults to command basename)"},
119  				"direction": map[string]any{"type": "string", "description": "Split direction: right or down (for new_split)"},
120  				"target":    map[string]any{"type": "string", "description": "Tab title to send input to (for send_input)"},
121  				"text":      map[string]any{"type": "string", "description": "Text/keystrokes to send (for send_input)"},
122  			},
123  		},
124  		Required: []string{"action"},
125  	}
126  }
127  
128  func (t *GhosttyTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
129  	var args ghosttyArgs
130  	if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
131  		return agent.ToolResult{Content: fmt.Sprintf("invalid arguments: %v", err), IsError: true}, nil
132  	}
133  	if !ghosttyAvailable() {
134  		return agent.ToolResult{
135  			Content: "Ghostty >= " + minGhosttyVersion + " is required but not found. " +
136  				"Use the applescript tool with macOS Terminal.app instead. " +
137  				"Example: tell application \"Terminal\" to do script \"<command>\"",
138  			IsError: true,
139  		}, nil
140  	}
141  	switch args.Action {
142  	case "new_tab":
143  		return t.runNewTab(args)
144  	case "new_split":
145  		return t.runNewSplit(args)
146  	case "send_input":
147  		return t.runSendInput(args)
148  	case "list_tabs":
149  		return t.runListTabs()
150  	default:
151  		return agent.ToolResult{
152  			Content: fmt.Sprintf("unknown action %q — use new_tab, new_split, send_input, or list_tabs", args.Action),
153  			IsError: true,
154  		}, nil
155  	}
156  }
157  
158  func (t *GhosttyTool) runNewTab(args ghosttyArgs) (agent.ToolResult, error) {
159  	title := resolveTitle(args.Title, args.Command)
160  	color := agentColor(title)
161  	winIdx, tabIdx, err := ghosttyNewTab(args.Command, title, color)
162  	if err != nil {
163  		return agent.ToolResult{Content: err.Error(), IsError: true}, nil
164  	}
165  	t.tabs.add(title, tabRef{windowIndex: winIdx, tabIndex: tabIdx})
166  	result := agent.ToolResult{Content: fmt.Sprintf("opened tab %q (window:%d, tab:%d)", title, winIdx, tabIdx)}
167  	appendScreenshot(&result)
168  	return result, nil
169  }
170  
171  func (t *GhosttyTool) runNewSplit(args ghosttyArgs) (agent.ToolResult, error) {
172  	dir := args.Direction
173  	if dir == "" {
174  		dir = "right"
175  	}
176  	if dir != "right" && dir != "down" {
177  		return agent.ToolResult{Content: fmt.Sprintf("invalid direction %q — use right or down", dir), IsError: true}, nil
178  	}
179  	title := resolveTitle(args.Title, args.Command)
180  	color := agentColor(title)
181  	winIdx, tabIdx, err := ghosttyNewSplit(dir, args.Command, title, color)
182  	if err != nil {
183  		return agent.ToolResult{Content: err.Error(), IsError: true}, nil
184  	}
185  	t.tabs.add(title, tabRef{windowIndex: winIdx, tabIndex: tabIdx})
186  	result := agent.ToolResult{Content: fmt.Sprintf("opened %s split %q", dir, title)}
187  	appendScreenshot(&result)
188  	return result, nil
189  }
190  
191  func (t *GhosttyTool) runSendInput(args ghosttyArgs) (agent.ToolResult, error) {
192  	if args.Target == "" {
193  		return agent.ToolResult{Content: "target is required for send_input", IsError: true}, nil
194  	}
195  	if args.Text == "" {
196  		return agent.ToolResult{Content: "text is required for send_input", IsError: true}, nil
197  	}
198  	ref, ok := t.tabs.lookup(args.Target)
199  	if !ok {
200  		known := make([]string, 0)
201  		for name := range t.tabs.list() {
202  			known = append(known, name)
203  		}
204  		return agent.ToolResult{
205  			Content: fmt.Sprintf("tab %q not found — known tabs: %s", args.Target, strings.Join(known, ", ")),
206  			IsError: true,
207  		}, nil
208  	}
209  	if err := ghosttySendInput(ref.windowIndex, ref.tabIndex, args.Text); err != nil {
210  		return agent.ToolResult{Content: err.Error(), IsError: true}, nil
211  	}
212  	return agent.ToolResult{Content: fmt.Sprintf("sent input to %q", args.Target)}, nil
213  }
214  
215  func (t *GhosttyTool) runListTabs() (agent.ToolResult, error) {
216  	tabs := t.tabs.list()
217  	if len(tabs) == 0 {
218  		return agent.ToolResult{Content: "no tracked tabs"}, nil
219  	}
220  	var sb strings.Builder
221  	for name, ref := range tabs {
222  		sb.WriteString(fmt.Sprintf("- %s (window:%d, tab:%d)\n", name, ref.windowIndex, ref.tabIndex))
223  	}
224  	return agent.ToolResult{Content: sb.String()}, nil
225  }
226  
227  func (t *GhosttyTool) RequiresApproval() bool { return true }
228  
229  func (t *GhosttyTool) IsReadOnlyCall(string) bool { return false }
230  
231  func resolveTitle(title, command string) string {
232  	if title != "" {
233  		return title
234  	}
235  	if command != "" {
236  		parts := strings.Fields(command)
237  		if len(parts) > 0 {
238  			segments := strings.Split(parts[0], "/")
239  			return segments[len(segments)-1]
240  		}
241  	}
242  	return "terminal"
243  }
244  
245  func appendScreenshot(result *agent.ToolResult) {
246  	time.Sleep(500 * time.Millisecond)
247  	_, block, err := CaptureAndEncode(DefaultAPIWidth)
248  	if err == nil {
249  		result.Images = []agent.ImageBlock{block}
250  	}
251  }