Skip to content
This repository was archived by the owner on Oct 6, 2025. It is now read-only.

Commit bb501f9

Browse files
committed
feat(run/chat): add Markdown rendering
Signed-off-by: Dorin Geman <[email protected]>
1 parent f064505 commit bb501f9

File tree

783 files changed

+164228
-2249
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

783 files changed

+164228
-2249
lines changed

commands/run.go

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88
"os"
99
"strings"
1010

11+
"github.com/charmbracelet/glamour"
1112
"github.com/docker/model-cli/commands/completion"
1213
"github.com/docker/model-cli/desktop"
1314
"github.com/spf13/cobra"
15+
"golang.org/x/term"
1416
)
1517

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

83+
var (
84+
markdownRenderer *glamour.TermRenderer
85+
lastWidth int
86+
)
87+
88+
// getTerminalWidth returns the terminal width, with a fallback to 80.
89+
func getTerminalWidth() int {
90+
width, _, err := term.GetSize(int(os.Stdout.Fd()))
91+
if err != nil {
92+
return 80
93+
}
94+
return width
95+
}
96+
97+
// getMarkdownRenderer returns a markdown renderer, recreating it if terminal width changed.
98+
func getMarkdownRenderer() (*glamour.TermRenderer, error) {
99+
currentWidth := getTerminalWidth()
100+
101+
// Recreate if width changed or renderer doesn't exist.
102+
if markdownRenderer == nil || currentWidth != lastWidth {
103+
r, err := glamour.NewTermRenderer(
104+
glamour.WithAutoStyle(),
105+
glamour.WithWordWrap(currentWidth),
106+
)
107+
if err != nil {
108+
return nil, fmt.Errorf("failed to create markdown renderer: %w", err)
109+
}
110+
markdownRenderer = r
111+
lastWidth = currentWidth
112+
}
113+
114+
return markdownRenderer, nil
115+
}
116+
117+
func renderMarkdown(content string) (string, error) {
118+
r, err := getMarkdownRenderer()
119+
if err != nil {
120+
return "", fmt.Errorf("failed to create markdown renderer: %w", err)
121+
}
122+
123+
rendered, err := r.Render(content)
124+
if err != nil {
125+
return "", fmt.Errorf("failed to render markdown: %w", err)
126+
}
127+
128+
return rendered, nil
129+
}
130+
131+
// chatWithMarkdown performs chat and renders the response as Markdown.
132+
func chatWithMarkdown(cmd *cobra.Command, client *desktop.Client, backend, model, prompt, apiKey string) error {
133+
response, err := client.Chat(backend, model, prompt, apiKey)
134+
if err != nil {
135+
return err
136+
}
137+
138+
// Try to render as Markdown, fallback to plain text if it fails.
139+
rendered, err := renderMarkdown(response)
140+
if err != nil {
141+
if debug, _ := cmd.Flags().GetBool("debug"); debug {
142+
cmd.PrintErrln(err)
143+
}
144+
cmd.Print(response)
145+
return nil
146+
}
147+
148+
cmd.Print(rendered)
149+
return nil
150+
}
151+
81152
func newRunCmd() *cobra.Command {
82153
var debug bool
83154
var backend string
@@ -103,8 +174,8 @@ func newRunCmd() *cobra.Command {
103174

104175
model := args[0]
105176
prompt := ""
106-
args_len := len(args)
107-
if args_len > 1 {
177+
argsLen := len(args)
178+
if argsLen > 1 {
108179
prompt = strings.Join(args[1:], " ")
109180
}
110181

@@ -149,7 +220,7 @@ func newRunCmd() *cobra.Command {
149220
}
150221

151222
if prompt != "" {
152-
if err := desktopClient.Chat(backend, model, prompt, apiKey); err != nil {
223+
if err := chatWithMarkdown(cmd, desktopClient, backend, model, prompt, apiKey); err != nil {
153224
return handleClientError(err, "Failed to generate a response")
154225
}
155226
cmd.Println()
@@ -178,7 +249,7 @@ func newRunCmd() *cobra.Command {
178249
continue
179250
}
180251

181-
if err := desktopClient.Chat(backend, model, userInput, apiKey); err != nil {
252+
if err := chatWithMarkdown(cmd, desktopClient, backend, model, userInput, apiKey); err != nil {
182253
cmd.PrintErr(handleClientError(err, "Failed to generate a response"))
183254
continue
184255
}

desktop/desktop.go

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"github.com/docker/model-runner/pkg/inference"
2020
dmrm "github.com/docker/model-runner/pkg/inference/models"
2121
"github.com/docker/model-runner/pkg/inference/scheduling"
22-
"github.com/fatih/color"
2322
"github.com/pkg/errors"
2423
"go.opentelemetry.io/otel"
2524
)
@@ -364,15 +363,8 @@ func (c *Client) fullModelID(id string) (string, error) {
364363
return "", fmt.Errorf("model with ID %s not found", id)
365364
}
366365

367-
type chatPrinterState int
368-
369-
const (
370-
chatPrinterNone chatPrinterState = iota
371-
chatPrinterContent
372-
chatPrinterReasoning
373-
)
374-
375-
func (c *Client) Chat(backend, model, prompt, apiKey string) error {
366+
// Chat performs a chat request and returns the response content.
367+
func (c *Client) Chat(backend, model, prompt, apiKey string) (string, error) {
376368
model = normalizeHuggingFaceModelName(model)
377369
if !strings.Contains(strings.Trim(model, "/"), "/") {
378370
// Do an extra API call to check if the model parameter isn't a model ID.
@@ -394,7 +386,7 @@ func (c *Client) Chat(backend, model, prompt, apiKey string) error {
394386

395387
jsonData, err := json.Marshal(reqBody)
396388
if err != nil {
397-
return fmt.Errorf("error marshaling request: %w", err)
389+
return "", fmt.Errorf("error marshaling request: %w", err)
398390
}
399391

400392
var completionsPath string
@@ -412,17 +404,17 @@ func (c *Client) Chat(backend, model, prompt, apiKey string) error {
412404
apiKey,
413405
)
414406
if err != nil {
415-
return c.handleQueryError(err, completionsPath)
407+
return "", c.handleQueryError(err, completionsPath)
416408
}
417409
defer resp.Body.Close()
418410

419411
if resp.StatusCode != http.StatusOK {
420412
body, _ := io.ReadAll(resp.Body)
421-
return fmt.Errorf("error response: status=%d body=%s", resp.StatusCode, body)
413+
return "", fmt.Errorf("error response: status=%d body=%s", resp.StatusCode, body)
422414
}
423415

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

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

448440
if len(streamResp.Choices) > 0 {
449441
if streamResp.Choices[0].Delta.ReasoningContent != "" {
450-
chunk := streamResp.Choices[0].Delta.ReasoningContent
451-
if printerState == chatPrinterContent {
452-
fmt.Print("\n\n")
453-
}
454-
if printerState != chatPrinterReasoning {
455-
reasoningFmt.Println("Thinking:")
456-
}
457-
printerState = chatPrinterReasoning
458-
reasoningFmt.Print(chunk)
442+
reasoningContent.WriteString(streamResp.Choices[0].Delta.ReasoningContent)
459443
}
460444
if streamResp.Choices[0].Delta.Content != "" {
461-
chunk := streamResp.Choices[0].Delta.Content
462-
if printerState == chatPrinterReasoning {
463-
fmt.Print("\n\n")
464-
}
465-
printerState = chatPrinterContent
466-
fmt.Print(chunk)
445+
responseContent.WriteString(streamResp.Choices[0].Delta.Content)
467446
}
468447
}
469448
}
470449

471450
if err := scanner.Err(); err != nil {
472-
return fmt.Errorf("error reading response stream: %w", err)
451+
return "", fmt.Errorf("error reading response stream: %w", err)
473452
}
474453

475-
return nil
454+
// Combine reasoning content and response content if both exist
455+
var fullResponse strings.Builder
456+
if reasoningContent.Len() > 0 {
457+
fullResponse.WriteString("\n## 🤔 Thinking\n\n")
458+
fullResponse.WriteString(reasoningContent.String())
459+
fullResponse.WriteString("\n\n---\n\n")
460+
}
461+
fullResponse.WriteString(responseContent.String())
462+
463+
return fullResponse.String(), nil
476464
}
477465

478466
func (c *Client) Remove(models []string, force bool) (string, error) {

desktop/desktop_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestChatHuggingFaceModel(t *testing.T) {
6363
Body: io.NopCloser(bytes.NewBufferString("data: {\"choices\":[{\"delta\":{\"content\":\"Hello there!\"}}]}\n")),
6464
}, nil)
6565

66-
err := client.Chat("", modelName, prompt, "")
66+
_, err := client.Chat("", modelName, prompt, "")
6767
assert.NoError(t, err)
6868
}
6969

go.mod

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.24
55
toolchain go1.24.4
66

77
require (
8+
github.com/charmbracelet/glamour v0.10.0
89
github.com/containerd/errdefs v1.0.0
910
github.com/docker/cli v28.3.0+incompatible
1011
github.com/docker/cli-docs-tool v0.10.0
@@ -13,7 +14,6 @@ require (
1314
github.com/docker/go-units v0.5.0
1415
github.com/docker/model-distribution v0.0.0-20250905083217-3f098b3d8058
1516
github.com/docker/model-runner v0.0.0-20250911130340-38bb0171c947
16-
github.com/fatih/color v1.15.0
1717
github.com/google/go-containerregistry v0.20.6
1818
github.com/mattn/go-isatty v0.0.20
1919
github.com/nxadm/tail v1.4.8
@@ -25,13 +25,23 @@ require (
2525
go.opentelemetry.io/otel v1.37.0
2626
go.uber.org/mock v0.5.0
2727
golang.org/x/sync v0.15.0
28+
golang.org/x/term v0.32.0
2829
)
2930

3031
require (
3132
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
3233
github.com/Microsoft/go-winio v0.6.2 // indirect
3334
github.com/StackExchange/wmi v1.2.1 // indirect
35+
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
36+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
37+
github.com/aymerick/douceur v0.2.0 // indirect
3438
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
39+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
40+
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
41+
github.com/charmbracelet/x/ansi v0.8.0 // indirect
42+
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
43+
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
44+
github.com/charmbracelet/x/term v0.2.1 // indirect
3545
github.com/containerd/containerd/v2 v2.1.3 // indirect
3646
github.com/containerd/errdefs/pkg v0.3.0 // indirect
3747
github.com/containerd/log v0.1.0 // indirect
@@ -42,6 +52,7 @@ require (
4252
github.com/creack/pty v1.1.24 // indirect
4353
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
4454
github.com/distribution/reference v0.6.0 // indirect
55+
github.com/dlclark/regexp2 v1.11.0 // indirect
4556
github.com/docker/distribution v2.8.3+incompatible // indirect
4657
github.com/docker/docker-credential-helpers v0.9.3 // indirect
4758
github.com/elastic/go-sysinfo v1.15.3 // indirect
@@ -54,6 +65,7 @@ require (
5465
github.com/go-ole/go-ole v1.3.0 // indirect
5566
github.com/gogo/protobuf v1.3.2 // indirect
5667
github.com/google/uuid v1.6.0 // indirect
68+
github.com/gorilla/css v1.0.1 // indirect
5769
github.com/gorilla/mux v1.8.1 // indirect
5870
github.com/gpustack/gguf-parser-go v0.14.1 // indirect
5971
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
@@ -64,9 +76,10 @@ require (
6476
github.com/json-iterator/go v1.1.12 // indirect
6577
github.com/klauspost/compress v1.18.0 // indirect
6678
github.com/kolesnikovae/go-winjob v1.0.0 // indirect
67-
github.com/mattn/go-colorable v0.1.13 // indirect
79+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
6880
github.com/mattn/go-runewidth v0.0.16 // indirect
6981
github.com/mattn/go-shellwords v1.0.12 // indirect
82+
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
7083
github.com/mitchellh/go-homedir v1.1.0 // indirect
7184
github.com/moby/docker-image-spec v1.3.1 // indirect
7285
github.com/moby/locker v1.0.1 // indirect
@@ -76,6 +89,8 @@ require (
7689
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
7790
github.com/modern-go/reflect2 v1.0.2 // indirect
7891
github.com/morikuni/aec v1.0.0 // indirect
92+
github.com/muesli/reflow v0.3.0 // indirect
93+
github.com/muesli/termenv v0.16.0 // indirect
7994
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
8095
github.com/opencontainers/go-digest v1.0.0 // indirect
8196
github.com/opencontainers/image-spec v1.1.1 // indirect
@@ -90,6 +105,9 @@ require (
90105
github.com/smallnest/ringbuffer v0.0.0-20241116012123-461381446e3d // indirect
91106
github.com/theupdateframework/notary v0.7.1-0.20210315103452-bf96a202a09a // indirect
92107
github.com/vbatts/tar-split v0.12.1 // indirect
108+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
109+
github.com/yuin/goldmark v1.7.8 // indirect
110+
github.com/yuin/goldmark-emoji v1.0.5 // indirect
93111
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
94112
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
95113
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.34.0 // indirect

0 commit comments

Comments
 (0)