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