@@ -2,80 +2,46 @@ package commands
22
33import (
44 "bufio"
5+ "context"
56 "errors"
67 "fmt"
78 "os"
89 "strings"
910
11+ "github.com/charmbracelet/bubbles/textinput"
12+ tea "github.com/charmbracelet/bubbletea"
1013 "github.com/docker/model-cli/commands/completion"
1114 "github.com/docker/model-cli/desktop"
15+ "github.com/docker/model-cli/pkg/history"
1216 "github.com/spf13/cobra"
17+ "golang.design/x/clipboard"
18+ "golang.org/x/term"
1319)
1420
15- // readMultilineInput reads input from stdin, supporting both single-line and multiline input.
16- // For multiline input, it detects triple-quoted strings and shows continuation prompts.
17- func readMultilineInput (cmd * cobra.Command , scanner * bufio.Scanner ) (string , error ) {
18- cmd .Print ("> " )
21+ const (
22+ helpCommands = `Available Commands:
23+ /bye Exit
24+ /copy Copy the last response to the clipboard
25+ /?, /help Show this help
26+ /? shortcuts Help for keyboard shortcuts
1927
20- if ! scanner .Scan () {
21- if err := scanner .Err (); err != nil {
22- return "" , fmt .Errorf ("error reading input: %v" , err )
23- }
24- return "" , fmt .Errorf ("EOF" )
25- }
28+ Use """ to begin and end a multi-line message.`
2629
27- line := scanner .Text ()
28-
29- // Check if this is the start of a multiline input (triple quotes)
30- tripleQuoteStart := ""
31- if strings .HasPrefix (line , `"""` ) {
32- tripleQuoteStart = `"""`
33- } else if strings .HasPrefix (line , "'''" ) {
34- tripleQuoteStart = "'''"
35- }
36-
37- // If no triple quotes, return a single line
38- if tripleQuoteStart == "" {
39- return line , nil
40- }
41-
42- // Check if the triple quotes are closed on the same line
43- restOfLine := line [3 :]
44- if strings .HasSuffix (restOfLine , tripleQuoteStart ) && len (restOfLine ) >= 3 {
45- // Complete multiline string on single line
46- return line , nil
47- }
48-
49- // Start collecting multiline input
50- var multilineInput strings.Builder
51- multilineInput .WriteString (line )
52- multilineInput .WriteString ("\n " )
53-
54- // Continue reading lines until we find the closing triple quotes
55- for {
56- cmd .Print ("... " )
30+ helpShortcuts = `Available keyboard shortcuts:
31+ Ctrl + a Move to the beginning of the line (Home)
32+ Ctrl + e Move to the end of the line (End)
33+ Alt + b Move left
34+ Alt + f Move right
35+ Ctrl + k Delete the sentence after the cursor
36+ Ctrl + u Delete the sentence before the cursor
37+ Ctrl + w Delete the word before the cursor
38+ Ctrl + d Delete the character under the cursor`
5739
58- if ! scanner .Scan () {
59- if err := scanner .Err (); err != nil {
60- return "" , fmt .Errorf ("error reading input: %v" , err )
61- }
62- return "" , fmt .Errorf ("unclosed multiline input (EOF)" )
63- }
64-
65- line = scanner .Text ()
66- multilineInput .WriteString (line )
67-
68- // Check if this line contains the closing triple quotes
69- if strings .Contains (line , tripleQuoteStart ) {
70- // Found closing quotes, we're done
71- break
72- }
73-
74- multilineInput .WriteString ("\n " )
75- }
76-
77- return multilineInput .String (), nil
78- }
40+ helpUnknownCommand = "Unknown command..."
41+ helpNothingToCopy = "Nothing to copy..."
42+ helpCopied = "Done! Response copied to clipboard."
43+ placeholder = "Start chatting! (/bye to quit, /? for help)"
44+ )
7945
8046func newRunCmd () * cobra.Command {
8147 var debug bool
@@ -132,43 +98,81 @@ func newRunCmd() *cobra.Command {
13298 }
13399
134100 if prompt != "" {
135- if err := desktopClient .Chat (backend , model , prompt , apiKey ); err != nil {
101+ if _ , err := desktopClient .Chat (backend , model , prompt , apiKey ); err != nil {
136102 return handleClientError (err , "Failed to generate a response" )
137103 }
138104 cmd .Println ()
139105 return nil
140106 }
141107
142- scanner := bufio .NewScanner (os .Stdin )
143108 cmd .Println ("Interactive chat mode started. Type '/bye' to exit." )
144109
110+ h , err := history .New (dockerCLI )
111+ if err != nil {
112+ return fmt .Errorf ("unable to initialize history: %w" , err )
113+ }
114+
115+ var lastCommand string
116+ var lastResp []string
145117 for {
146- userInput , err := readMultilineInput (cmd , scanner )
147- if err != nil {
148- if err .Error () == "EOF" {
149- cmd .Println ("\n Chat session ended." )
150- break
151- }
152- return fmt .Errorf ("Error reading input: %v" , err )
118+ var promptPlaceholder string
119+ if lastCommand == "" {
120+ promptPlaceholder = placeholder
153121 }
122+ prompt := promptModel (h , promptPlaceholder )
154123
155- if strings . ToLower ( strings . TrimSpace ( userInput )) == "/bye" {
156- cmd . Println ( "Chat session ended." )
157- break
124+ p := tea . NewProgram ( & prompt )
125+ if _ , err := p . Run (); err != nil {
126+ return err
158127 }
159128
160- if strings .TrimSpace (userInput ) == "" {
161- continue
162- }
129+ question := prompt .Text ()
130+ switch {
131+ case question == "/bye" :
132+ return nil
163133
164- if err := desktopClient .Chat (backend , model , userInput , apiKey ); err != nil {
165- cmd .PrintErr (handleClientError (err , "Failed to generate a response" ))
134+ case strings .TrimSpace (question ) == "" :
166135 continue
167- }
168136
169- cmd .Println ()
137+ case question == "/help" || question == "/?" :
138+ printHelp (helpCommands )
139+
140+ case question == "/? shortcuts" :
141+ printHelp (helpShortcuts )
142+
143+ case question == "/copy" :
144+ if len (lastResp ) == 0 {
145+ printHelp (helpNothingToCopy )
146+ continue
147+ }
148+ if err := copyToClipboard (strings .Join (lastResp , "" )); err != nil {
149+ return err
150+ }
151+ printHelp (helpCopied )
152+
153+ case strings .HasPrefix (question , "/" ):
154+ printHelp (helpUnknownCommand )
155+
156+ case strings .HasPrefix (question , `"""` ):
157+ initialText := strings .TrimPrefix (question , `"""` )
158+ restOfText , err := readMultilineString (cmd .Context (), initialText )
159+ if err != nil {
160+ return err
161+ }
162+ question = restOfText
163+ fallthrough
164+
165+ default :
166+ lastResp , err = desktopClient .Chat (backend , model , question , apiKey )
167+ if err != nil {
168+ cmd .PrintErr (handleClientError (err , "Failed to generate a response" ))
169+ return nil
170+ }
171+ h .Append (question )
172+ lastCommand = question
173+ cmd .Println ()
174+ }
170175 }
171- return nil
172176 },
173177 ValidArgsFunction : completion .ModelNames (getDesktopClient , 1 ),
174178 }
@@ -193,3 +197,175 @@ func newRunCmd() *cobra.Command {
193197
194198 return c
195199}
200+
201+ func printHelp (status string ) {
202+ fmt .Print (status )
203+ fmt .Println ()
204+ fmt .Println ()
205+ }
206+
207+ type prompt struct {
208+ text textinput.Model
209+ history * history.History
210+ historyIndex int
211+ }
212+
213+ func promptModel (h * history.History , placeholder string ) prompt {
214+ width , _ , _ := term .GetSize (int (os .Stdout .Fd ()))
215+
216+ text := textinput .New ()
217+ text .Placeholder = placeholder
218+ text .Prompt = ">>> "
219+ text .Width = width - 5
220+ text .Cursor .Blink = false
221+ text .ShowSuggestions = true
222+ text .Focus ()
223+
224+ return prompt {
225+ text : text ,
226+ history : h ,
227+ historyIndex : 0 ,
228+ }
229+ }
230+
231+ func (p * prompt ) Init () tea.Cmd {
232+ return textinput .Blink
233+ }
234+
235+ func (p * prompt ) Finalize () {
236+ p .text .Blur ()
237+ p .text .Placeholder = ""
238+ p .text .SetSuggestions (nil )
239+ }
240+
241+ func (p * prompt ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
242+ switch msg := msg .(type ) {
243+ case tea.KeyMsg :
244+ switch msg .Type {
245+ case tea .KeyEnter :
246+ p .Finalize ()
247+ return p , tea .Quit
248+ case tea .KeyCtrlC , tea .KeyCtrlD :
249+ p .text .SetValue ("/bye" )
250+ p .Finalize ()
251+ return p , tea .Quit
252+ case tea .KeyEsc :
253+ p .text .SetValue ("" )
254+ p .Finalize ()
255+ return p , tea .Quit
256+ case tea .KeyUp :
257+ if p .history != nil {
258+ position := p .text .Position ()
259+ var text string
260+ p .historyIndex , text = p .history .Previous (p .text .Value (), position , p .historyIndex )
261+ p .text .SetValue (text )
262+ p .text .SetCursor (position )
263+ }
264+ case tea .KeyDown :
265+ if p .history != nil {
266+ position := p .text .Position ()
267+ var text string
268+ p .historyIndex , text = p .history .Next (p .text .Value (), position , p .historyIndex )
269+ p .text .SetValue (text )
270+ p .text .SetCursor (position )
271+ }
272+ }
273+ case tea.WindowSizeMsg :
274+ p .text .Width = msg .Width - 5
275+ return p , nil
276+ default :
277+ if current := p .text .Value (); current == "/" {
278+ p .text .SetSuggestions ([]string {"/help" , "/bye" , "/copy" , "/?" , "/? shortcuts" })
279+ } else if p .history != nil {
280+ p .text .SetSuggestions (p .history .Suggestions (current ))
281+ } else {
282+ p .text .SetSuggestions (nil )
283+ }
284+ }
285+
286+ var cmd tea.Cmd
287+ p .text , cmd = p .text .Update (msg )
288+ return p , cmd
289+ }
290+
291+ func (p * prompt ) Text () string {
292+ return p .text .Value ()
293+ }
294+
295+ func (p * prompt ) View () string {
296+ return p .text .View () + "\n "
297+ }
298+
299+ func copyToClipboard (command string ) error {
300+ err := clipboard .Init ()
301+ if err != nil {
302+ return nil
303+ }
304+
305+ clipboard .Write (clipboard .FmtText , []byte (command ))
306+ return nil
307+ }
308+
309+ // readMultilineString reads a multiline string from stdin, starting with the initial text if provided.
310+ // The initialText parameter is optional and can be used to provide a starting point for the input.
311+ // It reads from stdin until it encounters a closing triple quote (""").
312+ func readMultilineString (ctx context.Context , initialText string ) (string , error ) {
313+ var question string
314+
315+ // Start with the initial text if provided
316+ if initialText != "" {
317+ question = initialText + "\n "
318+ }
319+
320+ for {
321+ fmt .Print ("... " )
322+
323+ line , err := readLine (ctx )
324+ if err != nil {
325+ return "" , err
326+ }
327+
328+ question += line + "\n "
329+ if strings .TrimSpace (line ) == `"""` || strings .HasSuffix (strings .TrimSpace (line ), `"""` ) {
330+ break
331+ }
332+ }
333+
334+ // Find and remove the closing triple quotes
335+ content := question
336+ if idx := strings .LastIndex (content , `"""` ); idx >= 0 {
337+ before := content [:idx ]
338+ after := content [idx + 3 :]
339+ content = before + after
340+ }
341+
342+ return strings .TrimRight (content , "\n " ), nil
343+ }
344+
345+ // readLine reads a single line from stdin.
346+ func readLine (ctx context.Context ) (string , error ) {
347+ lines := make (chan string )
348+ errs := make (chan error )
349+
350+ go func () {
351+ defer close (lines )
352+ defer close (errs )
353+
354+ reader := bufio .NewReader (os .Stdin )
355+ line , err := reader .ReadString ('\n' )
356+ if err != nil {
357+ errs <- err
358+ } else {
359+ lines <- line
360+ }
361+ }()
362+
363+ select {
364+ case <- ctx .Done ():
365+ return "" , ctx .Err ()
366+ case err := <- errs :
367+ return "" , err
368+ case line := <- lines :
369+ return line , nil
370+ }
371+ }
0 commit comments