blob: c0a8bc7fd8dd9f3c5dc2584cd98d00555e9a37fd [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package termui
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "os/signal"
philip.zeyliger6d3de482025-06-10 19:38:14 -070013 "regexp"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "strings"
15 "sync"
16 "syscall"
17 "text/template"
18 "time"
19
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +000020 "github.com/dustin/go-humanize"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "github.com/fatih/color"
22 "golang.org/x/term"
23 "sketch.dev/loop"
24)
25
26var (
27 // toolUseTemplTxt defines how tool invocations appear in the terminal UI.
28 // Keep this template in sync with the tools defined in claudetool package
29 // and registered in loop/agent.go.
30 // Add formatting for new tools as they are created.
31 // TODO: should this be part of tool definition to make it harder to forget to set up?
Josh Bleecher Snyderc3c20232025-05-07 05:46:04 -070032 toolUseTemplTxt = `{{if .msg.ToolError}}ใ€ฐ๏ธ {{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070033{{if eq .msg.ToolName "think" -}}
34 ๐Ÿง  {{.input.thoughts -}}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070035{{else if eq .msg.ToolName "todo_read" -}}
36 ๐Ÿ“‹ Reading todo list
37{{else if eq .msg.ToolName "todo_write" }}
38{{range .input.tasks}}{{if eq .status "queued"}}โšช{{else if eq .status "in-progress"}}๐Ÿฆ‰{{else if eq .status "completed"}}โœ…{{end}} {{.task}}
39{{end}}
Earl Lee2e463fb2025-04-17 11:22:22 -070040{{else if eq .msg.ToolName "keyword_search" -}}
Josh Bleecher Snyder453a62f2025-05-01 10:14:33 -070041 ๐Ÿ” {{ .input.query}}: {{.input.search_terms -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070042{{else if eq .msg.ToolName "bash" -}}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000043 ๐Ÿ–ฅ๏ธ{{if .input.background}}๐Ÿ”„{{end}} {{ .input.command -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070044{{else if eq .msg.ToolName "patch" -}}
45 โŒจ๏ธ {{.input.path -}}
46{{else if eq .msg.ToolName "done" -}}
47{{/* nothing to show here, the agent will write more in its next message */}}
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070048{{else if eq .msg.ToolName "set-slug" -}}
49๐ŸŒ {{.input.slug}}
50{{else if eq .msg.ToolName "commit-message-style" -}}
51๐ŸŒฑ learn git commit message style
Josh Bleecher Snyder74d690e2025-05-14 18:16:03 -070052{{else if eq .msg.ToolName "about_sketch" -}}
53๐Ÿ“š About Sketch
Earl Lee2e463fb2025-04-17 11:22:22 -070054{{else if eq .msg.ToolName "codereview" -}}
55 ๐Ÿ› Running automated code review, may be slow
Sean McCullough485afc62025-04-28 14:28:39 -070056{{else if eq .msg.ToolName "multiplechoice" -}}
57 ๐Ÿ“ {{.input.question}}
58{{ range .input.responseOptions -}}
59 - {{ .caption}}: {{.responseText}}
60{{end -}}
Josh Bleecher Snyder2d081192025-05-29 13:46:04 +000061{{else if eq .msg.ToolName "browser_navigate" -}}
62 ๐ŸŒ {{.input.url -}}
63{{else if eq .msg.ToolName "browser_click" -}}
64 ๐Ÿ–ฑ๏ธ {{.input.selector -}}
65{{else if eq .msg.ToolName "browser_type" -}}
66 โŒจ๏ธ {{.input.selector}}: "{{.input.text}}"
67{{else if eq .msg.ToolName "browser_wait_for" -}}
68 โณ {{.input.selector -}}
69{{else if eq .msg.ToolName "browser_get_text" -}}
70 ๐Ÿ“– {{.input.selector -}}
71{{else if eq .msg.ToolName "browser_eval" -}}
72 ๐Ÿ“ฑ {{.input.expression -}}
73{{else if eq .msg.ToolName "browser_take_screenshot" -}}
74 ๐Ÿ“ธ Screenshot
75{{else if eq .msg.ToolName "browser_scroll_into_view" -}}
76 ๐Ÿ”„ {{.input.selector -}}
77{{else if eq .msg.ToolName "browser_resize" -}}
78 ๐Ÿ–ผ๏ธ {{.input.width}}x{{.input.height -}}
Philip Zeyliger542bda32025-06-11 18:31:03 -070079{{else if eq .msg.ToolName "read_image" -}}
Josh Bleecher Snyder2d081192025-05-29 13:46:04 +000080 ๐Ÿ–ผ๏ธ {{.input.path -}}
81{{else if eq .msg.ToolName "browser_recent_console_logs" -}}
82 ๐Ÿ“œ Console logs
83{{else if eq .msg.ToolName "browser_clear_console_logs" -}}
84 ๐Ÿงน Clear console logs
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070085{{else if eq .msg.ToolName "list_recent_sketch_sessions" -}}
86 ๐Ÿ“š List recent sketch sessions
87{{else if eq .msg.ToolName "read_sketch_session" -}}
88 ๐Ÿ“– Read session {{.input.session_id}}
Earl Lee2e463fb2025-04-17 11:22:22 -070089{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000090 ๐Ÿ› ๏ธ {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070091{{end -}}
92`
93 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
94)
95
David Crawshaw93fec602025-05-05 08:40:06 -070096type TermUI struct {
Earl Lee2e463fb2025-04-17 11:22:22 -070097 stdin *os.File
98 stdout *os.File
99 stderr *os.File
100
101 agent loop.CodingAgent
102 httpURL string
103
104 trm *term.Terminal
105
106 // the chatMsgCh channel is for "conversation" messages, like responses to user input
107 // from the LLM, or output from executing slash-commands issued by the user.
108 chatMsgCh chan chatMessage
109
110 // the log channel is for secondary messages, like logging, errors, and debug information
111 // from local and remove subproceses.
112 termLogCh chan string
113
114 // protects following
115 mu sync.Mutex
116 oldState *term.State
117 // Tracks branches that were pushed during the session
118 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000119
120 // Pending message count, for graceful shutdown
121 messageWaitGroup sync.WaitGroup
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000122
123 currentSlug string
124 titlePushed bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700125}
126
127type chatMessage struct {
128 idx int
129 sender string
130 content string
131 thinking bool
132}
133
David Crawshaw93fec602025-05-05 08:40:06 -0700134func New(agent loop.CodingAgent, httpURL string) *TermUI {
135 return &TermUI{
Earl Lee2e463fb2025-04-17 11:22:22 -0700136 agent: agent,
137 stdin: os.Stdin,
138 stdout: os.Stdout,
139 stderr: os.Stderr,
140 httpURL: httpURL,
141 chatMsgCh: make(chan chatMessage, 1),
142 termLogCh: make(chan string, 1),
143 pushedBranches: make(map[string]struct{}),
144 }
145}
146
David Crawshaw93fec602025-05-05 08:40:06 -0700147func (ui *TermUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700148 fmt.Println(`๐ŸŒ ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700149 fmt.Println(`๐Ÿ’ฌ type 'help' for help`)
150 fmt.Println()
151
152 // Start up the main terminal UI:
153 if err := ui.initializeTerminalUI(ctx); err != nil {
154 return err
155 }
156 go ui.receiveMessagesLoop(ctx)
157 if err := ui.inputLoop(ctx); err != nil {
158 return err
159 }
160 return nil
161}
162
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000163func (ui *TermUI) HandleToolUse(resp *loop.AgentMessage) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700164 inputData := map[string]any{}
165 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
166 ui.AppendSystemMessage("error: %v", err)
167 return
168 }
169 buf := bytes.Buffer{}
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000170 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult, "branch_prefix": ui.agent.BranchPrefix()}); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700171 ui.AppendSystemMessage("error: %v", err)
172 return
173 }
174 ui.AppendSystemMessage("%s\n", buf.String())
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000175
176 if resp.ToolName == "set-slug" {
177 if slug, ok := inputData["slug"].(string); ok {
178 ui.updateTitleWithSlug(slug)
179 }
180 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700181}
182
David Crawshaw93fec602025-05-05 08:40:06 -0700183func (ui *TermUI) receiveMessagesLoop(ctx context.Context) {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700184 it := ui.agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700185 bold := color.New(color.Bold).SprintFunc()
186 for {
187 select {
188 case <-ctx.Done():
189 return
190 default:
191 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700192 resp := it.Next()
193 if resp == nil {
194 return
195 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000196 if resp.HideOutput {
197 continue
198 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700199 // Typically a user message will start the thinking and a (top-level
200 // conversation) end of turn will stop it.
201 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
202
203 switch resp.Type {
204 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700205 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "๐Ÿ•ด๏ธ ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700206 case loop.ToolUseMessageType:
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000207 ui.HandleToolUse(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -0700208 case loop.ErrorMessageType:
209 ui.AppendSystemMessage("โŒ %s", resp.Content)
210 case loop.BudgetMessageType:
211 ui.AppendSystemMessage("๐Ÿ’ฐ %s", resp.Content)
212 case loop.AutoMessageType:
213 ui.AppendSystemMessage("๐Ÿง %s", resp.Content)
214 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700215 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "๐Ÿฆธ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 case loop.CommitMessageType:
217 // Display each commit in the terminal
218 for _, commit := range resp.Commits {
219 if commit.PushedBranch != "" {
philip.zeyliger6d3de482025-06-10 19:38:14 -0700220 // Check if we should show a GitHub link
221 githubURL := ui.getGitHubBranchURL(commit.PushedBranch)
222 if githubURL != "" {
223 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s\npushed to: %s\n๐Ÿ”— %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch), githubURL)
224 } else {
225 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
226 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700227
228 // Track the pushed branch in our map
229 ui.mu.Lock()
230 ui.pushedBranches[commit.PushedBranch] = struct{}{}
231 ui.mu.Unlock()
232 } else {
233 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
234 }
235 }
236 default:
237 ui.AppendSystemMessage("โŒ Unexpected Message Type %s %v", resp.Type, resp)
238 }
239 }
240}
241
David Crawshaw93fec602025-05-05 08:40:06 -0700242func (ui *TermUI) inputLoop(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 for {
244 line, err := ui.trm.ReadLine()
245 if errors.Is(err, io.EOF) {
246 ui.AppendSystemMessage("\n")
247 line = "exit"
248 } else if err != nil {
249 return err
250 }
251
252 line = strings.TrimSpace(line)
253
254 switch line {
255 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700256 ui.AppendSystemMessage(`General use:
257Use chat to ask sketch to tackle a task or answer a question about this repo.
258
259Special commands:
260- help, ? : Show this help message
261- budget : Show original budget
262- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000263- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700264- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700265- exit, quit, q : Exit sketch
266- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 case "budget":
268 originalBudget := ui.agent.OriginalBudget()
269 ui.AppendSystemMessage("๐Ÿ’ฐ Budget summary:")
Philip Zeyligere6c294d2025-06-04 16:55:21 +0000270
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000272 case "browser", "open", "b":
273 if ui.httpURL != "" {
274 ui.AppendSystemMessage("๐ŸŒ Opening %s in browser", ui.httpURL)
275 go ui.agent.OpenBrowser(ui.httpURL)
276 } else {
277 ui.AppendSystemMessage("โŒ No web URL available for this session")
278 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700279 case "usage", "cost":
280 totalUsage := ui.agent.TotalUsage()
281 ui.AppendSystemMessage("๐Ÿ’ฐ Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000282 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
283 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
285 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
286 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
287 case "bye", "exit", "q", "quit":
288 ui.trm.SetPrompt("")
289 // Display final usage stats
290 totalUsage := ui.agent.TotalUsage()
291 ui.AppendSystemMessage("๐Ÿ’ฐ Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000292 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
293 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
295 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
296 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
297
298 // Display pushed branches
299 ui.mu.Lock()
300 if len(ui.pushedBranches) > 0 {
301 // Convert map keys to a slice for display
302 branches := make([]string, 0, len(ui.pushedBranches))
303 for branch := range ui.pushedBranches {
304 branches = append(branches, branch)
305 }
306
Philip Zeyliger49edc922025-05-14 09:45:45 -0700307 initialCommitRef := getShortSHA(ui.agent.SketchGitBase())
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 if len(branches) == 1 {
309 ui.AppendSystemMessage("\n๐Ÿ”„ Branch pushed during session: %s", branches[0])
philip.zeyliger6d3de482025-06-10 19:38:14 -0700310 // Add GitHub link if available
311 if githubURL := ui.getGitHubBranchURL(branches[0]); githubURL != "" {
312 ui.AppendSystemMessage("๐Ÿ”— %s", githubURL)
313 }
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000314 ui.AppendSystemMessage("๐Ÿ’ Cherry-pick those changes: git cherry-pick %s..%s", initialCommitRef, branches[0])
315 ui.AppendSystemMessage("๐Ÿ”€ Merge those changes: git merge %s", branches[0])
316 ui.AppendSystemMessage("๐Ÿ—‘๏ธ Delete the branch: git branch -D %s", branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700317 } else {
318 ui.AppendSystemMessage("\n๐Ÿ”„ Branches pushed during session:")
319 for _, branch := range branches {
320 ui.AppendSystemMessage("- %s", branch)
philip.zeyliger6d3de482025-06-10 19:38:14 -0700321 // Add GitHub link if available
322 if githubURL := ui.getGitHubBranchURL(branch); githubURL != "" {
323 ui.AppendSystemMessage(" ๐Ÿ”— %s", githubURL)
324 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700325 }
326 ui.AppendSystemMessage("\n๐Ÿ’ To add all those changes to your branch:")
327 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000328 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700330 ui.AppendSystemMessage("\n๐Ÿ”€ or:")
331 for _, branch := range branches {
332 ui.AppendSystemMessage("git merge %s", branch)
333 }
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000334
335 ui.AppendSystemMessage("\n๐Ÿ—‘๏ธ To delete branches:")
336 for _, branch := range branches {
337 ui.AppendSystemMessage("git branch -D %s", branch)
338 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 }
340 }
341 ui.mu.Unlock()
342
343 ui.AppendSystemMessage("\n๐Ÿ‘‹ Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000344 // Wait for all pending messages to be processed before exiting
345 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700346 return nil
347 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000348 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700349 case "panic":
350 panic("user forced a panic")
351 default:
352 if line == "" {
353 continue
354 }
355 if strings.HasPrefix(line, "!") {
356 // Execute as shell command
357 line = line[1:] // remove the '!' prefix
358 sendToLLM := strings.HasPrefix(line, "!")
359 if sendToLLM {
360 line = line[1:] // remove the second '!'
361 }
362
363 // Create a cmd and run it
364 // TODO: ui.trm contains a mutex inside its write call.
365 // It is potentially safe to attach ui.trm directly to this
366 // cmd object's Stdout/Stderr and stream the output.
367 // That would make a big difference for, e.g. wget.
368 cmd := exec.Command("bash", "-c", line)
369 out, err := cmd.CombinedOutput()
370 ui.AppendSystemMessage("%s", out)
371 if err != nil {
372 ui.AppendSystemMessage("โŒ Command error: %v", err)
373 }
374 if sendToLLM {
375 // Send the command and its output to the agent
376 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
377 if err != nil {
378 message += fmt.Sprintf("\n\nError: %v", err)
379 }
380 ui.agent.UserMessage(ctx, message)
381 }
382 continue
383 }
384
385 // Send it to the LLM
386 // chatMsg := chatMessage{sender: "you", content: line}
387 // ui.sendChatMessage(chatMsg)
388 ui.agent.UserMessage(ctx, line)
389 }
390 }
391}
392
David Crawshaw93fec602025-05-05 08:40:06 -0700393func (ui *TermUI) updatePrompt(thinking bool) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700394 var t string
Earl Lee2e463fb2025-04-17 11:22:22 -0700395 if thinking {
396 // Emoji don't seem to work here? Messes up my terminal.
397 t = "*"
398 }
Josh Bleecher Snyder03376232025-06-05 14:29:48 -0700399 var money string
400 if totalCost := ui.agent.TotalUsage().TotalCostUSD; totalCost > 0 {
401 money = fmt.Sprintf("($%0.2f/%0.2f)", totalCost, ui.agent.OriginalBudget().MaxDollars)
402 }
403 p := fmt.Sprintf("%s %s%s> ", ui.httpURL, money, t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700404 ui.trm.SetPrompt(p)
405}
406
David Crawshaw93fec602025-05-05 08:40:06 -0700407func (ui *TermUI) initializeTerminalUI(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700408 ui.mu.Lock()
409 defer ui.mu.Unlock()
410
411 if !term.IsTerminal(int(ui.stdin.Fd())) {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000412 return fmt.Errorf("this command requires terminal I/O when termui=true")
Earl Lee2e463fb2025-04-17 11:22:22 -0700413 }
414
415 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
416 if err != nil {
417 return err
418 }
419 ui.oldState = oldState
420 ui.trm = term.NewTerminal(ui.stdin, "")
421 width, height, err := term.GetSize(int(ui.stdin.Fd()))
422 if err != nil {
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000423 return fmt.Errorf("get terminal size: %v", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700424 }
425 ui.trm.SetSize(width, height)
426 // Handle terminal resizes...
427 sig := make(chan os.Signal, 1)
428 signal.Notify(sig, syscall.SIGWINCH)
429 go func() {
430 for {
431 <-sig
432 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
433 if err != nil {
434 continue
435 }
436 if newWidth != width || newHeight != height {
437 width, height = newWidth, newHeight
438 ui.trm.SetSize(width, height)
439 }
440 }
441 }()
442
443 ui.updatePrompt(false)
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000444 ui.pushTerminalTitle()
445 ui.setTerminalTitle("sketch")
Earl Lee2e463fb2025-04-17 11:22:22 -0700446
447 // This is the only place where we should call fe.trm.Write:
448 go func() {
Sean McCullougha4b19f82025-05-05 10:22:59 -0700449 var lastMsg *chatMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700450 for {
451 select {
452 case <-ctx.Done():
453 return
454 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000455 func() {
456 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700457 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
458 ui.updatePrompt(msg.thinking)
459 lastMsg = &msg
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000460 // Sometimes claude doesn't say anything when it runs tools.
461 // No need to output anything in that case.
462 if strings.TrimSpace(msg.content) == "" {
463 return
464 }
465 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000466 ui.trm.Write([]byte(s))
467 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700468 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000469 func() {
470 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700471 if lastMsg != nil {
472 ui.updatePrompt(lastMsg.thinking)
473 } else {
474 ui.updatePrompt(false)
475 }
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000476 b := []byte(logLine + "\n")
477 ui.trm.Write(b)
478 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700479 }
480 }
481 }()
482
483 return nil
484}
485
David Crawshaw93fec602025-05-05 08:40:06 -0700486func (ui *TermUI) RestoreOldState() error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700487 ui.mu.Lock()
488 defer ui.mu.Unlock()
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000489 ui.setTerminalTitle("")
490 ui.popTerminalTitle()
Earl Lee2e463fb2025-04-17 11:22:22 -0700491 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
492}
493
494// AppendChatMessage is for showing responses the user's request, conversational dialog etc
David Crawshaw93fec602025-05-05 08:40:06 -0700495func (ui *TermUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000496 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700497 ui.chatMsgCh <- msg
498}
499
500// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
501// but still need to be shown to the user.
David Crawshaw93fec602025-05-05 08:40:06 -0700502func (ui *TermUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000503 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700504 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
505}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000506
Josh Bleecher Snyder8fdf7532025-05-06 00:56:12 +0000507// getShortSHA returns the short SHA for the given git reference, falling back to the original SHA on error.
508func getShortSHA(sha string) string {
509 cmd := exec.Command("git", "rev-parse", "--short", sha)
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000510 shortSha, err := cmd.Output()
511 if err == nil {
512 shortStr := strings.TrimSpace(string(shortSha))
513 if shortStr != "" {
514 return shortStr
515 }
516 }
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000517 return sha
518}
philip.zeyliger6d3de482025-06-10 19:38:14 -0700519
520// isGitHubRepo checks if the git origin URL is a GitHub repository
521func (ui *TermUI) isGitHubRepo() bool {
522 gitOrigin := ui.agent.GitOrigin()
523 if gitOrigin == "" {
524 return false
525 }
526
527 // Common GitHub URL patterns
528 patterns := []string{
529 `^https://github\.com/[^/]+/[^/\s.]+(?:\.git)?`,
530 `^git@github\.com:[^/]+/[^/\s.]+(?:\.git)?`,
531 `^git://github\.com/[^/]+/[^/\s.]+(?:\.git)?`,
532 }
533
534 for _, pattern := range patterns {
535 if matched, _ := regexp.MatchString(pattern, gitOrigin); matched {
536 return true
537 }
538 }
539 return false
540}
541
542// getGitHubBranchURL generates a GitHub branch URL if conditions are met
543func (ui *TermUI) getGitHubBranchURL(branchName string) string {
544 if !ui.agent.LinkToGitHub() || branchName == "" {
545 return ""
546 }
547
548 gitOrigin := ui.agent.GitOrigin()
549 if gitOrigin == "" || !ui.isGitHubRepo() {
550 return ""
551 }
552
553 // Extract owner and repo from GitHub URL
554 patterns := []string{
555 `^https://github\.com/([^/]+)/([^/\s.]+)(?:\.git)?`,
556 `^git@github\.com:([^/]+)/([^/\s.]+)(?:\.git)?`,
557 `^git://github\.com/([^/]+)/([^/\s.]+)(?:\.git)?`,
558 }
559
560 for _, pattern := range patterns {
561 re := regexp.MustCompile(pattern)
562 matches := re.FindStringSubmatch(gitOrigin)
563 if len(matches) == 3 {
564 owner := matches[1]
565 repo := matches[2]
566 return fmt.Sprintf("https://github.com/%s/%s/tree/%s", owner, repo, branchName)
567 }
568 }
569 return ""
570}
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000571
572// pushTerminalTitle pushes the current terminal title onto the title stack
573// Only works on xterm-compatible terminals, but does no harm elsewhere
574func (ui *TermUI) pushTerminalTitle() {
575 fmt.Fprintf(ui.stderr, "\033[22;0t")
576 ui.titlePushed = true
577}
578
579// popTerminalTitle pops the terminal title from the title stack
580func (ui *TermUI) popTerminalTitle() {
581 if ui.titlePushed {
582 fmt.Fprintf(ui.stderr, "\033[23;0t")
583 ui.titlePushed = false
584 }
585}
586
587func (ui *TermUI) setTerminalTitle(title string) {
588 fmt.Fprintf(ui.stderr, "\033]0;%s\007", title)
589}
590
591// updateTitleWithSlug updates the terminal title with slug slug
592func (ui *TermUI) updateTitleWithSlug(slug string) {
593 ui.mu.Lock()
594 defer ui.mu.Unlock()
595 ui.currentSlug = slug
596 title := "sketch"
597 if slug != "" {
598 title = fmt.Sprintf("sketch: %s", slug)
599 }
600 ui.setTerminalTitle(title)
601}