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