blob: 70f9841528b4960c62500591533a875b64b9c958 [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" -}}
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +000043 ๐Ÿ–ฅ๏ธ {{if .input.background}}๐Ÿฅท {{end}}{{if .input.slow_ok}}๐Ÿข {{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 Snyder74d690e2025-05-14 18:16:03 -070048{{else if eq .msg.ToolName "about_sketch" -}}
49๐Ÿ“š About Sketch
Earl Lee2e463fb2025-04-17 11:22:22 -070050{{else if eq .msg.ToolName "codereview" -}}
51 ๐Ÿ› Running automated code review, may be slow
Josh Bleecher Snyder2d081192025-05-29 13:46:04 +000052{{else if eq .msg.ToolName "browser_navigate" -}}
53 ๐ŸŒ {{.input.url -}}
Josh Bleecher Snyder2d081192025-05-29 13:46:04 +000054{{else if eq .msg.ToolName "browser_eval" -}}
55 ๐Ÿ“ฑ {{.input.expression -}}
56{{else if eq .msg.ToolName "browser_take_screenshot" -}}
57 ๐Ÿ“ธ Screenshot
Philip Zeyliger542bda32025-06-11 18:31:03 -070058{{else if eq .msg.ToolName "read_image" -}}
Josh Bleecher Snyder2d081192025-05-29 13:46:04 +000059 ๐Ÿ–ผ๏ธ {{.input.path -}}
60{{else if eq .msg.ToolName "browser_recent_console_logs" -}}
61 ๐Ÿ“œ Console logs
62{{else if eq .msg.ToolName "browser_clear_console_logs" -}}
63 ๐Ÿงน Clear console logs
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070064{{else if eq .msg.ToolName "list_recent_sketch_sessions" -}}
65 ๐Ÿ“š List recent sketch sessions
66{{else if eq .msg.ToolName "read_sketch_session" -}}
67 ๐Ÿ“– Read session {{.input.session_id}}
gio30503072025-06-17 10:50:15 +000068{{else if eq .msg.ToolName "dodo" -}}
69 ๐Ÿ‘‹ {{.output.message}}
Earl Lee2e463fb2025-04-17 11:22:22 -070070{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000071 ๐Ÿ› ๏ธ {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070072{{end -}}
73`
74 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
75)
76
David Crawshaw93fec602025-05-05 08:40:06 -070077type TermUI struct {
Earl Lee2e463fb2025-04-17 11:22:22 -070078 stdin *os.File
79 stdout *os.File
80 stderr *os.File
81
82 agent loop.CodingAgent
83 httpURL string
84
85 trm *term.Terminal
86
87 // the chatMsgCh channel is for "conversation" messages, like responses to user input
88 // from the LLM, or output from executing slash-commands issued by the user.
89 chatMsgCh chan chatMessage
90
91 // the log channel is for secondary messages, like logging, errors, and debug information
92 // from local and remove subproceses.
93 termLogCh chan string
94
95 // protects following
96 mu sync.Mutex
97 oldState *term.State
98 // Tracks branches that were pushed during the session
99 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000100
101 // Pending message count, for graceful shutdown
102 messageWaitGroup sync.WaitGroup
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000103
104 currentSlug string
105 titlePushed bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700106}
107
108type chatMessage struct {
109 idx int
110 sender string
111 content string
112 thinking bool
113}
114
David Crawshaw93fec602025-05-05 08:40:06 -0700115func New(agent loop.CodingAgent, httpURL string) *TermUI {
116 return &TermUI{
Earl Lee2e463fb2025-04-17 11:22:22 -0700117 agent: agent,
118 stdin: os.Stdin,
119 stdout: os.Stdout,
120 stderr: os.Stderr,
121 httpURL: httpURL,
122 chatMsgCh: make(chan chatMessage, 1),
123 termLogCh: make(chan string, 1),
124 pushedBranches: make(map[string]struct{}),
125 }
126}
127
David Crawshaw93fec602025-05-05 08:40:06 -0700128func (ui *TermUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700129 fmt.Println(`๐ŸŒ ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700130 fmt.Println(`๐Ÿ’ฌ type 'help' for help`)
131 fmt.Println()
132
133 // Start up the main terminal UI:
134 if err := ui.initializeTerminalUI(ctx); err != nil {
135 return err
136 }
137 go ui.receiveMessagesLoop(ctx)
138 if err := ui.inputLoop(ctx); err != nil {
139 return err
140 }
141 return nil
142}
143
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000144func (ui *TermUI) HandleToolUse(resp *loop.AgentMessage) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700145 inputData := map[string]any{}
146 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
147 ui.AppendSystemMessage("error: %v", err)
148 return
149 }
150 buf := bytes.Buffer{}
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000151 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 -0700152 ui.AppendSystemMessage("error: %v", err)
153 return
154 }
155 ui.AppendSystemMessage("%s\n", buf.String())
156}
157
David Crawshaw93fec602025-05-05 08:40:06 -0700158func (ui *TermUI) receiveMessagesLoop(ctx context.Context) {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700159 it := ui.agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 bold := color.New(color.Bold).SprintFunc()
161 for {
162 select {
163 case <-ctx.Done():
164 return
165 default:
166 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700167 resp := it.Next()
168 if resp == nil {
169 return
170 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000171 if resp.HideOutput {
172 continue
173 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700174 // Typically a user message will start the thinking and a (top-level
175 // conversation) end of turn will stop it.
176 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
177
178 switch resp.Type {
179 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700180 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "๐Ÿ•ด๏ธ ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700181 case loop.ToolUseMessageType:
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000182 ui.HandleToolUse(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 case loop.ErrorMessageType:
184 ui.AppendSystemMessage("โŒ %s", resp.Content)
185 case loop.BudgetMessageType:
186 ui.AppendSystemMessage("๐Ÿ’ฐ %s", resp.Content)
187 case loop.AutoMessageType:
188 ui.AppendSystemMessage("๐Ÿง %s", resp.Content)
189 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700190 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "๐Ÿฆธ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700191 case loop.CommitMessageType:
192 // Display each commit in the terminal
193 for _, commit := range resp.Commits {
194 if commit.PushedBranch != "" {
philip.zeyliger6d3de482025-06-10 19:38:14 -0700195 // Check if we should show a GitHub link
196 githubURL := ui.getGitHubBranchURL(commit.PushedBranch)
197 if githubURL != "" {
198 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s\npushed to: %s\n๐Ÿ”— %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch), githubURL)
199 } else {
200 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
201 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700202
203 // Track the pushed branch in our map
204 ui.mu.Lock()
205 ui.pushedBranches[commit.PushedBranch] = struct{}{}
206 ui.mu.Unlock()
207 } else {
208 ui.AppendSystemMessage("๐Ÿ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
209 }
210 }
Josh Bleecher Snyder289525b2025-07-08 04:03:02 +0000211 case loop.PortMessageType:
212 ui.AppendSystemMessage("๐Ÿ”Œ %s", resp.Content)
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +0000213 case loop.SlugMessageType:
214 ui.updateTitleWithSlug(resp.Content)
215 case loop.CompactMessageType:
216 // TODO: print something for compaction?
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 default:
218 ui.AppendSystemMessage("โŒ Unexpected Message Type %s %v", resp.Type, resp)
219 }
220 }
221}
222
David Crawshaw93fec602025-05-05 08:40:06 -0700223func (ui *TermUI) inputLoop(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 for {
225 line, err := ui.trm.ReadLine()
226 if errors.Is(err, io.EOF) {
227 ui.AppendSystemMessage("\n")
228 line = "exit"
229 } else if err != nil {
230 return err
231 }
232
233 line = strings.TrimSpace(line)
234
235 switch line {
236 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700237 ui.AppendSystemMessage(`General use:
238Use chat to ask sketch to tackle a task or answer a question about this repo.
239
240Special commands:
241- help, ? : Show this help message
242- budget : Show original budget
243- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000244- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700245- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700246- exit, quit, q : Exit sketch
247- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 case "budget":
249 originalBudget := ui.agent.OriginalBudget()
250 ui.AppendSystemMessage("๐Ÿ’ฐ Budget summary:")
Philip Zeyligere6c294d2025-06-04 16:55:21 +0000251
Earl Lee2e463fb2025-04-17 11:22:22 -0700252 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder89ba5f42025-07-17 14:21:43 -0700253 case "browser", "open", "b", "v": // "v" is a common typo for "b"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000254 if ui.httpURL != "" {
255 ui.AppendSystemMessage("๐ŸŒ Opening %s in browser", ui.httpURL)
256 go ui.agent.OpenBrowser(ui.httpURL)
257 } else {
258 ui.AppendSystemMessage("โŒ No web URL available for this session")
259 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 case "usage", "cost":
261 totalUsage := ui.agent.TotalUsage()
262 ui.AppendSystemMessage("๐Ÿ’ฐ Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000263 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
264 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700265 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
266 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
267 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
268 case "bye", "exit", "q", "quit":
269 ui.trm.SetPrompt("")
270 // Display final usage stats
271 totalUsage := ui.agent.TotalUsage()
272 ui.AppendSystemMessage("๐Ÿ’ฐ Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000273 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
274 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
276 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
277 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
278
279 // Display pushed branches
280 ui.mu.Lock()
281 if len(ui.pushedBranches) > 0 {
282 // Convert map keys to a slice for display
283 branches := make([]string, 0, len(ui.pushedBranches))
284 for branch := range ui.pushedBranches {
285 branches = append(branches, branch)
286 }
287
Philip Zeyliger49edc922025-05-14 09:45:45 -0700288 initialCommitRef := getShortSHA(ui.agent.SketchGitBase())
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 if len(branches) == 1 {
290 ui.AppendSystemMessage("\n๐Ÿ”„ Branch pushed during session: %s", branches[0])
philip.zeyliger6d3de482025-06-10 19:38:14 -0700291 // Add GitHub link if available
292 if githubURL := ui.getGitHubBranchURL(branches[0]); githubURL != "" {
293 ui.AppendSystemMessage("๐Ÿ”— %s", githubURL)
294 }
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000295 ui.AppendSystemMessage("๐Ÿ’ Cherry-pick those changes: git cherry-pick %s..%s", initialCommitRef, branches[0])
296 ui.AppendSystemMessage("๐Ÿ”€ Merge those changes: git merge %s", branches[0])
297 ui.AppendSystemMessage("๐Ÿ—‘๏ธ Delete the branch: git branch -D %s", branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 } else {
299 ui.AppendSystemMessage("\n๐Ÿ”„ Branches pushed during session:")
300 for _, branch := range branches {
301 ui.AppendSystemMessage("- %s", branch)
philip.zeyliger6d3de482025-06-10 19:38:14 -0700302 // Add GitHub link if available
303 if githubURL := ui.getGitHubBranchURL(branch); githubURL != "" {
304 ui.AppendSystemMessage(" ๐Ÿ”— %s", githubURL)
305 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700306 }
307 ui.AppendSystemMessage("\n๐Ÿ’ To add all those changes to your branch:")
308 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000309 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700311 ui.AppendSystemMessage("\n๐Ÿ”€ or:")
312 for _, branch := range branches {
313 ui.AppendSystemMessage("git merge %s", branch)
314 }
Josh Bleecher Snyder956626d2025-05-15 21:24:07 +0000315
316 ui.AppendSystemMessage("\n๐Ÿ—‘๏ธ To delete branches:")
317 for _, branch := range branches {
318 ui.AppendSystemMessage("git branch -D %s", branch)
319 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700320 }
321 }
322 ui.mu.Unlock()
323
324 ui.AppendSystemMessage("\n๐Ÿ‘‹ Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000325 // Wait for all pending messages to be processed before exiting
326 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700327 return nil
328 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000329 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 case "panic":
331 panic("user forced a panic")
332 default:
333 if line == "" {
334 continue
335 }
336 if strings.HasPrefix(line, "!") {
337 // Execute as shell command
338 line = line[1:] // remove the '!' prefix
339 sendToLLM := strings.HasPrefix(line, "!")
340 if sendToLLM {
341 line = line[1:] // remove the second '!'
342 }
343
344 // Create a cmd and run it
345 // TODO: ui.trm contains a mutex inside its write call.
346 // It is potentially safe to attach ui.trm directly to this
347 // cmd object's Stdout/Stderr and stream the output.
348 // That would make a big difference for, e.g. wget.
349 cmd := exec.Command("bash", "-c", line)
350 out, err := cmd.CombinedOutput()
351 ui.AppendSystemMessage("%s", out)
352 if err != nil {
353 ui.AppendSystemMessage("โŒ Command error: %v", err)
354 }
355 if sendToLLM {
356 // Send the command and its output to the agent
357 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
358 if err != nil {
359 message += fmt.Sprintf("\n\nError: %v", err)
360 }
361 ui.agent.UserMessage(ctx, message)
362 }
363 continue
364 }
365
366 // Send it to the LLM
367 // chatMsg := chatMessage{sender: "you", content: line}
368 // ui.sendChatMessage(chatMsg)
369 ui.agent.UserMessage(ctx, line)
370 }
371 }
372}
373
David Crawshaw93fec602025-05-05 08:40:06 -0700374func (ui *TermUI) updatePrompt(thinking bool) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700375 var t string
Earl Lee2e463fb2025-04-17 11:22:22 -0700376 if thinking {
377 // Emoji don't seem to work here? Messes up my terminal.
Josh Bleecher Snydera77889b2025-07-28 13:08:14 -0700378 t = " *"
Earl Lee2e463fb2025-04-17 11:22:22 -0700379 }
Josh Bleecher Snydera77889b2025-07-28 13:08:14 -0700380 p := fmt.Sprintf("%s%s> ", ui.agent.Slug(), t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700381 ui.trm.SetPrompt(p)
382}
383
David Crawshaw93fec602025-05-05 08:40:06 -0700384func (ui *TermUI) initializeTerminalUI(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700385 ui.mu.Lock()
386 defer ui.mu.Unlock()
387
388 if !term.IsTerminal(int(ui.stdin.Fd())) {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000389 return fmt.Errorf("this command requires terminal I/O when termui=true")
Earl Lee2e463fb2025-04-17 11:22:22 -0700390 }
391
392 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
393 if err != nil {
394 return err
395 }
396 ui.oldState = oldState
397 ui.trm = term.NewTerminal(ui.stdin, "")
398 width, height, err := term.GetSize(int(ui.stdin.Fd()))
399 if err != nil {
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000400 return fmt.Errorf("get terminal size: %v", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 }
402 ui.trm.SetSize(width, height)
403 // Handle terminal resizes...
404 sig := make(chan os.Signal, 1)
405 signal.Notify(sig, syscall.SIGWINCH)
406 go func() {
407 for {
408 <-sig
409 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
410 if err != nil {
411 continue
412 }
413 if newWidth != width || newHeight != height {
414 width, height = newWidth, newHeight
415 ui.trm.SetSize(width, height)
416 }
417 }
418 }()
419
420 ui.updatePrompt(false)
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000421 ui.pushTerminalTitle()
422 ui.setTerminalTitle("sketch")
Earl Lee2e463fb2025-04-17 11:22:22 -0700423
424 // This is the only place where we should call fe.trm.Write:
425 go func() {
Sean McCullougha4b19f82025-05-05 10:22:59 -0700426 var lastMsg *chatMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700427 for {
428 select {
429 case <-ctx.Done():
430 return
431 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000432 func() {
433 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700434 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
435 ui.updatePrompt(msg.thinking)
436 lastMsg = &msg
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000437 // Sometimes claude doesn't say anything when it runs tools.
438 // No need to output anything in that case.
439 if strings.TrimSpace(msg.content) == "" {
440 return
441 }
442 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000443 ui.trm.Write([]byte(s))
444 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700445 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000446 func() {
447 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700448 if lastMsg != nil {
449 ui.updatePrompt(lastMsg.thinking)
450 } else {
451 ui.updatePrompt(false)
452 }
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000453 b := []byte(logLine + "\n")
454 ui.trm.Write(b)
455 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700456 }
457 }
458 }()
459
460 return nil
461}
462
David Crawshaw93fec602025-05-05 08:40:06 -0700463func (ui *TermUI) RestoreOldState() error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700464 ui.mu.Lock()
465 defer ui.mu.Unlock()
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000466 ui.setTerminalTitle("")
467 ui.popTerminalTitle()
Earl Lee2e463fb2025-04-17 11:22:22 -0700468 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
469}
470
471// AppendChatMessage is for showing responses the user's request, conversational dialog etc
David Crawshaw93fec602025-05-05 08:40:06 -0700472func (ui *TermUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000473 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700474 ui.chatMsgCh <- msg
475}
476
477// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
478// but still need to be shown to the user.
David Crawshaw93fec602025-05-05 08:40:06 -0700479func (ui *TermUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000480 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700481 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
482}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000483
Josh Bleecher Snyder8fdf7532025-05-06 00:56:12 +0000484// getShortSHA returns the short SHA for the given git reference, falling back to the original SHA on error.
485func getShortSHA(sha string) string {
486 cmd := exec.Command("git", "rev-parse", "--short", sha)
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000487 shortSha, err := cmd.Output()
488 if err == nil {
489 shortStr := strings.TrimSpace(string(shortSha))
490 if shortStr != "" {
491 return shortStr
492 }
493 }
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000494 return sha
495}
philip.zeyliger6d3de482025-06-10 19:38:14 -0700496
497// isGitHubRepo checks if the git origin URL is a GitHub repository
498func (ui *TermUI) isGitHubRepo() bool {
499 gitOrigin := ui.agent.GitOrigin()
500 if gitOrigin == "" {
501 return false
502 }
503
504 // Common GitHub URL patterns
505 patterns := []string{
506 `^https://github\.com/[^/]+/[^/\s.]+(?:\.git)?`,
507 `^git@github\.com:[^/]+/[^/\s.]+(?:\.git)?`,
508 `^git://github\.com/[^/]+/[^/\s.]+(?:\.git)?`,
509 }
510
511 for _, pattern := range patterns {
512 if matched, _ := regexp.MatchString(pattern, gitOrigin); matched {
513 return true
514 }
515 }
516 return false
517}
518
519// getGitHubBranchURL generates a GitHub branch URL if conditions are met
520func (ui *TermUI) getGitHubBranchURL(branchName string) string {
521 if !ui.agent.LinkToGitHub() || branchName == "" {
522 return ""
523 }
524
525 gitOrigin := ui.agent.GitOrigin()
526 if gitOrigin == "" || !ui.isGitHubRepo() {
527 return ""
528 }
529
530 // Extract owner and repo from GitHub URL
531 patterns := []string{
532 `^https://github\.com/([^/]+)/([^/\s.]+)(?:\.git)?`,
533 `^git@github\.com:([^/]+)/([^/\s.]+)(?:\.git)?`,
534 `^git://github\.com/([^/]+)/([^/\s.]+)(?:\.git)?`,
535 }
536
537 for _, pattern := range patterns {
538 re := regexp.MustCompile(pattern)
539 matches := re.FindStringSubmatch(gitOrigin)
540 if len(matches) == 3 {
541 owner := matches[1]
542 repo := matches[2]
543 return fmt.Sprintf("https://github.com/%s/%s/tree/%s", owner, repo, branchName)
544 }
545 }
546 return ""
547}
Josh Bleecher Snyder2153f8b2025-07-04 02:41:20 +0000548
549// pushTerminalTitle pushes the current terminal title onto the title stack
550// Only works on xterm-compatible terminals, but does no harm elsewhere
551func (ui *TermUI) pushTerminalTitle() {
552 fmt.Fprintf(ui.stderr, "\033[22;0t")
553 ui.titlePushed = true
554}
555
556// popTerminalTitle pops the terminal title from the title stack
557func (ui *TermUI) popTerminalTitle() {
558 if ui.titlePushed {
559 fmt.Fprintf(ui.stderr, "\033[23;0t")
560 ui.titlePushed = false
561 }
562}
563
564func (ui *TermUI) setTerminalTitle(title string) {
565 fmt.Fprintf(ui.stderr, "\033]0;%s\007", title)
566}
567
568// updateTitleWithSlug updates the terminal title with slug slug
569func (ui *TermUI) updateTitleWithSlug(slug string) {
570 ui.mu.Lock()
571 defer ui.mu.Unlock()
572 ui.currentSlug = slug
573 title := "sketch"
574 if slug != "" {
575 title = fmt.Sprintf("sketch: %s", slug)
576 }
577 ui.setTerminalTitle(title)
578}