blob: 7e4def92818fa064c3a9c6ee87dcdc628540e264 [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?
Josh Bleecher Snyderc3c20232025-05-07 05:46:04 -070031 toolUseTemplTxt = `{{if .msg.ToolError}}ć€°ļø {{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070032{{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}}
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +000044{{else if eq .msg.ToolName "precommit" -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000045🌱 git branch: sketch/{{.input.branch_name}}
Earl Lee2e463fb2025-04-17 11:22:22 -070046{{else if eq .msg.ToolName "str_replace_editor" -}}
47 āœļø {{.input.file_path -}}
48{{else if eq .msg.ToolName "codereview" -}}
49 šŸ› Running automated code review, may be slow
Sean McCullough485afc62025-04-28 14:28:39 -070050{{else if eq .msg.ToolName "multiplechoice" -}}
51 šŸ“ {{.input.question}}
52{{ range .input.responseOptions -}}
53 - {{ .caption}}: {{.responseText}}
54{{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070055{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000056 šŸ› ļø {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070057{{end -}}
58`
59 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
60)
61
David Crawshaw93fec602025-05-05 08:40:06 -070062type TermUI struct {
Earl Lee2e463fb2025-04-17 11:22:22 -070063 stdin *os.File
64 stdout *os.File
65 stderr *os.File
66
67 agent loop.CodingAgent
68 httpURL string
69
70 trm *term.Terminal
71
72 // the chatMsgCh channel is for "conversation" messages, like responses to user input
73 // from the LLM, or output from executing slash-commands issued by the user.
74 chatMsgCh chan chatMessage
75
76 // the log channel is for secondary messages, like logging, errors, and debug information
77 // from local and remove subproceses.
78 termLogCh chan string
79
80 // protects following
81 mu sync.Mutex
82 oldState *term.State
83 // Tracks branches that were pushed during the session
84 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +000085
86 // Pending message count, for graceful shutdown
87 messageWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -070088}
89
90type chatMessage struct {
91 idx int
92 sender string
93 content string
94 thinking bool
95}
96
David Crawshaw93fec602025-05-05 08:40:06 -070097func New(agent loop.CodingAgent, httpURL string) *TermUI {
98 return &TermUI{
Earl Lee2e463fb2025-04-17 11:22:22 -070099 agent: agent,
100 stdin: os.Stdin,
101 stdout: os.Stdout,
102 stderr: os.Stderr,
103 httpURL: httpURL,
104 chatMsgCh: make(chan chatMessage, 1),
105 termLogCh: make(chan string, 1),
106 pushedBranches: make(map[string]struct{}),
107 }
108}
109
David Crawshaw93fec602025-05-05 08:40:06 -0700110func (ui *TermUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700111 fmt.Println(`🌐 ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 fmt.Println(`šŸ’¬ type 'help' for help`)
113 fmt.Println()
114
115 // Start up the main terminal UI:
116 if err := ui.initializeTerminalUI(ctx); err != nil {
117 return err
118 }
119 go ui.receiveMessagesLoop(ctx)
120 if err := ui.inputLoop(ctx); err != nil {
121 return err
122 }
123 return nil
124}
125
David Crawshaw93fec602025-05-05 08:40:06 -0700126func (ui *TermUI) LogToolUse(resp *loop.AgentMessage) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700127 inputData := map[string]any{}
128 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
129 ui.AppendSystemMessage("error: %v", err)
130 return
131 }
132 buf := bytes.Buffer{}
133 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
134 ui.AppendSystemMessage("error: %v", err)
135 return
136 }
137 ui.AppendSystemMessage("%s\n", buf.String())
138}
139
David Crawshaw93fec602025-05-05 08:40:06 -0700140func (ui *TermUI) receiveMessagesLoop(ctx context.Context) {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700141 it := ui.agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700142 bold := color.New(color.Bold).SprintFunc()
143 for {
144 select {
145 case <-ctx.Done():
146 return
147 default:
148 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700149 resp := it.Next()
150 if resp == nil {
151 return
152 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000153 if resp.HideOutput {
154 continue
155 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700156 // Typically a user message will start the thinking and a (top-level
157 // conversation) end of turn will stop it.
158 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
159
160 switch resp.Type {
161 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700162 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "šŸ•“ļø ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 case loop.ToolUseMessageType:
164 ui.LogToolUse(resp)
165 case loop.ErrorMessageType:
166 ui.AppendSystemMessage("āŒ %s", resp.Content)
167 case loop.BudgetMessageType:
168 ui.AppendSystemMessage("šŸ’° %s", resp.Content)
169 case loop.AutoMessageType:
170 ui.AppendSystemMessage("🧐 %s", resp.Content)
171 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700172 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "🦸", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700173 case loop.CommitMessageType:
174 // Display each commit in the terminal
175 for _, commit := range resp.Commits {
176 if commit.PushedBranch != "" {
Sean McCullough43664f62025-04-20 16:13:03 -0700177 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
Earl Lee2e463fb2025-04-17 11:22:22 -0700178
179 // Track the pushed branch in our map
180 ui.mu.Lock()
181 ui.pushedBranches[commit.PushedBranch] = struct{}{}
182 ui.mu.Unlock()
183 } else {
184 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
185 }
186 }
187 default:
188 ui.AppendSystemMessage("āŒ Unexpected Message Type %s %v", resp.Type, resp)
189 }
190 }
191}
192
David Crawshaw93fec602025-05-05 08:40:06 -0700193func (ui *TermUI) inputLoop(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700194 for {
195 line, err := ui.trm.ReadLine()
196 if errors.Is(err, io.EOF) {
197 ui.AppendSystemMessage("\n")
198 line = "exit"
199 } else if err != nil {
200 return err
201 }
202
203 line = strings.TrimSpace(line)
204
205 switch line {
206 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700207 ui.AppendSystemMessage(`General use:
208Use chat to ask sketch to tackle a task or answer a question about this repo.
209
210Special commands:
211- help, ? : Show this help message
212- budget : Show original budget
213- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000214- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700215- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700216- exit, quit, q : Exit sketch
217- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 case "budget":
219 originalBudget := ui.agent.OriginalBudget()
220 ui.AppendSystemMessage("šŸ’° Budget summary:")
221 if originalBudget.MaxResponses > 0 {
222 ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
223 }
224 if originalBudget.MaxWallTime > 0 {
225 ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
226 }
227 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000228 case "browser", "open", "b":
229 if ui.httpURL != "" {
230 ui.AppendSystemMessage("🌐 Opening %s in browser", ui.httpURL)
231 go ui.agent.OpenBrowser(ui.httpURL)
232 } else {
233 ui.AppendSystemMessage("āŒ No web URL available for this session")
234 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700235 case "usage", "cost":
236 totalUsage := ui.agent.TotalUsage()
237 ui.AppendSystemMessage("šŸ’° Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000238 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
239 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
241 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
242 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
243 case "bye", "exit", "q", "quit":
244 ui.trm.SetPrompt("")
245 // Display final usage stats
246 totalUsage := ui.agent.TotalUsage()
247 ui.AppendSystemMessage("šŸ’° Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000248 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
249 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700250 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
251 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
252 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
253
254 // Display pushed branches
255 ui.mu.Lock()
256 if len(ui.pushedBranches) > 0 {
257 // Convert map keys to a slice for display
258 branches := make([]string, 0, len(ui.pushedBranches))
259 for branch := range ui.pushedBranches {
260 branches = append(branches, branch)
261 }
262
Josh Bleecher Snyder8fdf7532025-05-06 00:56:12 +0000263 initialCommitRef := getShortSHA(ui.agent.InitialCommit())
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 if len(branches) == 1 {
265 ui.AppendSystemMessage("\nšŸ”„ Branch pushed during session: %s", branches[0])
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000266 ui.AppendSystemMessage("šŸ’ To add those changes to your branch: git cherry-pick %s..%s", initialCommitRef, branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 } else {
268 ui.AppendSystemMessage("\nšŸ”„ Branches pushed during session:")
269 for _, branch := range branches {
270 ui.AppendSystemMessage("- %s", branch)
271 }
272 ui.AppendSystemMessage("\nšŸ’ To add all those changes to your branch:")
273 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000274 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 }
276 }
277 }
278 ui.mu.Unlock()
279
280 ui.AppendSystemMessage("\nšŸ‘‹ Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000281 // Wait for all pending messages to be processed before exiting
282 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 return nil
284 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000285 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700286 case "panic":
287 panic("user forced a panic")
288 default:
289 if line == "" {
290 continue
291 }
292 if strings.HasPrefix(line, "!") {
293 // Execute as shell command
294 line = line[1:] // remove the '!' prefix
295 sendToLLM := strings.HasPrefix(line, "!")
296 if sendToLLM {
297 line = line[1:] // remove the second '!'
298 }
299
300 // Create a cmd and run it
301 // TODO: ui.trm contains a mutex inside its write call.
302 // It is potentially safe to attach ui.trm directly to this
303 // cmd object's Stdout/Stderr and stream the output.
304 // That would make a big difference for, e.g. wget.
305 cmd := exec.Command("bash", "-c", line)
306 out, err := cmd.CombinedOutput()
307 ui.AppendSystemMessage("%s", out)
308 if err != nil {
309 ui.AppendSystemMessage("āŒ Command error: %v", err)
310 }
311 if sendToLLM {
312 // Send the command and its output to the agent
313 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
314 if err != nil {
315 message += fmt.Sprintf("\n\nError: %v", err)
316 }
317 ui.agent.UserMessage(ctx, message)
318 }
319 continue
320 }
321
322 // Send it to the LLM
323 // chatMsg := chatMessage{sender: "you", content: line}
324 // ui.sendChatMessage(chatMsg)
325 ui.agent.UserMessage(ctx, line)
326 }
327 }
328}
329
David Crawshaw93fec602025-05-05 08:40:06 -0700330func (ui *TermUI) updatePrompt(thinking bool) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700331 var t string
332
333 if thinking {
334 // Emoji don't seem to work here? Messes up my terminal.
335 t = "*"
336 }
Josh Bleecher Snyder23b6a2d2025-04-30 04:07:52 +0000337 p := fmt.Sprintf("%s ($%0.2f/%0.2f)%s> ",
338 ui.httpURL, ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 ui.trm.SetPrompt(p)
340}
341
David Crawshaw93fec602025-05-05 08:40:06 -0700342func (ui *TermUI) initializeTerminalUI(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700343 ui.mu.Lock()
344 defer ui.mu.Unlock()
345
346 if !term.IsTerminal(int(ui.stdin.Fd())) {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000347 return fmt.Errorf("this command requires terminal I/O when termui=true")
Earl Lee2e463fb2025-04-17 11:22:22 -0700348 }
349
350 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
351 if err != nil {
352 return err
353 }
354 ui.oldState = oldState
355 ui.trm = term.NewTerminal(ui.stdin, "")
356 width, height, err := term.GetSize(int(ui.stdin.Fd()))
357 if err != nil {
358 return fmt.Errorf("Error getting terminal size: %v\n", err)
359 }
360 ui.trm.SetSize(width, height)
361 // Handle terminal resizes...
362 sig := make(chan os.Signal, 1)
363 signal.Notify(sig, syscall.SIGWINCH)
364 go func() {
365 for {
366 <-sig
367 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
368 if err != nil {
369 continue
370 }
371 if newWidth != width || newHeight != height {
372 width, height = newWidth, newHeight
373 ui.trm.SetSize(width, height)
374 }
375 }
376 }()
377
378 ui.updatePrompt(false)
379
380 // This is the only place where we should call fe.trm.Write:
381 go func() {
Sean McCullougha4b19f82025-05-05 10:22:59 -0700382 var lastMsg *chatMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700383 for {
384 select {
385 case <-ctx.Done():
386 return
387 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000388 func() {
389 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700390 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
391 ui.updatePrompt(msg.thinking)
392 lastMsg = &msg
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000393 // Sometimes claude doesn't say anything when it runs tools.
394 // No need to output anything in that case.
395 if strings.TrimSpace(msg.content) == "" {
396 return
397 }
398 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000399 ui.trm.Write([]byte(s))
400 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700401 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000402 func() {
403 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700404 if lastMsg != nil {
405 ui.updatePrompt(lastMsg.thinking)
406 } else {
407 ui.updatePrompt(false)
408 }
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000409 b := []byte(logLine + "\n")
410 ui.trm.Write(b)
411 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700412 }
413 }
414 }()
415
416 return nil
417}
418
David Crawshaw93fec602025-05-05 08:40:06 -0700419func (ui *TermUI) RestoreOldState() error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700420 ui.mu.Lock()
421 defer ui.mu.Unlock()
422 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
423}
424
425// AppendChatMessage is for showing responses the user's request, conversational dialog etc
David Crawshaw93fec602025-05-05 08:40:06 -0700426func (ui *TermUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000427 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700428 ui.chatMsgCh <- msg
429}
430
431// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
432// but still need to be shown to the user.
David Crawshaw93fec602025-05-05 08:40:06 -0700433func (ui *TermUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000434 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700435 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
436}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000437
Josh Bleecher Snyder8fdf7532025-05-06 00:56:12 +0000438// getShortSHA returns the short SHA for the given git reference, falling back to the original SHA on error.
439func getShortSHA(sha string) string {
440 cmd := exec.Command("git", "rev-parse", "--short", sha)
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000441 shortSha, err := cmd.Output()
442 if err == nil {
443 shortStr := strings.TrimSpace(string(shortSha))
444 if shortStr != "" {
445 return shortStr
446 }
447 }
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000448 return sha
449}