blob: 322b23d3712dd60336883461a90ef199975980e8 [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
Sean McCullough485afc62025-04-28 14:28:39 -070049{{else if eq .msg.ToolName "multiplechoice" -}}
50 📝 {{.input.question}}
51{{ range .input.responseOptions -}}
52 - {{ .caption}}: {{.responseText}}
53{{end -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070054{{else -}}
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000055 🛠️ {{ .msg.ToolName}}: {{.msg.ToolInput -}}
Earl Lee2e463fb2025-04-17 11:22:22 -070056{{end -}}
57`
58 toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
59)
60
David Crawshaw93fec602025-05-05 08:40:06 -070061type TermUI struct {
Earl Lee2e463fb2025-04-17 11:22:22 -070062 stdin *os.File
63 stdout *os.File
64 stderr *os.File
65
66 agent loop.CodingAgent
67 httpURL string
68
69 trm *term.Terminal
70
71 // the chatMsgCh channel is for "conversation" messages, like responses to user input
72 // from the LLM, or output from executing slash-commands issued by the user.
73 chatMsgCh chan chatMessage
74
75 // the log channel is for secondary messages, like logging, errors, and debug information
76 // from local and remove subproceses.
77 termLogCh chan string
78
79 // protects following
80 mu sync.Mutex
81 oldState *term.State
82 // Tracks branches that were pushed during the session
83 pushedBranches map[string]struct{}
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +000084
85 // Pending message count, for graceful shutdown
86 messageWaitGroup sync.WaitGroup
Earl Lee2e463fb2025-04-17 11:22:22 -070087}
88
89type chatMessage struct {
90 idx int
91 sender string
92 content string
93 thinking bool
94}
95
David Crawshaw93fec602025-05-05 08:40:06 -070096func New(agent loop.CodingAgent, httpURL string) *TermUI {
97 return &TermUI{
Earl Lee2e463fb2025-04-17 11:22:22 -070098 agent: agent,
99 stdin: os.Stdin,
100 stdout: os.Stdout,
101 stderr: os.Stderr,
102 httpURL: httpURL,
103 chatMsgCh: make(chan chatMessage, 1),
104 termLogCh: make(chan string, 1),
105 pushedBranches: make(map[string]struct{}),
106 }
107}
108
David Crawshaw93fec602025-05-05 08:40:06 -0700109func (ui *TermUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700110 fmt.Println(`🌐 ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700111 fmt.Println(`💬 type 'help' for help`)
112 fmt.Println()
113
114 // Start up the main terminal UI:
115 if err := ui.initializeTerminalUI(ctx); err != nil {
116 return err
117 }
118 go ui.receiveMessagesLoop(ctx)
119 if err := ui.inputLoop(ctx); err != nil {
120 return err
121 }
122 return nil
123}
124
David Crawshaw93fec602025-05-05 08:40:06 -0700125func (ui *TermUI) LogToolUse(resp *loop.AgentMessage) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 inputData := map[string]any{}
127 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
128 ui.AppendSystemMessage("error: %v", err)
129 return
130 }
131 buf := bytes.Buffer{}
132 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
133 ui.AppendSystemMessage("error: %v", err)
134 return
135 }
136 ui.AppendSystemMessage("%s\n", buf.String())
137}
138
David Crawshaw93fec602025-05-05 08:40:06 -0700139func (ui *TermUI) receiveMessagesLoop(ctx context.Context) {
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700140 it := ui.agent.NewIterator(ctx, 0)
Earl Lee2e463fb2025-04-17 11:22:22 -0700141 bold := color.New(color.Bold).SprintFunc()
142 for {
143 select {
144 case <-ctx.Done():
145 return
146 default:
147 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700148 resp := it.Next()
149 if resp == nil {
150 return
151 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 // Typically a user message will start the thinking and a (top-level
153 // conversation) end of turn will stop it.
154 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
155
156 switch resp.Type {
157 case loop.AgentMessageType:
Josh Bleecher Snyder2978ab22025-04-30 10:29:32 -0700158 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "🕴️ ", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700159 case loop.ToolUseMessageType:
160 ui.LogToolUse(resp)
161 case loop.ErrorMessageType:
162 ui.AppendSystemMessage("❌ %s", resp.Content)
163 case loop.BudgetMessageType:
164 ui.AppendSystemMessage("💰 %s", resp.Content)
165 case loop.AutoMessageType:
166 ui.AppendSystemMessage("🧐 %s", resp.Content)
167 case loop.UserMessageType:
Josh Bleecher Snyderc2d26102025-04-30 06:19:43 -0700168 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "🦸", content: resp.Content})
Earl Lee2e463fb2025-04-17 11:22:22 -0700169 case loop.CommitMessageType:
170 // Display each commit in the terminal
171 for _, commit := range resp.Commits {
172 if commit.PushedBranch != "" {
Sean McCullough43664f62025-04-20 16:13:03 -0700173 ui.AppendSystemMessage("🔄 new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
Earl Lee2e463fb2025-04-17 11:22:22 -0700174
175 // Track the pushed branch in our map
176 ui.mu.Lock()
177 ui.pushedBranches[commit.PushedBranch] = struct{}{}
178 ui.mu.Unlock()
179 } else {
180 ui.AppendSystemMessage("🔄 new commit: [%s] %s", commit.Hash[:8], commit.Subject)
181 }
182 }
183 default:
184 ui.AppendSystemMessage("❌ Unexpected Message Type %s %v", resp.Type, resp)
185 }
186 }
187}
188
David Crawshaw93fec602025-05-05 08:40:06 -0700189func (ui *TermUI) inputLoop(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 for {
191 line, err := ui.trm.ReadLine()
192 if errors.Is(err, io.EOF) {
193 ui.AppendSystemMessage("\n")
194 line = "exit"
195 } else if err != nil {
196 return err
197 }
198
199 line = strings.TrimSpace(line)
200
201 switch line {
202 case "?", "help":
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700203 ui.AppendSystemMessage(`General use:
204Use chat to ask sketch to tackle a task or answer a question about this repo.
205
206Special commands:
207- help, ? : Show this help message
208- budget : Show original budget
209- usage, cost : Show current token usage and cost
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000210- browser, open, b : Open current conversation in browser
Earl Lee2e463fb2025-04-17 11:22:22 -0700211- stop, cancel, abort : Cancel the current operation
Josh Bleecher Snyder85068942025-04-30 10:51:27 -0700212- exit, quit, q : Exit sketch
213- ! <command> : Execute a shell command (e.g. !ls -la)`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 case "budget":
215 originalBudget := ui.agent.OriginalBudget()
216 ui.AppendSystemMessage("💰 Budget summary:")
217 if originalBudget.MaxResponses > 0 {
218 ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
219 }
220 if originalBudget.MaxWallTime > 0 {
221 ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
222 }
223 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000224 case "browser", "open", "b":
225 if ui.httpURL != "" {
226 ui.AppendSystemMessage("🌐 Opening %s in browser", ui.httpURL)
227 go ui.agent.OpenBrowser(ui.httpURL)
228 } else {
229 ui.AppendSystemMessage("❌ No web URL available for this session")
230 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 case "usage", "cost":
232 totalUsage := ui.agent.TotalUsage()
233 ui.AppendSystemMessage("💰 Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000234 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
235 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700236 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
237 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
238 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
239 case "bye", "exit", "q", "quit":
240 ui.trm.SetPrompt("")
241 // Display final usage stats
242 totalUsage := ui.agent.TotalUsage()
243 ui.AppendSystemMessage("💰 Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000244 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
245 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700246 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
247 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
248 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
249
250 // Display pushed branches
251 ui.mu.Lock()
252 if len(ui.pushedBranches) > 0 {
253 // Convert map keys to a slice for display
254 branches := make([]string, 0, len(ui.pushedBranches))
255 for branch := range ui.pushedBranches {
256 branches = append(branches, branch)
257 }
258
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000259 initialCommitRef := getGitRefName(ui.agent.InitialCommit())
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 if len(branches) == 1 {
261 ui.AppendSystemMessage("\n🔄 Branch pushed during session: %s", branches[0])
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000262 ui.AppendSystemMessage("🍒 To add those changes to your branch: git cherry-pick %s..%s", initialCommitRef, branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700263 } else {
264 ui.AppendSystemMessage("\n🔄 Branches pushed during session:")
265 for _, branch := range branches {
266 ui.AppendSystemMessage("- %s", branch)
267 }
268 ui.AppendSystemMessage("\n🍒 To add all those changes to your branch:")
269 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000270 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 }
272 }
273 }
274 ui.mu.Unlock()
275
276 ui.AppendSystemMessage("\n👋 Goodbye!")
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000277 // Wait for all pending messages to be processed before exiting
278 ui.messageWaitGroup.Wait()
Earl Lee2e463fb2025-04-17 11:22:22 -0700279 return nil
280 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000281 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700282 case "panic":
283 panic("user forced a panic")
284 default:
285 if line == "" {
286 continue
287 }
288 if strings.HasPrefix(line, "!") {
289 // Execute as shell command
290 line = line[1:] // remove the '!' prefix
291 sendToLLM := strings.HasPrefix(line, "!")
292 if sendToLLM {
293 line = line[1:] // remove the second '!'
294 }
295
296 // Create a cmd and run it
297 // TODO: ui.trm contains a mutex inside its write call.
298 // It is potentially safe to attach ui.trm directly to this
299 // cmd object's Stdout/Stderr and stream the output.
300 // That would make a big difference for, e.g. wget.
301 cmd := exec.Command("bash", "-c", line)
302 out, err := cmd.CombinedOutput()
303 ui.AppendSystemMessage("%s", out)
304 if err != nil {
305 ui.AppendSystemMessage("❌ Command error: %v", err)
306 }
307 if sendToLLM {
308 // Send the command and its output to the agent
309 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
310 if err != nil {
311 message += fmt.Sprintf("\n\nError: %v", err)
312 }
313 ui.agent.UserMessage(ctx, message)
314 }
315 continue
316 }
317
318 // Send it to the LLM
319 // chatMsg := chatMessage{sender: "you", content: line}
320 // ui.sendChatMessage(chatMsg)
321 ui.agent.UserMessage(ctx, line)
322 }
323 }
324}
325
David Crawshaw93fec602025-05-05 08:40:06 -0700326func (ui *TermUI) updatePrompt(thinking bool) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700327 var t string
328
329 if thinking {
330 // Emoji don't seem to work here? Messes up my terminal.
331 t = "*"
332 }
Josh Bleecher Snyder23b6a2d2025-04-30 04:07:52 +0000333 p := fmt.Sprintf("%s ($%0.2f/%0.2f)%s> ",
334 ui.httpURL, ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
Earl Lee2e463fb2025-04-17 11:22:22 -0700335 ui.trm.SetPrompt(p)
336}
337
David Crawshaw93fec602025-05-05 08:40:06 -0700338func (ui *TermUI) initializeTerminalUI(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 ui.mu.Lock()
340 defer ui.mu.Unlock()
341
342 if !term.IsTerminal(int(ui.stdin.Fd())) {
Philip Zeyligerc5b8ed42025-05-05 20:28:34 +0000343 return fmt.Errorf("this command requires terminal I/O when termui=true")
Earl Lee2e463fb2025-04-17 11:22:22 -0700344 }
345
346 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
347 if err != nil {
348 return err
349 }
350 ui.oldState = oldState
351 ui.trm = term.NewTerminal(ui.stdin, "")
352 width, height, err := term.GetSize(int(ui.stdin.Fd()))
353 if err != nil {
354 return fmt.Errorf("Error getting terminal size: %v\n", err)
355 }
356 ui.trm.SetSize(width, height)
357 // Handle terminal resizes...
358 sig := make(chan os.Signal, 1)
359 signal.Notify(sig, syscall.SIGWINCH)
360 go func() {
361 for {
362 <-sig
363 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
364 if err != nil {
365 continue
366 }
367 if newWidth != width || newHeight != height {
368 width, height = newWidth, newHeight
369 ui.trm.SetSize(width, height)
370 }
371 }
372 }()
373
374 ui.updatePrompt(false)
375
376 // This is the only place where we should call fe.trm.Write:
377 go func() {
Sean McCullougha4b19f82025-05-05 10:22:59 -0700378 var lastMsg *chatMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700379 for {
380 select {
381 case <-ctx.Done():
382 return
383 case msg := <-ui.chatMsgCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000384 func() {
385 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700386 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
387 ui.updatePrompt(msg.thinking)
388 lastMsg = &msg
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000389 // Sometimes claude doesn't say anything when it runs tools.
390 // No need to output anything in that case.
391 if strings.TrimSpace(msg.content) == "" {
392 return
393 }
394 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000395 ui.trm.Write([]byte(s))
396 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700397 case logLine := <-ui.termLogCh:
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000398 func() {
399 defer ui.messageWaitGroup.Done()
Sean McCullougha4b19f82025-05-05 10:22:59 -0700400 if lastMsg != nil {
401 ui.updatePrompt(lastMsg.thinking)
402 } else {
403 ui.updatePrompt(false)
404 }
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000405 b := []byte(logLine + "\n")
406 ui.trm.Write(b)
407 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700408 }
409 }
410 }()
411
412 return nil
413}
414
David Crawshaw93fec602025-05-05 08:40:06 -0700415func (ui *TermUI) RestoreOldState() error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700416 ui.mu.Lock()
417 defer ui.mu.Unlock()
418 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
419}
420
421// AppendChatMessage is for showing responses the user's request, conversational dialog etc
David Crawshaw93fec602025-05-05 08:40:06 -0700422func (ui *TermUI) AppendChatMessage(msg chatMessage) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000423 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700424 ui.chatMsgCh <- msg
425}
426
427// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
428// but still need to be shown to the user.
David Crawshaw93fec602025-05-05 08:40:06 -0700429func (ui *TermUI) AppendSystemMessage(fmtString string, args ...any) {
Josh Bleecher Snyderb1e81572025-05-01 00:53:27 +0000430 ui.messageWaitGroup.Add(1)
Earl Lee2e463fb2025-04-17 11:22:22 -0700431 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
432}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000433
434// getGitRefName returns a readable git ref for sha, falling back to the original sha on error.
435func getGitRefName(sha string) string {
Josh Bleecher Snyder2bdc9532025-05-01 23:19:21 +0000436 // Best-effort git fetch --prune to ensure we have the latest refs
Josh Bleecher Snyder78f6c402025-05-01 21:20:40 -0700437 exec.Command("git", "fetch", "--prune", "sketch-host").Run()
Josh Bleecher Snyder2bdc9532025-05-01 23:19:21 +0000438
Josh Bleecher Snydera687c1b2025-04-30 20:59:29 +0000439 // local branch or tag name
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000440 cmd := exec.Command("git", "rev-parse", "--abbrev-ref", sha)
441 branchName, err := cmd.Output()
442 if err == nil {
443 branchStr := strings.TrimSpace(string(branchName))
444 // If we got a branch name that's not HEAD, use it
445 if branchStr != "" && branchStr != "HEAD" {
446 return branchStr
447 }
448 }
449
Josh Bleecher Snydera687c1b2025-04-30 20:59:29 +0000450 // check sketch-host (outer) remote branches
451 cmd = exec.Command("git", "branch", "-r", "--contains", sha)
452 remoteBranches, err := cmd.Output()
453 if err == nil {
454 for line := range strings.Lines(string(remoteBranches)) {
455 line = strings.TrimSpace(line)
456 if line == "" {
457 continue
458 }
459 suf, ok := strings.CutPrefix(line, "sketch-host/")
460 if ok {
461 return suf
462 }
463 }
464 }
465
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000466 // short SHA
467 cmd = exec.Command("git", "rev-parse", "--short", sha)
468 shortSha, err := cmd.Output()
469 if err == nil {
470 shortStr := strings.TrimSpace(string(shortSha))
471 if shortStr != "" {
472 return shortStr
473 }
474 }
475
476 return sha
477}