@@ -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+
81305func 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}
0 commit comments