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 }