blob: 932f07f2c4464d02876c510e329ceb7554a54cc8 [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"
13 "strings"
14 "sync"
15 "syscall"
16 "text/template"
17 "time"
18
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +000019 "github.com/dustin/go-humanize"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "github.com/fatih/color"
21 "golang.org/x/term"
22 "sketch.dev/loop"
23)
24
25var (
26 // toolUseTemplTxt defines how tool invocations appear in the terminal UI.
27 // Keep this template in sync with the tools defined in claudetool package
28 // and registered in loop/agent.go.
29 // Add formatting for new tools as they are created.
30 // TODO: should this be part of tool definition to make it harder to forget to set up?
31 toolUseTemplTxt = `{{if .msg.ToolError}}šŸ™ˆ {{end -}}
32{{if eq .msg.ToolName "think" -}}
33 🧠 {{.input.thoughts -}}
34{{else if eq .msg.ToolName "keyword_search" -}}
Josh Bleecher Snyder453a62f2025-05-01 10:14:33 -070035 šŸ” {{ .input.query}}: {{.input.search_terms -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070036{{else if eq .msg.ToolName "bash" -}}
Philip Zeyligerb60f0f22025-04-23 18:19:32 +000037 šŸ–„ļø{{if .input.background}}šŸ”„{{end}} {{ .input.command -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070038{{else if eq .msg.ToolName "patch" -}}
39 āŒØļø {{.input.path -}}
40{{else if eq .msg.ToolName "done" -}}
41{{/* nothing to show here, the agent will write more in its next message */}}
42{{else if eq .msg.ToolName "title" -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000043šŸ·ļø {{.input.title}}
44🌱 git branch: sketch/{{.input.branch_name}}
Earl Lee2e463fb2025-04-17 11:22:22 -070045{{else if eq .msg.ToolName "str_replace_editor" -}}
46 āœļø {{.input.file_path -}}
47{{else if eq .msg.ToolName "codereview" -}}
48 šŸ› Running automated code review, may be slow
49{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000050 šŸ› ļø {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070051{{end -}}
52`
53 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
54)
55
56type termUI struct {
57 stdin *os.File
58 stdout *os.File
59 stderr *os.File
60
61 agent loop.CodingAgent
62 httpURL string
63
64 trm *term.Terminal
65
66 // the chatMsgCh channel is for "conversation" messages, like responses to user input
67 // from the LLM, or output from executing slash-commands issued by the user.
68 chatMsgCh chan chatMessage
69
70 // the log channel is for secondary messages, like logging, errors, and debug information
71 // from local and remove subproceses.
72 termLogCh chan string
73
74 // protects following
75 mu sync.Mutex
76 oldState *term.State
77 // Tracks branches that were pushed during the session
78 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +000079
80 // Pending message count, for graceful shutdown
81 messageWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -070082}
83
84type chatMessage struct {
85 idx int
86 sender string
87 content string
88 thinking bool
89}
90
91func New(agent loop.CodingAgent, httpURL string) *termUI {
92 return &termUI{
93 agent: agent,
94 stdin: os.Stdin,
95 stdout: os.Stdout,
96 stderr: os.Stderr,
97 httpURL: httpURL,
98 chatMsgCh: make(chan chatMessage, 1),
99 termLogCh: make(chan string, 1),
100 pushedBranches: make(map[string]struct{}),
101 }
102}
103
104func (ui *termUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700105 fmt.Println(`🌐 ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700106 fmt.Println(`šŸ’¬ type 'help' for help`)
107 fmt.Println()
108
109 // Start up the main terminal UI:
110 if err := ui.initializeTerminalUI(ctx); err != nil {
111 return err
112 }
113 go ui.receiveMessagesLoop(ctx)
114 if err := ui.inputLoop(ctx); err != nil {
115 return err
116 }
117 return nil
118}
119
120func (ui *termUI) LogToolUse(resp loop.AgentMessage) {
121 inputData := map[string]any{}
122 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
123 ui.AppendSystemMessage("error: %v", err)
124 return
125 }
126 buf := bytes.Buffer{}
127 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
128 ui.AppendSystemMessage("error: %v", err)
129 return
130 }
131 ui.AppendSystemMessage("%s\n", buf.String())
132}
133
134func (ui *termUI) receiveMessagesLoop(ctx context.Context) {
135 bold := color.New(color.Bold).SprintFunc()
136 for {
137 select {
138 case <-ctx.Done():
139 return
140 default:
141 }
142 resp := ui.agent.WaitForMessage(ctx)
143 // Typically a user message will start the thinking and a (top-level
144 // conversation) end of turn will stop it.
145 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
146
147 switch resp.Type {
148 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700149 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "šŸ•“ļø ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700150 case loop.ToolUseMessageType:
151 ui.LogToolUse(resp)
152 case loop.ErrorMessageType:
153 ui.AppendSystemMessage("āŒ %s", resp.Content)
154 case loop.BudgetMessageType:
155 ui.AppendSystemMessage("šŸ’° %s", resp.Content)
156 case loop.AutoMessageType:
157 ui.AppendSystemMessage("🧐 %s", resp.Content)
158 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700159 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "🦸", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 case loop.CommitMessageType:
161 // Display each commit in the terminal
162 for _, commit := range resp.Commits {
163 if commit.PushedBranch != "" {
Sean McCullough43664f62025-04-20 16:13:03 -0700164 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
Earl Lee2e463fb2025-04-17 11:22:22 -0700165
166 // Track the pushed branch in our map
167 ui.mu.Lock()
168 ui.pushedBranches[commit.PushedBranch] = struct{}{}
169 ui.mu.Unlock()
170 } else {
171 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
172 }
173 }
174 default:
175 ui.AppendSystemMessage("āŒ Unexpected Message Type %s %v", resp.Type, resp)
176 }
177 }
178}
179
180func (ui *termUI) inputLoop(ctx context.Context) error {
181 for {
182 line, err := ui.trm.ReadLine()
183 if errors.Is(err, io.EOF) {
184 ui.AppendSystemMessage("\n")
185 line = "exit"
186 } else if err != nil {
187 return err
188 }
189
190 line = strings.TrimSpace(line)
191
192 switch line {
193 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700194 ui.AppendSystemMessage(`General use:
195Use chat to ask sketch to tackle a task or answer a question about this repo.
196
197Special commands:
198- help, ? : Show this help message
199- budget : Show original budget
200- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000201- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700202- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700203- exit, quit, q : Exit sketch
204- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 case "budget":
206 originalBudget := ui.agent.OriginalBudget()
207 ui.AppendSystemMessage("šŸ’° Budget summary:")
208 if originalBudget.MaxResponses > 0 {
209 ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
210 }
211 if originalBudget.MaxWallTime > 0 {
212 ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
213 }
214 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000215 case "browser", "open", "b":
216 if ui.httpURL != "" {
217 ui.AppendSystemMessage("🌐 Opening %s in browser", ui.httpURL)
218 go ui.agent.OpenBrowser(ui.httpURL)
219 } else {
220 ui.AppendSystemMessage("āŒ No web URL available for this session")
221 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700222 case "usage", "cost":
223 totalUsage := ui.agent.TotalUsage()
224 ui.AppendSystemMessage("šŸ’° Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000225 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
226 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700227 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
228 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
229 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
230 case "bye", "exit", "q", "quit":
231 ui.trm.SetPrompt("")
232 // Display final usage stats
233 totalUsage := ui.agent.TotalUsage()
234 ui.AppendSystemMessage("šŸ’° Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000235 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
236 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
238 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
239 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
240
241 // Display pushed branches
242 ui.mu.Lock()
243 if len(ui.pushedBranches) > 0 {
244 // Convert map keys to a slice for display
245 branches := make([]string, 0, len(ui.pushedBranches))
246 for branch := range ui.pushedBranches {
247 branches = append(branches, branch)
248 }
249
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000250 initialCommitRef := getGitRefName(ui.agent.InitialCommit())
Earl Lee2e463fb2025-04-17 11:22:22 -0700251 if len(branches) == 1 {
252 ui.AppendSystemMessage("\nšŸ”„ Branch pushed during session: %s", branches[0])
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000253 ui.AppendSystemMessage("šŸ’ To add those changes to your branch: git cherry-pick %s..%s", initialCommitRef, branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700254 } else {
255 ui.AppendSystemMessage("\nšŸ”„ Branches pushed during session:")
256 for _, branch := range branches {
257 ui.AppendSystemMessage("- %s", branch)
258 }
259 ui.AppendSystemMessage("\nšŸ’ To add all those changes to your branch:")
260 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000261 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700262 }
263 }
264 }
265 ui.mu.Unlock()
266
267 ui.AppendSystemMessage("\nšŸ‘‹ Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000268 // Wait for all pending messages to be processed before exiting
269 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 return nil
271 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000272 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 case "panic":
274 panic("user forced a panic")
275 default:
276 if line == "" {
277 continue
278 }
279 if strings.HasPrefix(line, "!") {
280 // Execute as shell command
281 line = line[1:] // remove the '!' prefix
282 sendToLLM := strings.HasPrefix(line, "!")
283 if sendToLLM {
284 line = line[1:] // remove the second '!'
285 }
286
287 // Create a cmd and run it
288 // TODO: ui.trm contains a mutex inside its write call.
289 // It is potentially safe to attach ui.trm directly to this
290 // cmd object's Stdout/Stderr and stream the output.
291 // That would make a big difference for, e.g. wget.
292 cmd := exec.Command("bash", "-c", line)
293 out, err := cmd.CombinedOutput()
294 ui.AppendSystemMessage("%s", out)
295 if err != nil {
296 ui.AppendSystemMessage("āŒ Command error: %v", err)
297 }
298 if sendToLLM {
299 // Send the command and its output to the agent
300 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
301 if err != nil {
302 message += fmt.Sprintf("\n\nError: %v", err)
303 }
304 ui.agent.UserMessage(ctx, message)
305 }
306 continue
307 }
308
309 // Send it to the LLM
310 // chatMsg := chatMessage{sender: "you", content: line}
311 // ui.sendChatMessage(chatMsg)
312 ui.agent.UserMessage(ctx, line)
313 }
314 }
315}
316
317func (ui *termUI) updatePrompt(thinking bool) {
318 var t string
319
320 if thinking {
321 // Emoji don't seem to work here? Messes up my terminal.
322 t = "*"
323 }
Josh Bleecher Snyder23b6a2d2025-04-30 04:07:52 +0000324 p := fmt.Sprintf("%s ($%0.2f/%0.2f)%s> ",
325 ui.httpURL, ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 ui.trm.SetPrompt(p)
327}
328
329func (ui *termUI) initializeTerminalUI(ctx context.Context) error {
330 ui.mu.Lock()
331 defer ui.mu.Unlock()
332
333 if !term.IsTerminal(int(ui.stdin.Fd())) {
334 return fmt.Errorf("this command requires terminal I/O")
335 }
336
337 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
338 if err != nil {
339 return err
340 }
341 ui.oldState = oldState
342 ui.trm = term.NewTerminal(ui.stdin, "")
343 width, height, err := term.GetSize(int(ui.stdin.Fd()))
344 if err != nil {
345 return fmt.Errorf("Error getting terminal size: %v\n", err)
346 }
347 ui.trm.SetSize(width, height)
348 // Handle terminal resizes...
349 sig := make(chan os.Signal, 1)
350 signal.Notify(sig, syscall.SIGWINCH)
351 go func() {
352 for {
353 <-sig
354 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
355 if err != nil {
356 continue
357 }
358 if newWidth != width || newHeight != height {
359 width, height = newWidth, newHeight
360 ui.trm.SetSize(width, height)
361 }
362 }
363 }()
364
365 ui.updatePrompt(false)
366
367 // This is the only place where we should call fe.trm.Write:
368 go func() {
369 for {
370 select {
371 case <-ctx.Done():
372 return
373 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000374 func() {
375 defer ui.messageWaitGroup.Done()
376 // Sometimes claude doesn't say anything when it runs tools.
377 // No need to output anything in that case.
378 if strings.TrimSpace(msg.content) == "" {
379 return
380 }
381 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
382 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
383 ui.updatePrompt(msg.thinking)
384 ui.trm.Write([]byte(s))
385 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700386 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000387 func() {
388 defer ui.messageWaitGroup.Done()
389 b := []byte(logLine + "\n")
390 ui.trm.Write(b)
391 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700392 }
393 }
394 }()
395
396 return nil
397}
398
399func (ui *termUI) RestoreOldState() error {
400 ui.mu.Lock()
401 defer ui.mu.Unlock()
402 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
403}
404
405// AppendChatMessage is for showing responses the user's request, conversational dialog etc
406func (ui *termUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000407 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700408 ui.chatMsgCh <- msg
409}
410
411// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
412// but still need to be shown to the user.
413func (ui *termUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000414 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700415 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
416}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000417
418// getGitRefName returns a readable git ref for sha, falling back to the original sha on error.
419func getGitRefName(sha string) string {
Josh Bleecher Snydera687c1b2025-04-30 20:59:29 +0000420 // local branch or tag name
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000421 cmd := exec.Command("git", "rev-parse", "--abbrev-ref", sha)
422 branchName, err := cmd.Output()
423 if err == nil {
424 branchStr := strings.TrimSpace(string(branchName))
425 // If we got a branch name that's not HEAD, use it
426 if branchStr != "" && branchStr != "HEAD" {
427 return branchStr
428 }
429 }
430
Josh Bleecher Snydera687c1b2025-04-30 20:59:29 +0000431 // check sketch-host (outer) remote branches
432 cmd = exec.Command("git", "branch", "-r", "--contains", sha)
433 remoteBranches, err := cmd.Output()
434 if err == nil {
435 for line := range strings.Lines(string(remoteBranches)) {
436 line = strings.TrimSpace(line)
437 if line == "" {
438 continue
439 }
440 suf, ok := strings.CutPrefix(line, "sketch-host/")
441 if ok {
442 return suf
443 }
444 }
445 }
446
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000447 // short SHA
448 cmd = exec.Command("git", "rev-parse", "--short", sha)
449 shortSha, err := cmd.Output()
450 if err == nil {
451 shortStr := strings.TrimSpace(string(shortSha))
452 if shortStr != "" {
453 return shortStr
454 }
455 }
456
457 return sha
458}