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

Commit 6487f12

Browse files
committed
feat: enhance chat interface with comprehensive UI improvements
- Use bubbletea to implement a readline-like interface - Support arrow keys as well as the common readline keybindings for search - Implement /copy command for copying the latest response - Maintain backward compatibility with triple-quote formatting Signed-off-by: Alberto Garcia Hierro <[email protected]>
1 parent 08a8afe commit 6487f12

File tree

368 files changed

+64350
-88
lines changed

Some content is hidden

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

368 files changed

+64350
-88
lines changed

commands/run.go

Lines changed: 257 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,80 +2,46 @@ package commands
22

33
import (
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

8046
func 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("\nChat 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

Comments
 (0)