package shared import ( "encoding/json" "errors" "fmt" "io" "path/filepath" "slices" "strings" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" ) //go:generate moq -rm -out log_mock.go . LogRenderer type LogRenderer interface { Follow(fetcher func() ([]byte, error), w io.Writer, io *iostreams.IOStreams) error Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (stop bool, err error) } type logRenderer struct{} func NewLogRenderer() LogRenderer { return &logRenderer{} } // Follow continuously fetches logs using the provided fetcher function and // renders them to the provided writer. It stops when Render indicates to stop. func (r *logRenderer) Follow(fetcher func() ([]byte, error), w io.Writer, io *iostreams.IOStreams) error { var last string for { raw, err := fetcher() if err != nil { return err } logs := string(raw) if logs == last { continue } diff := strings.TrimSpace(logs[len(last):]) if stop, err := r.Render([]byte(diff), w, io); err != nil { return err } else if stop { return nil } last = logs } } // Render processes the given logs and writes the rendered output to w. // Errors are returned when an unexpected log entry is encountered. func (r *logRenderer) Render(logs []byte, w io.Writer, io *iostreams.IOStreams) (bool, error) { lines := slices.DeleteFunc(strings.Split(string(logs), "\n"), func(line string) bool { return line == "" }) for _, line := range lines { raw, found := strings.CutPrefix(line, "data: ") if !found { return false, errors.New("unexpected log format") } // The only log entry type we're interested in is a chat completion chunk, // which can be verified by a successful unmarshal into the corresponding // type AND the Object field being equal to "chat.completion.chunk". The // latter is to avoid accepting an empty JSON object (i.e. "{}"). Also, // if the entry is not what we expect, we should just skip and avoid // returning an error. var entry chatCompletionChunkEntry err := json.Unmarshal([]byte(raw), &entry) if err != nil || entry.Object != "chat.completion.chunk" { continue } if stop, err := renderLogEntry(entry, w, io); err != nil { return false, fmt.Errorf("failed to process log entry: %w", err) } else if stop { return true, nil } } return false, nil } func renderLogEntry(entry chatCompletionChunkEntry, w io.Writer, io *iostreams.IOStreams) (bool, error) { cs := io.ColorScheme() var stop bool for _, choice := range entry.Choices { if choice.FinishReason == "stop" { stop = true } if len(choice.Delta.ToolCalls) == 0 { if choice.Delta.Content != "" && choice.Delta.Role == "assistant" { // Copilot message and we should display. renderRawMarkdown(choice.Delta.Content, w, io) } continue } // Since we don't want to clear-and-reprint live progress of events, we // need to only process entries that correspond to a finished tool call. // Such entries have a non-empty Content field. if choice.Delta.Content == "" { continue } if choice.Delta.ReasoningText != "" { // Note that this should be formatted as a normal "thought" message, // without the heading. renderRawMarkdown(choice.Delta.ReasoningText, w, io) } for _, tc := range choice.Delta.ToolCalls { name := tc.Function.Name if name == "" { continue } args := tc.Function.Arguments switch name { case "run_setup": if v := unmarshal[runSetupToolArgs](args); v != nil { renderToolCallTitle(w, cs, v.Name, "") continue } case "view": args := viewToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to parse 'view' tool call arguments: %v\n", err) continue } renderToolCallTitle(w, cs, fmt.Sprintf("View %s", cs.Bold(relativeFilePath(args.Path))), "") content := stripDiffFormat(choice.Delta.Content) if err := renderFileContentAsMarkdown(args.Path, content, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render viewed file content: %v\n\n", err) fmt.Fprintln(io.ErrOut, content) // raw fallback } case "bash": if v := unmarshal[bashToolArgs](args); v != nil { if v.Description != "" { renderToolCallTitle(w, cs, "Bash", v.Description) } else { renderToolCallTitle(w, cs, "Run Bash command", "") } contentWithCommand := choice.Delta.Content if v.Command != "" { contentWithCommand = fmt.Sprintf("$ %s\n%s", v.Command, choice.Delta.Content) } if err := renderFileContentAsMarkdown("commands.sh", contentWithCommand, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render bash command output: %v\n\n", err) fmt.Fprintln(io.ErrOut, contentWithCommand) } } // TODO: consider including more details for these bash-related tool calls. case "write_bash": if v := unmarshal[writeBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Send input to Bash session", "") continue } case "read_bash": if v := unmarshal[readBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Read logs from Bash session", "") continue } case "stop_bash": if v := unmarshal[stopBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Stop Bash session", "") continue } case "async_bash": if v := unmarshal[asyncBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Start or send input to long-running Bash session", "") continue } case "read_async_bash": if v := unmarshal[readAsyncBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "View logs from long-running Bash session", "") continue } case "stop_async_bash": if v := unmarshal[stopAsyncBashToolArgs](args); v != nil { renderToolCallTitle(w, cs, "Stop long-running Bash session", "") continue } case "think": args := thinkToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to parse 'think' tool call arguments: %v\n", err) continue } // NOTE: omit the delta.content since it's the same as thought renderToolCallTitle(w, cs, "Thought", "") if err := renderRawMarkdown(args.Thought, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render thought: %v\n", err) } case "report_progress": args := reportProgressToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to parse 'report_progress' tool call arguments: %v\n", err) continue } renderToolCallTitle(w, cs, "Progress update", cs.Bold(args.CommitMessage)) if args.PrDescription != "" { if err := renderRawMarkdown(args.PrDescription, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render PR description: %v\n", err) } } // TODO: KW I wasn't able to get this case to populate ever. if choice.Delta.Content != "" { // Try to treat this as JSON if err := renderContentAsJSONMarkdown("", choice.Delta.Content, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render progress update content: %v\n", err) } } case "create": args := createToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to parse 'create' tool call arguments: %v\n", err) continue } renderToolCallTitle(w, cs, "Create", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown(args.Path, args.FileText, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render created file content: %v\n\n", err) fmt.Fprintln(io.ErrOut, args.FileText) } case "str_replace": args := strReplaceToolArgs{} if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to parse 'str_replace' tool call arguments: %v\n", err) continue } renderToolCallTitle(w, cs, "Edit", cs.Bold(relativeFilePath(args.Path))) if err := renderFileContentAsMarkdown("output.diff", choice.Delta.Content, w, io); err != nil { fmt.Fprintf(io.ErrOut, "\nfailed to render str_replace diff: %v\n\n", err) fmt.Fprintln(io.ErrOut, choice.Delta.Content) } default: // Unknown tool call. For example for "codeql_checker": // NOTE: omit the delta.content since we don't know how large could that be renderGenericToolCall(w, cs, name) // If it's JSON, treat it as such, otherwise we skip whatever the content is. _ = renderContentAsJSONMarkdown("Output:", choice.Delta.Content, w, io) // The entirety of the args can be treated as "input" to the tool call. // We try to render it as JSON, but if that fails, just skip it. _ = renderContentAsJSONMarkdown("Input:", args, w, io) } } } return stop, nil } // renderContentAsJSONMarkdown tries to unmarshal the given content as JSON, // wrap that content in a markdown JSON code block, and render it as markdown. // If label is non-empty, it is rendered as leading text before and outside of // the JSON block. func renderContentAsJSONMarkdown(label, content string, w io.Writer, io *iostreams.IOStreams) error { var contentAsJSON any if err := json.Unmarshal([]byte(content), &contentAsJSON); err == nil { marshaled, err := json.MarshalIndent(contentAsJSON, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } if label != "" { if err := renderRawMarkdown(label, w, io); err != nil { return fmt.Errorf("failed to render label: %w", err) } } if err := renderFileContentAsMarkdown("output.json", string(marshaled), w, io); err != nil { return fmt.Errorf("failed to render JSON: %w", err) } } return nil } // renderRawMarkdown renders the given raw markdown string to the given writer. // Use for complete markdown content from tool calls that need no conversion. func renderRawMarkdown(md string, w io.Writer, io *iostreams.IOStreams) error { // Glamour doesn't add leading newlines when content is a complete // markdown document. So, we must add the leading newline. formatFunc := func(s string) string { return fmt.Sprintf("\n%s\n\n", s) } return renderMarkdownWithFormat(md, w, io, formatFunc) } // renderMarkdownWithFormat renders the given markdown string to the given writer. // If a formatFunc is provided, the md string is ran through it before // rendering. This can be used to add newlines before and after the content. func renderMarkdownWithFormat(md string, w io.Writer, io *iostreams.IOStreams, formatFunc func(string) string) error { rendered, err := markdown.Render(md, markdown.WithTheme(io.TerminalTheme()), markdown.WithWrap(io.TerminalWidth()), ) if err != nil { return fmt.Errorf("failed to render markdown: %w", err) } rendered = strings.TrimSpace(rendered) if formatFunc != nil { rendered = formatFunc(rendered) } fmt.Fprint(w, rendered) return nil } // stripDiffFormat implements a primitive conversion from a diff string to a // plain text representation by removing diff-specific formatting. func stripDiffFormat(diff string) string { lines := strings.Split(diff, "\n") // Find where the hunk header ends. hunkEndIndex := -1 for i, line := range lines { if strings.HasPrefix(line, "@@") { hunkEndIndex = i break } } // This isn't a diff. if hunkEndIndex == -1 { return diff } // Removing hunk header. lines = lines[hunkEndIndex+1:] // Strip the leading + and - from lines, if they exist. var stripped []string for _, line := range lines { if strings.HasPrefix(line, "+") || strings.HasPrefix(line, "-") { stripped = append(stripped, line[1:]) } else { stripped = append(stripped, line) } } return strings.Join(stripped, "\n") } // renderFileContentAsMarkdown renders the given content as markdown // based on the file extension of the path. func renderFileContentAsMarkdown(path, content string, w io.Writer, io *iostreams.IOStreams) error { lang := filepath.Ext(filepath.ToSlash(path)) if lang == ".md" { return renderRawMarkdown(content, w, io) } md := fmt.Sprintf("```%s\n%s\n```", lang, content) // Glamour adds leading newlines when content is only a code block, // so we only want to add a trailing newline. formatFunc := func(s string) string { return fmt.Sprintf("%s\n\n", s) } return renderMarkdownWithFormat(md, w, io, formatFunc) } // relativeFilePath converts an absolute file path to a relative one. // We expect paths to be of the form: /home/runner/work///path/to/file // The expected output of that example is: path/to/file func relativeFilePath(absPath string) string { relPath := strings.TrimPrefix(absPath, "/home/runner/work/") parts := strings.Split(relPath, "/") // The last two parts of the path are the // repo name and the repo owner. // If that's all we have (or less), // we return a friendly name "repository". if len(parts) > 2 { // Drop the repo owner and name, returning the remaining path. return strings.Join(parts[2:], "/") } return "repository" } func unmarshal[T any](raw string) *T { var t T if err := json.Unmarshal([]byte(raw), &t); err != nil { return nil } return &t } // renderToolCallTitle renders a title for a tool call. Should be followed by a // call to render a markdown representation of the tool call's content. func renderToolCallTitle(w io.Writer, cs *iostreams.ColorScheme, toolName, title string) { // Should not happen, but if it does we still want to print a heading // with the information we do have. if toolName == "" { toolName = "Generic tool call" } if title != "" { title = cs.Bold(title) } if title != "" { fmt.Fprintf(w, "%s: %s\n", toolName, title) } else { fmt.Fprintf(w, "%s\n", toolName) } } // genericToolCallNamesToTitles maps known generic tool call identifiers to human-friendly titles. var genericToolCallNamesToTitles = map[string]string{ // Custom tools, the GitHub UI doesn't currently have these. "codeql_checker": "Run CodeQL analysis", // Playwright tools. "playwright-browser_navigate": "Navigate Playwright web browser to a URL", "playwright-browser_navigate_back": "Navigate back in Playwright web browser", "playwright-browser_navigate_forward": "Navigate forward in Playwright web browser", "playwright-browser_click": "Click element in Playwright web browser", "playwright-browser_take_screenshot": "Take screenshot of Playwright web browser", "playwright-browser_type": "Type in Playwright web browser", "playwright-browser_wait_for": "Wait for text to appear/disappear in Playwright web browser", "playwright-browser_evaluate": "Run JavaScript in Playwright web browser", "playwright-browser_snapshot": "Take snapshot of page in Playwright web browser", "playwright-browser_resize": "Resize Playwright web browser window", "playwright-browser_close": "Close Playwright web browser", "playwright-browser_press_key": "Press key in Playwright web browser", "playwright-browser_select_option": "Select option in Playwright web browser", "playwright-browser_handle_dialog": "Interact with dialog in Playwright web browser", "playwright-browser_console_messages": "Get console messages from Playwright web browser", "playwright-browser_drag": "Drag mouse between elements in Playwright web browser", "playwright-browser_file_upload": "Upload file in Playwright web browser", "playwright-browser_hover": "Hover mouse over element in Playwright web browser", "playwright-browser_network_requests": "Get network requests from Playwright web browser", // GitHub MCP server common tools "github-mcp-server-get_file_contents": "Get file contents from GitHub", "github-mcp-server-get_pull_request": "Get pull request from GitHub", "github-mcp-server-get_issue": "Get issue from GitHub", "github-mcp-server-get_pull_request_files": "Get pull request changed files from GitHub", "github-mcp-server-list_pull_requests": "List pull requests on GitHub", "github-mcp-server-list_branches": "List branches on GitHub", "github-mcp-server-get_pull_request_diff": "Get pull request diff from GitHub", "github-mcp-server-get_pull_request_comments": "Get pull request comments from GitHub", "github-mcp-server-get_commit": "Get commit from GitHub", "github-mcp-server-search_repositories": "Search repositories on GitHub", "github-mcp-server-search_code": "Search code on GitHub", "github-mcp-server-get_issue_comments": "Get issue comments from GitHub", "github-mcp-server-list_issues": "List issues on GitHub", "github-mcp-server-search_pull_requests": "Search pull requests on GitHub", "github-mcp-server-list_commits": "List commits on GitHub", "github-mcp-server-get_pull_request_status": "Get pull request status from GitHub", "github-mcp-server-search_issues": "Search issues on GitHub", "github-mcp-server-get_pull_request_reviews": "Get pull request reviews from GitHub", "github-mcp-server-download_workflow_run_artifact": "Download GitHub Actions workflow run artifact", "github-mcp-server-get_job_logs": "Get GitHub Actions job logs", "github-mcp-server-get_workflow_run": "Get GitHub Actions workflow run", "github-mcp-server-get_workflow_run_logs": "Get GitHub Actions workflow run logs", "github-mcp-server-get_workflow_run_usage": "Get GitHub Actions workflow usage", "github-mcp-server-list_workflow_jobs": "List GitHub Actions workflow jobs", "github-mcp-server-list_workflow_run_artifacts": "List GitHub Actions workflow run artifacts", "github-mcp-server-list_workflow_runs": "List GitHub Actions workflow runs", "github-mcp-server-list_workflows": "List GitHub Actions workflows", } func renderGenericToolCall(w io.Writer, cs *iostreams.ColorScheme, name string) { toolName, ok := genericToolCallNamesToTitles[name] if !ok { toolName = fmt.Sprintf("Call to %s", name) } renderToolCallTitle(w, cs, toolName, "") } type chatCompletionChunkEntry struct { ID string `json:"id"` Created int64 `json:"created"` Model string `json:"model"` Object string `json:"object"` Choices []struct { Delta struct { ReasoningText string `json:"reasoning_text"` Content string `json:"content"` Role string `json:"role"` ToolCalls []struct { Function struct { Name string `json:"name"` Arguments string `json:"arguments"` } `json:"function"` Index int `json:"index"` ID string `json:"id"` } `json:"tool_calls"` } `json:"delta"` FinishReason string `json:"finish_reason"` Index int `json:"index"` } `json:"choices"` } type runSetupToolArgs struct { Name string `json:"name"` } type bashToolArgs struct { Command string `json:"command"` Description string `json:"description"` } type readBashToolArgs struct { SessionID string `json:"sessionId"` } type writeBashToolArgs struct { SessionID string `json:"sessionId"` Input string `json:"input"` } type stopBashToolArgs struct { SessionID string `json:"sessionId"` } type asyncBashToolArgs struct { Command string `json:"command"` SessionID string `json:"sessionId"` } type readAsyncBashToolArgs struct { SessionID string `json:"sessionId"` } type stopAsyncBashToolArgs struct { SessionID string `json:"sessionId"` } type viewToolArgs struct { Path string `json:"path"` } type thinkToolArgs struct { SessionID string `json:"sessionId"` Thought string `json:"thought"` } type reportProgressToolArgs struct { CommitMessage string `json:"commitMessage"` PrDescription string `json:"prDescription"` } type createToolArgs struct { FileText string `json:"file_text"` Path string `json:"path"` } type strReplaceToolArgs struct { NewStr string `json:"new_str"` OldStr string `json:"old_str"` Path string `json:"path"` }