Skip to content
This repository was archived by the owner on Oct 6, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
79 changes: 75 additions & 4 deletions commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"os"
"strings"

"github.com/charmbracelet/glamour"
"github.com/docker/model-cli/commands/completion"
"github.com/docker/model-cli/desktop"
"github.com/spf13/cobra"
"golang.org/x/term"
)

// readMultilineInput reads input from stdin, supporting both single-line and multiline input.
Expand Down Expand Up @@ -78,6 +80,75 @@ func readMultilineInput(cmd *cobra.Command, scanner *bufio.Scanner) (string, err
return multilineInput.String(), nil
}

var (
markdownRenderer *glamour.TermRenderer
lastWidth int
)
Comment on lines +84 to +87
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Global variables markdownRenderer and lastWidth are not thread-safe. If multiple goroutines call getMarkdownRenderer() concurrently, this could lead to race conditions when checking and updating these variables.

Copilot uses AI. Check for mistakes.

// getTerminalWidth returns the terminal width, with a fallback to 80.
func getTerminalWidth() int {
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
return 80
}
return width
}

// getMarkdownRenderer returns a markdown renderer, recreating it if terminal width changed.
func getMarkdownRenderer() (*glamour.TermRenderer, error) {
currentWidth := getTerminalWidth()

// Recreate if width changed or renderer doesn't exist.
if markdownRenderer == nil || currentWidth != lastWidth {
r, err := glamour.NewTermRenderer(
glamour.WithAutoStyle(),
glamour.WithWordWrap(currentWidth),
)
if err != nil {
return nil, fmt.Errorf("failed to create markdown renderer: %w", err)
}
markdownRenderer = r
lastWidth = currentWidth
}

return markdownRenderer, nil
}

func renderMarkdown(content string) (string, error) {
r, err := getMarkdownRenderer()
if err != nil {
return "", fmt.Errorf("failed to create markdown renderer: %w", err)
}

rendered, err := r.Render(content)
if err != nil {
return "", fmt.Errorf("failed to render markdown: %w", err)
}

return rendered, nil
}

// chatWithMarkdown performs chat and renders the response as Markdown.
func chatWithMarkdown(cmd *cobra.Command, client *desktop.Client, backend, model, prompt, apiKey string) error {
response, err := client.Chat(backend, model, prompt, apiKey)
if err != nil {
return err
}

// Try to render as Markdown, fallback to plain text if it fails.
rendered, err := renderMarkdown(response)
if err != nil {
if debug, _ := cmd.Flags().GetBool("debug"); debug {
cmd.PrintErrln(err)
}
cmd.Print(response)
return nil
}

cmd.Print(rendered)
return nil
}

func newRunCmd() *cobra.Command {
var debug bool
var backend string
Expand All @@ -103,8 +174,8 @@ func newRunCmd() *cobra.Command {

model := args[0]
prompt := ""
args_len := len(args)
if args_len > 1 {
argsLen := len(args)
if argsLen > 1 {
prompt = strings.Join(args[1:], " ")
}

Expand Down Expand Up @@ -149,7 +220,7 @@ func newRunCmd() *cobra.Command {
}

if prompt != "" {
if err := desktopClient.Chat(backend, model, prompt, apiKey); err != nil {
if err := chatWithMarkdown(cmd, desktopClient, backend, model, prompt, apiKey); err != nil {
return handleClientError(err, "Failed to generate a response")
}
cmd.Println()
Expand Down Expand Up @@ -178,7 +249,7 @@ func newRunCmd() *cobra.Command {
continue
}

if err := desktopClient.Chat(backend, model, userInput, apiKey); err != nil {
if err := chatWithMarkdown(cmd, desktopClient, backend, model, userInput, apiKey); err != nil {
cmd.PrintErr(handleClientError(err, "Failed to generate a response"))
continue
}
Expand Down
54 changes: 21 additions & 33 deletions desktop/desktop.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"github.com/docker/model-runner/pkg/inference"
dmrm "github.com/docker/model-runner/pkg/inference/models"
"github.com/docker/model-runner/pkg/inference/scheduling"
"github.com/fatih/color"
"github.com/pkg/errors"
"go.opentelemetry.io/otel"
)
Expand Down Expand Up @@ -364,15 +363,8 @@ func (c *Client) fullModelID(id string) (string, error) {
return "", fmt.Errorf("model with ID %s not found", id)
}

type chatPrinterState int

const (
chatPrinterNone chatPrinterState = iota
chatPrinterContent
chatPrinterReasoning
)

func (c *Client) Chat(backend, model, prompt, apiKey string) error {
// Chat performs a chat request and returns the response content.
func (c *Client) Chat(backend, model, prompt, apiKey string) (string, error) {
model = normalizeHuggingFaceModelName(model)
if !strings.Contains(strings.Trim(model, "/"), "/") {
// Do an extra API call to check if the model parameter isn't a model ID.
Expand All @@ -394,7 +386,7 @@ func (c *Client) Chat(backend, model, prompt, apiKey string) error {

jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("error marshaling request: %w", err)
return "", fmt.Errorf("error marshaling request: %w", err)
}

var completionsPath string
Expand All @@ -412,17 +404,17 @@ func (c *Client) Chat(backend, model, prompt, apiKey string) error {
apiKey,
)
if err != nil {
return c.handleQueryError(err, completionsPath)
return "", c.handleQueryError(err, completionsPath)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("error response: status=%d body=%s", resp.StatusCode, body)
return "", fmt.Errorf("error response: status=%d body=%s", resp.StatusCode, body)
}

printerState := chatPrinterNone
reasoningFmt := color.New(color.FgWhite).Add(color.Italic)
var responseContent strings.Builder
var reasoningContent strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
Expand All @@ -442,37 +434,33 @@ func (c *Client) Chat(backend, model, prompt, apiKey string) error {

var streamResp OpenAIChatResponse
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
return fmt.Errorf("error parsing stream response: %w", err)
return "", fmt.Errorf("error parsing stream response: %w", err)
}

if len(streamResp.Choices) > 0 {
if streamResp.Choices[0].Delta.ReasoningContent != "" {
chunk := streamResp.Choices[0].Delta.ReasoningContent
if printerState == chatPrinterContent {
fmt.Print("\n\n")
}
if printerState != chatPrinterReasoning {
reasoningFmt.Println("Thinking:")
}
printerState = chatPrinterReasoning
reasoningFmt.Print(chunk)
reasoningContent.WriteString(streamResp.Choices[0].Delta.ReasoningContent)
}
if streamResp.Choices[0].Delta.Content != "" {
chunk := streamResp.Choices[0].Delta.Content
if printerState == chatPrinterReasoning {
fmt.Print("\n\n")
}
printerState = chatPrinterContent
fmt.Print(chunk)
responseContent.WriteString(streamResp.Choices[0].Delta.Content)
}
}
}

if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading response stream: %w", err)
return "", fmt.Errorf("error reading response stream: %w", err)
}

return nil
// Combine reasoning content and response content if both exist
var fullResponse strings.Builder
if reasoningContent.Len() > 0 {
fullResponse.WriteString("\n## 🤔 Thinking\n\n")
fullResponse.WriteString(reasoningContent.String())
fullResponse.WriteString("\n\n---\n\n")
}
fullResponse.WriteString(responseContent.String())

return fullResponse.String(), nil
}

func (c *Client) Remove(models []string, force bool) (string, error) {
Expand Down
2 changes: 1 addition & 1 deletion desktop/desktop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestChatHuggingFaceModel(t *testing.T) {
Body: io.NopCloser(bytes.NewBufferString("data: {\"choices\":[{\"delta\":{\"content\":\"Hello there!\"}}]}\n")),
}, nil)

err := client.Chat("", modelName, prompt, "")
_, err := client.Chat("", modelName, prompt, "")
assert.NoError(t, err)
}

Expand Down
22 changes: 20 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.24
toolchain go1.24.4

require (
github.com/charmbracelet/glamour v0.10.0
github.com/containerd/errdefs v1.0.0
github.com/docker/cli v28.3.0+incompatible
github.com/docker/cli-docs-tool v0.10.0
Expand All @@ -13,7 +14,6 @@ require (
github.com/docker/go-units v0.5.0
github.com/docker/model-distribution v0.0.0-20250905083217-3f098b3d8058
github.com/docker/model-runner v0.0.0-20250911130340-38bb0171c947
github.com/fatih/color v1.15.0
github.com/google/go-containerregistry v0.20.6
github.com/mattn/go-isatty v0.0.20
github.com/nxadm/tail v1.4.8
Expand All @@ -25,13 +25,23 @@ require (
go.opentelemetry.io/otel v1.37.0
go.uber.org/mock v0.5.0
golang.org/x/sync v0.15.0
golang.org/x/term v0.32.0
)

require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/containerd/containerd/v2 v2.1.3 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
Expand All @@ -42,6 +52,7 @@ require (
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/elastic/go-sysinfo v1.15.3 // indirect
Expand All @@ -54,6 +65,7 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gpustack/gguf-parser-go v0.14.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
Expand All @@ -64,9 +76,10 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kolesnikovae/go-winjob v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
Expand All @@ -76,6 +89,8 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
Expand All @@ -90,6 +105,9 @@ require (
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect
github.com/theupdateframework/notary v0.7.1-0.20210315103452-bf96a202a09a // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.8 // indirect
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 // indirect
Expand Down
Loading
Loading