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

Commit a224872

Browse files
authored
Merge pull request #153 from doringeman/render
feat(run/chat): add Markdown rendering
2 parents b6f5137 + f5b50e4 commit a224872

File tree

778 files changed

+164569
-44
lines changed

Some content is hidden

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

778 files changed

+164569
-44
lines changed

commands/run.go

Lines changed: 238 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ 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"
14+
"github.com/fatih/color"
1315
"github.com/spf13/cobra"
16+
"golang.org/x/term"
1417
)
1518

1619
// readMultilineInput reads input from stdin, supporting both single-line and multiline input.
@@ -78,15 +81,245 @@ func readMultilineInput(cmd *cobra.Command, scanner *bufio.Scanner) (string, err
7881
return multilineInput.String(), nil
7982
}
8083

84+
var (
85+
markdownRenderer *glamour.TermRenderer
86+
lastWidth int
87+
)
88+
89+
// StreamingMarkdownBuffer handles partial content and renders complete markdown blocks
90+
type StreamingMarkdownBuffer struct {
91+
buffer strings.Builder
92+
inCodeBlock bool
93+
codeBlockEnd string // tracks the closing fence (``` or ```)
94+
lastFlush int // position of last flush
95+
}
96+
97+
// NewStreamingMarkdownBuffer creates a new streaming markdown buffer
98+
func NewStreamingMarkdownBuffer() *StreamingMarkdownBuffer {
99+
return &StreamingMarkdownBuffer{}
100+
}
101+
102+
// AddContent adds new content to the buffer and returns any content that should be displayed
103+
func (smb *StreamingMarkdownBuffer) AddContent(content string, shouldUseMarkdown bool) (string, error) {
104+
smb.buffer.WriteString(content)
105+
106+
if !shouldUseMarkdown {
107+
// If not using markdown, just return the new content as-is
108+
result := content
109+
smb.lastFlush = smb.buffer.Len()
110+
return result, nil
111+
}
112+
113+
return smb.processPartialMarkdown()
114+
}
115+
116+
// processPartialMarkdown processes the buffer and returns content ready for display
117+
func (smb *StreamingMarkdownBuffer) processPartialMarkdown() (string, error) {
118+
fullText := smb.buffer.String()
119+
120+
// Look for code block start/end in the full text from our last position
121+
if !smb.inCodeBlock {
122+
// Check if we're entering a code block
123+
if idx := strings.Index(fullText[smb.lastFlush:], "```"); idx != -1 {
124+
// Found code block start
125+
beforeCodeBlock := fullText[smb.lastFlush : smb.lastFlush+idx]
126+
smb.inCodeBlock = true
127+
smb.codeBlockEnd = "```"
128+
129+
// Stream everything before the code block as plain text
130+
smb.lastFlush = smb.lastFlush + idx
131+
return beforeCodeBlock, nil
132+
}
133+
134+
// No code block found, stream all new content as plain text
135+
newContent := fullText[smb.lastFlush:]
136+
smb.lastFlush = smb.buffer.Len()
137+
return newContent, nil
138+
} else {
139+
// We're in a code block, look for the closing fence
140+
searchStart := smb.lastFlush
141+
if endIdx := strings.Index(fullText[searchStart:], smb.codeBlockEnd+"\n"); endIdx != -1 {
142+
// Found complete code block with newline after closing fence
143+
endPos := searchStart + endIdx + len(smb.codeBlockEnd) + 1
144+
codeBlockContent := fullText[smb.lastFlush:endPos]
145+
146+
// Render the complete code block
147+
rendered, err := renderMarkdown(codeBlockContent)
148+
if err != nil {
149+
// Fallback to plain text
150+
smb.lastFlush = endPos
151+
smb.inCodeBlock = false
152+
return codeBlockContent, nil
153+
}
154+
155+
smb.lastFlush = endPos
156+
smb.inCodeBlock = false
157+
return rendered, nil
158+
} else if endIdx := strings.Index(fullText[searchStart:], smb.codeBlockEnd); endIdx != -1 && searchStart+endIdx+len(smb.codeBlockEnd) == len(fullText) {
159+
// Found code block end at the very end of buffer (no trailing newline yet)
160+
endPos := searchStart + endIdx + len(smb.codeBlockEnd)
161+
codeBlockContent := fullText[smb.lastFlush:endPos]
162+
163+
// Render the complete code block
164+
rendered, err := renderMarkdown(codeBlockContent)
165+
if err != nil {
166+
// Fallback to plain text
167+
smb.lastFlush = endPos
168+
smb.inCodeBlock = false
169+
return codeBlockContent, nil
170+
}
171+
172+
smb.lastFlush = endPos
173+
smb.inCodeBlock = false
174+
return rendered, nil
175+
}
176+
177+
// Still in code block, don't output anything until it's complete
178+
return "", nil
179+
}
180+
}
181+
182+
// Flush renders and returns any remaining content in the buffer
183+
func (smb *StreamingMarkdownBuffer) Flush(shouldUseMarkdown bool) (string, error) {
184+
fullText := smb.buffer.String()
185+
remainingContent := fullText[smb.lastFlush:]
186+
187+
if remainingContent == "" {
188+
return "", nil
189+
}
190+
191+
if !shouldUseMarkdown {
192+
return remainingContent, nil
193+
}
194+
195+
rendered, err := renderMarkdown(remainingContent)
196+
if err != nil {
197+
return remainingContent, nil
198+
}
199+
200+
return rendered, nil
201+
}
202+
203+
// shouldUseMarkdown determines if Markdown rendering should be used based on color mode.
204+
func shouldUseMarkdown(colorMode string) bool {
205+
supportsColor := func() bool {
206+
return !color.NoColor
207+
}
208+
209+
switch colorMode {
210+
case "yes":
211+
return true
212+
case "no":
213+
return false
214+
case "auto":
215+
return supportsColor()
216+
default:
217+
return supportsColor()
218+
}
219+
}
220+
221+
// getTerminalWidth returns the terminal width, with a fallback to 80.
222+
func getTerminalWidth() int {
223+
width, _, err := term.GetSize(int(os.Stdout.Fd()))
224+
if err != nil {
225+
return 80
226+
}
227+
return width
228+
}
229+
230+
// getMarkdownRenderer returns a Markdown renderer, recreating it if terminal width changed.
231+
func getMarkdownRenderer() (*glamour.TermRenderer, error) {
232+
currentWidth := getTerminalWidth()
233+
234+
// Recreate if width changed or renderer doesn't exist.
235+
if markdownRenderer == nil || currentWidth != lastWidth {
236+
r, err := glamour.NewTermRenderer(
237+
glamour.WithAutoStyle(),
238+
glamour.WithWordWrap(currentWidth),
239+
)
240+
if err != nil {
241+
return nil, fmt.Errorf("failed to create markdown renderer: %w", err)
242+
}
243+
markdownRenderer = r
244+
lastWidth = currentWidth
245+
}
246+
247+
return markdownRenderer, nil
248+
}
249+
250+
func renderMarkdown(content string) (string, error) {
251+
r, err := getMarkdownRenderer()
252+
if err != nil {
253+
return "", fmt.Errorf("failed to create markdown renderer: %w", err)
254+
}
255+
256+
rendered, err := r.Render(content)
257+
if err != nil {
258+
return "", fmt.Errorf("failed to render markdown: %w", err)
259+
}
260+
261+
return rendered, nil
262+
}
263+
264+
// chatWithMarkdown performs chat and streams the response with selective markdown rendering.
265+
func chatWithMarkdown(cmd *cobra.Command, client *desktop.Client, backend, model, prompt, apiKey string) error {
266+
colorMode, _ := cmd.Flags().GetString("color")
267+
useMarkdown := shouldUseMarkdown(colorMode)
268+
debug, _ := cmd.Flags().GetBool("debug")
269+
270+
if !useMarkdown {
271+
// Simple case: just stream as plain text
272+
return client.Chat(backend, model, prompt, apiKey, func(content string) {
273+
cmd.Print(content)
274+
}, false)
275+
}
276+
277+
// For markdown: use streaming buffer to render code blocks as they complete
278+
markdownBuffer := NewStreamingMarkdownBuffer()
279+
280+
err := client.Chat(backend, model, prompt, apiKey, func(content string) {
281+
// Use the streaming markdown buffer to intelligently render content
282+
rendered, err := markdownBuffer.AddContent(content, true)
283+
if err != nil {
284+
if debug {
285+
cmd.PrintErrln(err)
286+
}
287+
// Fallback to plain text on error
288+
cmd.Print(content)
289+
} else if rendered != "" {
290+
cmd.Print(rendered)
291+
}
292+
}, true)
293+
if err != nil {
294+
return err
295+
}
296+
297+
// Flush any remaining content from the markdown buffer
298+
if remaining, flushErr := markdownBuffer.Flush(true); flushErr == nil && remaining != "" {
299+
cmd.Print(remaining)
300+
}
301+
302+
return nil
303+
}
304+
81305
func newRunCmd() *cobra.Command {
82306
var debug bool
83307
var backend string
84308
var ignoreRuntimeMemoryCheck bool
309+
var colorMode string
85310

86311
const cmdArgs = "MODEL [PROMPT]"
87312
c := &cobra.Command{
88313
Use: "run " + cmdArgs,
89314
Short: "Run a model and interact with it using a submitted prompt or chat mode",
315+
PreRunE: func(cmd *cobra.Command, args []string) error {
316+
switch colorMode {
317+
case "auto", "yes", "no":
318+
return nil
319+
default:
320+
return fmt.Errorf("--color must be one of: auto, yes, no (got %q)", colorMode)
321+
}
322+
},
90323
RunE: func(cmd *cobra.Command, args []string) error {
91324
// Validate backend if specified
92325
if backend != "" {
@@ -103,8 +336,8 @@ func newRunCmd() *cobra.Command {
103336

104337
model := args[0]
105338
prompt := ""
106-
args_len := len(args)
107-
if args_len > 1 {
339+
argsLen := len(args)
340+
if argsLen > 1 {
108341
prompt = strings.Join(args[1:], " ")
109342
}
110343

@@ -149,7 +382,7 @@ func newRunCmd() *cobra.Command {
149382
}
150383

151384
if prompt != "" {
152-
if err := desktopClient.Chat(backend, model, prompt, apiKey); err != nil {
385+
if err := chatWithMarkdown(cmd, desktopClient, backend, model, prompt, apiKey); err != nil {
153386
return handleClientError(err, "Failed to generate a response")
154387
}
155388
cmd.Println()
@@ -178,7 +411,7 @@ func newRunCmd() *cobra.Command {
178411
continue
179412
}
180413

181-
if err := desktopClient.Chat(backend, model, userInput, apiKey); err != nil {
414+
if err := chatWithMarkdown(cmd, desktopClient, backend, model, userInput, apiKey); err != nil {
182415
cmd.PrintErr(handleClientError(err, "Failed to generate a response"))
183416
continue
184417
}
@@ -205,6 +438,7 @@ func newRunCmd() *cobra.Command {
205438
c.Flags().StringVar(&backend, "backend", "", fmt.Sprintf("Specify the backend to use (%s)", ValidBackendsKeys()))
206439
c.Flags().MarkHidden("backend")
207440
c.Flags().BoolVar(&ignoreRuntimeMemoryCheck, "ignore-runtime-memory-check", false, "Do not block pull if estimated runtime memory for model exceeds system resources.")
441+
c.Flags().StringVar(&colorMode, "color", "auto", "Use colored output (auto|yes|no)")
208442

209443
return c
210444
}

desktop/api.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,9 @@ type OpenAIChatResponse struct {
4040
Index int `json:"index"`
4141
FinishReason string `json:"finish_reason"`
4242
} `json:"choices"`
43+
Usage *struct {
44+
CompletionTokens int `json:"completion_tokens"`
45+
PromptTokens int `json:"prompt_tokens"`
46+
TotalTokens int `json:"total_tokens"`
47+
} `json:"usage,omitempty"`
4348
}

0 commit comments

Comments
 (0)