blob: 6ff3e28711e35d49408a03b52a8a19e716c482c2 [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" -}}
35 šŸ” {{ .input.query}}: {{.input.keywords -}}
36{{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{}
79}
80
81type chatMessage struct {
82 idx int
83 sender string
84 content string
85 thinking bool
86}
87
88func New(agent loop.CodingAgent, httpURL string) *termUI {
89 return &termUI{
90 agent: agent,
91 stdin: os.Stdin,
92 stdout: os.Stdout,
93 stderr: os.Stderr,
94 httpURL: httpURL,
95 chatMsgCh: make(chan chatMessage, 1),
96 termLogCh: make(chan string, 1),
97 pushedBranches: make(map[string]struct{}),
98 }
99}
100
101func (ui *termUI) Run(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 fmt.Println(`🌐 ` + ui.httpURL + `/`)
Earl Lee2e463fb2025-04-17 11:22:22 -0700103 fmt.Println(`šŸ’¬ type 'help' for help`)
104 fmt.Println()
105
106 // Start up the main terminal UI:
107 if err := ui.initializeTerminalUI(ctx); err != nil {
108 return err
109 }
110 go ui.receiveMessagesLoop(ctx)
111 if err := ui.inputLoop(ctx); err != nil {
112 return err
113 }
114 return nil
115}
116
117func (ui *termUI) LogToolUse(resp loop.AgentMessage) {
118 inputData := map[string]any{}
119 if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
120 ui.AppendSystemMessage("error: %v", err)
121 return
122 }
123 buf := bytes.Buffer{}
124 if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
125 ui.AppendSystemMessage("error: %v", err)
126 return
127 }
128 ui.AppendSystemMessage("%s\n", buf.String())
129}
130
131func (ui *termUI) receiveMessagesLoop(ctx context.Context) {
132 bold := color.New(color.Bold).SprintFunc()
133 for {
134 select {
135 case <-ctx.Done():
136 return
137 default:
138 }
139 resp := ui.agent.WaitForMessage(ctx)
140 // Typically a user message will start the thinking and a (top-level
141 // conversation) end of turn will stop it.
142 thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
143
144 switch resp.Type {
145 case loop.AgentMessageType:
146 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "šŸ•“ļø", content: resp.Content})
147 case loop.ToolUseMessageType:
148 ui.LogToolUse(resp)
149 case loop.ErrorMessageType:
150 ui.AppendSystemMessage("āŒ %s", resp.Content)
151 case loop.BudgetMessageType:
152 ui.AppendSystemMessage("šŸ’° %s", resp.Content)
153 case loop.AutoMessageType:
154 ui.AppendSystemMessage("🧐 %s", resp.Content)
155 case loop.UserMessageType:
156 ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "šŸ‘¤ļø", content: resp.Content})
157 case loop.CommitMessageType:
158 // Display each commit in the terminal
159 for _, commit := range resp.Commits {
160 if commit.PushedBranch != "" {
Sean McCullough43664f62025-04-20 16:13:03 -0700161 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s\npushed to: %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
Earl Lee2e463fb2025-04-17 11:22:22 -0700162
163 // Track the pushed branch in our map
164 ui.mu.Lock()
165 ui.pushedBranches[commit.PushedBranch] = struct{}{}
166 ui.mu.Unlock()
167 } else {
168 ui.AppendSystemMessage("šŸ”„ new commit: [%s] %s", commit.Hash[:8], commit.Subject)
169 }
170 }
171 default:
172 ui.AppendSystemMessage("āŒ Unexpected Message Type %s %v", resp.Type, resp)
173 }
174 }
175}
176
177func (ui *termUI) inputLoop(ctx context.Context) error {
178 for {
179 line, err := ui.trm.ReadLine()
180 if errors.Is(err, io.EOF) {
181 ui.AppendSystemMessage("\n")
182 line = "exit"
183 } else if err != nil {
184 return err
185 }
186
187 line = strings.TrimSpace(line)
188
189 switch line {
190 case "?", "help":
191 ui.AppendSystemMessage(`Available commands:
192- help, ? : Show this help message
193- budget : Show original budget
194- usage, cost : Show current token usage and cost
195- stop, cancel, abort : Cancel the current operation
196- exit, quit, q : Exit the application
197- ! <command> : Execute shell command (e.g. !ls -la)`)
198 case "budget":
199 originalBudget := ui.agent.OriginalBudget()
200 ui.AppendSystemMessage("šŸ’° Budget summary:")
201 if originalBudget.MaxResponses > 0 {
202 ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
203 }
204 if originalBudget.MaxWallTime > 0 {
205 ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
206 }
207 ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
208 case "usage", "cost":
209 totalUsage := ui.agent.TotalUsage()
210 ui.AppendSystemMessage("šŸ’° Current usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000211 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
212 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
214 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
215 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
216 case "bye", "exit", "q", "quit":
217 ui.trm.SetPrompt("")
218 // Display final usage stats
219 totalUsage := ui.agent.TotalUsage()
220 ui.AppendSystemMessage("šŸ’° Final usage summary:")
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +0000221 ui.AppendSystemMessage("- Input tokens: %s", humanize.Comma(int64(totalUsage.TotalInputTokens())))
222 ui.AppendSystemMessage("- Output tokens: %s", humanize.Comma(int64(totalUsage.OutputTokens)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
224 ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
225 ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
226
227 // Display pushed branches
228 ui.mu.Lock()
229 if len(ui.pushedBranches) > 0 {
230 // Convert map keys to a slice for display
231 branches := make([]string, 0, len(ui.pushedBranches))
232 for branch := range ui.pushedBranches {
233 branches = append(branches, branch)
234 }
235
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000236 initialCommitRef := getGitRefName(ui.agent.InitialCommit())
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 if len(branches) == 1 {
238 ui.AppendSystemMessage("\nšŸ”„ Branch pushed during session: %s", branches[0])
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000239 ui.AppendSystemMessage("šŸ’ To add those changes to your branch: git cherry-pick %s..%s", initialCommitRef, branches[0])
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 } else {
241 ui.AppendSystemMessage("\nšŸ”„ Branches pushed during session:")
242 for _, branch := range branches {
243 ui.AppendSystemMessage("- %s", branch)
244 }
245 ui.AppendSystemMessage("\nšŸ’ To add all those changes to your branch:")
246 for _, branch := range branches {
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000247 ui.AppendSystemMessage("git cherry-pick %s..%s", initialCommitRef, branch)
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 }
249 }
250 }
251 ui.mu.Unlock()
252
253 ui.AppendSystemMessage("\nšŸ‘‹ Goodbye!")
254 return nil
255 case "stop", "cancel", "abort":
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000256 ui.agent.CancelTurn(fmt.Errorf("user canceled the operation"))
Earl Lee2e463fb2025-04-17 11:22:22 -0700257 case "panic":
258 panic("user forced a panic")
259 default:
260 if line == "" {
261 continue
262 }
263 if strings.HasPrefix(line, "!") {
264 // Execute as shell command
265 line = line[1:] // remove the '!' prefix
266 sendToLLM := strings.HasPrefix(line, "!")
267 if sendToLLM {
268 line = line[1:] // remove the second '!'
269 }
270
271 // Create a cmd and run it
272 // TODO: ui.trm contains a mutex inside its write call.
273 // It is potentially safe to attach ui.trm directly to this
274 // cmd object's Stdout/Stderr and stream the output.
275 // That would make a big difference for, e.g. wget.
276 cmd := exec.Command("bash", "-c", line)
277 out, err := cmd.CombinedOutput()
278 ui.AppendSystemMessage("%s", out)
279 if err != nil {
280 ui.AppendSystemMessage("āŒ Command error: %v", err)
281 }
282 if sendToLLM {
283 // Send the command and its output to the agent
284 message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
285 if err != nil {
286 message += fmt.Sprintf("\n\nError: %v", err)
287 }
288 ui.agent.UserMessage(ctx, message)
289 }
290 continue
291 }
292
293 // Send it to the LLM
294 // chatMsg := chatMessage{sender: "you", content: line}
295 // ui.sendChatMessage(chatMsg)
296 ui.agent.UserMessage(ctx, line)
297 }
298 }
299}
300
301func (ui *termUI) updatePrompt(thinking bool) {
302 var t string
303
304 if thinking {
305 // Emoji don't seem to work here? Messes up my terminal.
306 t = "*"
307 }
308 p := fmt.Sprintf("%s/ %s($%0.2f/%0.2f)%s> ",
309 ui.httpURL, ui.agent.WorkingDir(), ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
310 ui.trm.SetPrompt(p)
311}
312
313func (ui *termUI) initializeTerminalUI(ctx context.Context) error {
314 ui.mu.Lock()
315 defer ui.mu.Unlock()
316
317 if !term.IsTerminal(int(ui.stdin.Fd())) {
318 return fmt.Errorf("this command requires terminal I/O")
319 }
320
321 oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
322 if err != nil {
323 return err
324 }
325 ui.oldState = oldState
326 ui.trm = term.NewTerminal(ui.stdin, "")
327 width, height, err := term.GetSize(int(ui.stdin.Fd()))
328 if err != nil {
329 return fmt.Errorf("Error getting terminal size: %v\n", err)
330 }
331 ui.trm.SetSize(width, height)
332 // Handle terminal resizes...
333 sig := make(chan os.Signal, 1)
334 signal.Notify(sig, syscall.SIGWINCH)
335 go func() {
336 for {
337 <-sig
338 newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
339 if err != nil {
340 continue
341 }
342 if newWidth != width || newHeight != height {
343 width, height = newWidth, newHeight
344 ui.trm.SetSize(width, height)
345 }
346 }
347 }()
348
349 ui.updatePrompt(false)
350
351 // This is the only place where we should call fe.trm.Write:
352 go func() {
353 for {
354 select {
355 case <-ctx.Done():
356 return
357 case msg := <-ui.chatMsgCh:
358 // Sometimes claude doesn't say anything when it runs tools.
359 // No need to output anything in that case.
360 if strings.TrimSpace(msg.content) == "" {
361 break
362 }
363 s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
364 // Update prompt before writing, because otherwise it doesn't redraw the prompt.
365 ui.updatePrompt(msg.thinking)
366 ui.trm.Write([]byte(s))
367 case logLine := <-ui.termLogCh:
368 b := []byte(logLine + "\n")
369 ui.trm.Write(b)
370 }
371 }
372 }()
373
374 return nil
375}
376
377func (ui *termUI) RestoreOldState() error {
378 ui.mu.Lock()
379 defer ui.mu.Unlock()
380 return term.Restore(int(ui.stdin.Fd()), ui.oldState)
381}
382
383// AppendChatMessage is for showing responses the user's request, conversational dialog etc
384func (ui *termUI) AppendChatMessage(msg chatMessage) {
385 ui.chatMsgCh <- msg
386}
387
388// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
389// but still need to be shown to the user.
390func (ui *termUI) AppendSystemMessage(fmtString string, args ...any) {
391 ui.termLogCh <- fmt.Sprintf(fmtString, args...)
392}
Josh Bleecher Snyder0137a7f2025-04-30 01:16:35 +0000393
394// getGitRefName returns a readable git ref for sha, falling back to the original sha on error.
395func getGitRefName(sha string) string {
396 // branch or tag name
397 cmd := exec.Command("git", "rev-parse", "--abbrev-ref", sha)
398 branchName, err := cmd.Output()
399 if err == nil {
400 branchStr := strings.TrimSpace(string(branchName))
401 // If we got a branch name that's not HEAD, use it
402 if branchStr != "" && branchStr != "HEAD" {
403 return branchStr
404 }
405 }
406
407 // short SHA
408 cmd = exec.Command("git", "rev-parse", "--short", sha)
409 shortSha, err := cmd.Output()
410 if err == nil {
411 shortStr := strings.TrimSpace(string(shortSha))
412 if shortStr != "" {
413 return shortStr
414 }
415 }
416
417 return sha
418}