Initial commit
diff --git a/termui/termui.go b/termui/termui.go
new file mode 100644
index 0000000..d1aaf73
--- /dev/null
+++ b/termui/termui.go
@@ -0,0 +1,391 @@
+package termui
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "os/signal"
+ "strings"
+ "sync"
+ "syscall"
+ "text/template"
+ "time"
+
+ "github.com/fatih/color"
+ "golang.org/x/term"
+ "sketch.dev/loop"
+)
+
+var (
+ // toolUseTemplTxt defines how tool invocations appear in the terminal UI.
+ // Keep this template in sync with the tools defined in claudetool package
+ // and registered in loop/agent.go.
+ // Add formatting for new tools as they are created.
+ // TODO: should this be part of tool definition to make it harder to forget to set up?
+ toolUseTemplTxt = `{{if .msg.ToolError}}š {{end -}}
+{{if eq .msg.ToolName "think" -}}
+ š§ {{.input.thoughts -}}
+{{else if eq .msg.ToolName "keyword_search" -}}
+ š {{ .input.query}}: {{.input.keywords -}}
+{{else if eq .msg.ToolName "bash" -}}
+ š„ļø {{ .input.command -}}
+{{else if eq .msg.ToolName "patch" -}}
+ āØļø {{.input.path -}}
+{{else if eq .msg.ToolName "done" -}}
+{{/* nothing to show here, the agent will write more in its next message */}}
+{{else if eq .msg.ToolName "title" -}}
+ š·ļø {{.input.title -}}
+{{else if eq .msg.ToolName "str_replace_editor" -}}
+ āļø {{.input.file_path -}}
+{{else if eq .msg.ToolName "codereview" -}}
+ š Running automated code review, may be slow
+{{else -}}
+ š ļø {{ .msg.ToolName}}: {{.msg.ToolInput -}}
+{{end -}}
+`
+ toolUseTmpl = template.Must(template.New("tool_use").Parse(toolUseTemplTxt))
+)
+
+type termUI struct {
+ stdin *os.File
+ stdout *os.File
+ stderr *os.File
+
+ agent loop.CodingAgent
+ httpURL string
+
+ trm *term.Terminal
+
+ // the chatMsgCh channel is for "conversation" messages, like responses to user input
+ // from the LLM, or output from executing slash-commands issued by the user.
+ chatMsgCh chan chatMessage
+
+ // the log channel is for secondary messages, like logging, errors, and debug information
+ // from local and remove subproceses.
+ termLogCh chan string
+
+ // protects following
+ mu sync.Mutex
+ oldState *term.State
+ // Tracks branches that were pushed during the session
+ pushedBranches map[string]struct{}
+}
+
+type chatMessage struct {
+ idx int
+ sender string
+ content string
+ thinking bool
+}
+
+func New(agent loop.CodingAgent, httpURL string) *termUI {
+ return &termUI{
+ agent: agent,
+ stdin: os.Stdin,
+ stdout: os.Stdout,
+ stderr: os.Stderr,
+ httpURL: httpURL,
+ chatMsgCh: make(chan chatMessage, 1),
+ termLogCh: make(chan string, 1),
+ pushedBranches: make(map[string]struct{}),
+ }
+}
+
+func (ui *termUI) Run(ctx context.Context) error {
+ fmt.Println(`šØ Welcome to Sketch`)
+ fmt.Println(`š ` + ui.httpURL + `/`)
+ fmt.Println(`š Initial Commit: ` + ui.agent.InitialCommit())
+ fmt.Println(`š¬ type 'help' for help`)
+ fmt.Println()
+
+ // Start up the main terminal UI:
+ if err := ui.initializeTerminalUI(ctx); err != nil {
+ return err
+ }
+ go ui.receiveMessagesLoop(ctx)
+ if err := ui.inputLoop(ctx); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (ui *termUI) LogToolUse(resp loop.AgentMessage) {
+ inputData := map[string]any{}
+ if err := json.Unmarshal([]byte(resp.ToolInput), &inputData); err != nil {
+ ui.AppendSystemMessage("error: %v", err)
+ return
+ }
+ buf := bytes.Buffer{}
+ if err := toolUseTmpl.Execute(&buf, map[string]any{"msg": resp, "input": inputData, "output": resp.ToolResult}); err != nil {
+ ui.AppendSystemMessage("error: %v", err)
+ return
+ }
+ ui.AppendSystemMessage("%s\n", buf.String())
+}
+
+func (ui *termUI) receiveMessagesLoop(ctx context.Context) {
+ bold := color.New(color.Bold).SprintFunc()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ resp := ui.agent.WaitForMessage(ctx)
+ // Typically a user message will start the thinking and a (top-level
+ // conversation) end of turn will stop it.
+ thinking := !(resp.EndOfTurn && resp.ParentConversationID == nil)
+
+ switch resp.Type {
+ case loop.AgentMessageType:
+ ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "š“ļø", content: resp.Content})
+ case loop.ToolUseMessageType:
+ ui.LogToolUse(resp)
+ case loop.ErrorMessageType:
+ ui.AppendSystemMessage("ā %s", resp.Content)
+ case loop.BudgetMessageType:
+ ui.AppendSystemMessage("š° %s", resp.Content)
+ case loop.AutoMessageType:
+ ui.AppendSystemMessage("š§ %s", resp.Content)
+ case loop.UserMessageType:
+ ui.AppendChatMessage(chatMessage{thinking: thinking, idx: resp.Idx, sender: "š¤ļø", content: resp.Content})
+ case loop.CommitMessageType:
+ // Display each commit in the terminal
+ for _, commit := range resp.Commits {
+ if commit.PushedBranch != "" {
+ ui.AppendSystemMessage("š new commit: [%s] %s pushed to %s", commit.Hash[:8], commit.Subject, bold(commit.PushedBranch))
+
+ // Track the pushed branch in our map
+ ui.mu.Lock()
+ ui.pushedBranches[commit.PushedBranch] = struct{}{}
+ ui.mu.Unlock()
+ } else {
+ ui.AppendSystemMessage("š new commit: [%s] %s", commit.Hash[:8], commit.Subject)
+ }
+ }
+ default:
+ ui.AppendSystemMessage("ā Unexpected Message Type %s %v", resp.Type, resp)
+ }
+ }
+}
+
+func (ui *termUI) inputLoop(ctx context.Context) error {
+ for {
+ line, err := ui.trm.ReadLine()
+ if errors.Is(err, io.EOF) {
+ ui.AppendSystemMessage("\n")
+ line = "exit"
+ } else if err != nil {
+ return err
+ }
+
+ line = strings.TrimSpace(line)
+
+ switch line {
+ case "?", "help":
+ ui.AppendSystemMessage(`Available commands:
+- help, ? : Show this help message
+- budget : Show original budget
+- usage, cost : Show current token usage and cost
+- stop, cancel, abort : Cancel the current operation
+- exit, quit, q : Exit the application
+- ! <command> : Execute shell command (e.g. !ls -la)`)
+ case "budget":
+ originalBudget := ui.agent.OriginalBudget()
+ ui.AppendSystemMessage("š° Budget summary:")
+ if originalBudget.MaxResponses > 0 {
+ ui.AppendSystemMessage("- Max responses: %d", originalBudget.MaxResponses)
+ }
+ if originalBudget.MaxWallTime > 0 {
+ ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
+ }
+ ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
+ case "usage", "cost":
+ totalUsage := ui.agent.TotalUsage()
+ ui.AppendSystemMessage("š° Current usage summary:")
+ ui.AppendSystemMessage("- Input tokens: %d [Cache: read=%d, creation=%d]", totalUsage.InputTokens, totalUsage.CacheReadInputTokens, totalUsage.CacheCreationInputTokens)
+ ui.AppendSystemMessage("- Output tokens: %d", totalUsage.OutputTokens)
+ ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
+ ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
+ ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
+ case "bye", "exit", "q", "quit":
+ ui.trm.SetPrompt("")
+ // Display final usage stats
+ totalUsage := ui.agent.TotalUsage()
+ ui.AppendSystemMessage("š° Final usage summary:")
+ ui.AppendSystemMessage("- Input tokens: %d [Cache: read=%d, creation=%d]", totalUsage.InputTokens, totalUsage.CacheReadInputTokens, totalUsage.CacheCreationInputTokens)
+ ui.AppendSystemMessage("- Output tokens: %d", totalUsage.OutputTokens)
+ ui.AppendSystemMessage("- Responses: %d", totalUsage.Responses)
+ ui.AppendSystemMessage("- Wall time: %s", totalUsage.WallTime().Round(time.Second))
+ ui.AppendSystemMessage("- Total cost: $%0.2f", totalUsage.TotalCostUSD)
+
+ // Display pushed branches
+ ui.mu.Lock()
+ if len(ui.pushedBranches) > 0 {
+ // Convert map keys to a slice for display
+ branches := make([]string, 0, len(ui.pushedBranches))
+ for branch := range ui.pushedBranches {
+ branches = append(branches, branch)
+ }
+
+ if len(branches) == 1 {
+ ui.AppendSystemMessage("\nš Branch pushed during session: %s", branches[0])
+ ui.AppendSystemMessage("š To add those changes to your branch: git cherry-pick %s..%s", ui.agent.InitialCommit(), branches[0])
+ } else {
+ ui.AppendSystemMessage("\nš Branches pushed during session:")
+ for _, branch := range branches {
+ ui.AppendSystemMessage("- %s", branch)
+ }
+ ui.AppendSystemMessage("\nš To add all those changes to your branch:")
+ for _, branch := range branches {
+ ui.AppendSystemMessage("git cherry-pick %s..%s", ui.agent.InitialCommit(), branch)
+ }
+ }
+ }
+ ui.mu.Unlock()
+
+ ui.AppendSystemMessage("\nš Goodbye!")
+ return nil
+ case "stop", "cancel", "abort":
+ ui.agent.CancelInnerLoop(fmt.Errorf("user canceled the operation"))
+ case "panic":
+ panic("user forced a panic")
+ default:
+ if line == "" {
+ continue
+ }
+ if strings.HasPrefix(line, "!") {
+ // Execute as shell command
+ line = line[1:] // remove the '!' prefix
+ sendToLLM := strings.HasPrefix(line, "!")
+ if sendToLLM {
+ line = line[1:] // remove the second '!'
+ }
+
+ // Create a cmd and run it
+ // TODO: ui.trm contains a mutex inside its write call.
+ // It is potentially safe to attach ui.trm directly to this
+ // cmd object's Stdout/Stderr and stream the output.
+ // That would make a big difference for, e.g. wget.
+ cmd := exec.Command("bash", "-c", line)
+ out, err := cmd.CombinedOutput()
+ ui.AppendSystemMessage("%s", out)
+ if err != nil {
+ ui.AppendSystemMessage("ā Command error: %v", err)
+ }
+ if sendToLLM {
+ // Send the command and its output to the agent
+ message := fmt.Sprintf("I ran the command: `%s`\nOutput:\n```\n%s```", line, out)
+ if err != nil {
+ message += fmt.Sprintf("\n\nError: %v", err)
+ }
+ ui.agent.UserMessage(ctx, message)
+ }
+ continue
+ }
+
+ // Send it to the LLM
+ // chatMsg := chatMessage{sender: "you", content: line}
+ // ui.sendChatMessage(chatMsg)
+ ui.agent.UserMessage(ctx, line)
+ }
+ }
+}
+
+func (ui *termUI) updatePrompt(thinking bool) {
+ var t string
+
+ if thinking {
+ // Emoji don't seem to work here? Messes up my terminal.
+ t = "*"
+ }
+ p := fmt.Sprintf("%s/ %s($%0.2f/%0.2f)%s> ",
+ ui.httpURL, ui.agent.WorkingDir(), ui.agent.TotalUsage().TotalCostUSD, ui.agent.OriginalBudget().MaxDollars, t)
+ ui.trm.SetPrompt(p)
+}
+
+func (ui *termUI) initializeTerminalUI(ctx context.Context) error {
+ ui.mu.Lock()
+ defer ui.mu.Unlock()
+
+ if !term.IsTerminal(int(ui.stdin.Fd())) {
+ return fmt.Errorf("this command requires terminal I/O")
+ }
+
+ oldState, err := term.MakeRaw(int(ui.stdin.Fd()))
+ if err != nil {
+ return err
+ }
+ ui.oldState = oldState
+ ui.trm = term.NewTerminal(ui.stdin, "")
+ width, height, err := term.GetSize(int(ui.stdin.Fd()))
+ if err != nil {
+ return fmt.Errorf("Error getting terminal size: %v\n", err)
+ }
+ ui.trm.SetSize(width, height)
+ // Handle terminal resizes...
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGWINCH)
+ go func() {
+ for {
+ <-sig
+ newWidth, newHeight, err := term.GetSize(int(ui.stdin.Fd()))
+ if err != nil {
+ continue
+ }
+ if newWidth != width || newHeight != height {
+ width, height = newWidth, newHeight
+ ui.trm.SetSize(width, height)
+ }
+ }
+ }()
+
+ ui.updatePrompt(false)
+
+ // This is the only place where we should call fe.trm.Write:
+ go func() {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case msg := <-ui.chatMsgCh:
+ // Sometimes claude doesn't say anything when it runs tools.
+ // No need to output anything in that case.
+ if strings.TrimSpace(msg.content) == "" {
+ break
+ }
+ s := fmt.Sprintf("%s %s\n", msg.sender, msg.content)
+ // Update prompt before writing, because otherwise it doesn't redraw the prompt.
+ ui.updatePrompt(msg.thinking)
+ ui.trm.Write([]byte(s))
+ case logLine := <-ui.termLogCh:
+ b := []byte(logLine + "\n")
+ ui.trm.Write(b)
+ }
+ }
+ }()
+
+ return nil
+}
+
+func (ui *termUI) RestoreOldState() error {
+ ui.mu.Lock()
+ defer ui.mu.Unlock()
+ return term.Restore(int(ui.stdin.Fd()), ui.oldState)
+}
+
+// AppendChatMessage is for showing responses the user's request, conversational dialog etc
+func (ui *termUI) AppendChatMessage(msg chatMessage) {
+ ui.chatMsgCh <- msg
+}
+
+// AppendSystemMessage is for debug information, errors and such that are not part of the "conversation" per se,
+// but still need to be shown to the user.
+func (ui *termUI) AppendSystemMessage(fmtString string, args ...any) {
+ ui.termLogCh <- fmt.Sprintf(fmtString, args...)
+}