on main help sign in
feat: non-interactive mode
103f1c1Radu Topala
.gitignore+1
@@ -43,3 +43,4 @@ Thumbs.db
43
43
44.opencode/
44.opencode/
45
45
46+opencode
README.md+41−6
@@ -235,7 +235,7 @@ OpenCode supports a variety of AI models from different providers:
235
235
236- Gemini 2.5
236- Gemini 2.5
237- Gemini 2.5 Flash
237- Gemini 2.5 Flash
238−
238+
239## Usage
239## Usage
240
240
241```bash
241```bash
@@ -249,13 +249,46 @@ opencode -d
249opencode -c /path/to/project
249opencode -c /path/to/project
250```
250```
251
251
252+## Non-interactive Prompt Mode
253+
254+You can run OpenCode in non-interactive mode by passing a prompt directly as a command-line argument. This is useful for scripting, automation, or when you want a quick answer without launching the full TUI.
255+
256+```bash
257+# Run a single prompt and print the AI's response to the terminal
258+opencode -p "Explain the use of context in Go"
259+
260+# Get response in JSON format
261+opencode -p "Explain the use of context in Go" -f json
262+
263+# Run without showing the spinner (useful for scripts)
264+opencode -p "Explain the use of context in Go" -q
265+```
266+
267+In this mode, OpenCode will process your prompt, print the result to standard output, and then exit. All permissions are auto-approved for the session.
268+
269+By default, a spinner animation is displayed while the model is processing your query. You can disable this spinner with the `-q` or `--quiet` flag, which is particularly useful when running OpenCode from scripts or automated workflows.
270+
271+### Output Formats
272+
273+OpenCode supports the following output formats in non-interactive mode:
274+
275+| Format | Description |
276+| ------ | -------------------------------------- |
277+| `text` | Plain text output (default) |
278+| `json` | Output wrapped in a JSON object |
279+
280+The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs.
281+
252## Command-line Flags
282## Command-line Flags
253
283
254−| Flag | Short | Description |
284+| Flag | Short | Description |
255−| --------- | ----- | ----------------------------- |
285+| ----------------- | ----- | ------------------------------------------------------ |
256−| `--help` | `-h` | Display help information |
286+| `--help` | `-h` | Display help information |
257−| `--debug` | `-d` | Enable debug mode |
287+| `--debug` | `-d` | Enable debug mode |
258−| `--cwd` | `-c` | Set current working directory |
288+| `--cwd` | `-c` | Set current working directory |
289+| `--prompt` | `-p` | Run a single prompt in non-interactive mode |
290+| `--output-format` | `-f` | Output format for non-interactive mode (text, json) |
291+| `--quiet` | `-q` | Hide spinner in non-interactive mode |
259
292
260## Keyboard Shortcuts
293## Keyboard Shortcuts
261
294
@@ -390,6 +423,7 @@ Custom commands are predefined prompts stored as Markdown files in one of three
390 ```
423 ```
391
424
3922. **Project Commands** (prefixed with `project:`):
4252. **Project Commands** (prefixed with `project:`):
426+
393 ```
427 ```
394 <PROJECT DIR>/.opencode/commands/
428 <PROJECT DIR>/.opencode/commands/
395 ```
429 ```
@@ -420,6 +454,7 @@ RUN grep -R "$SEARCH_PATTERN" $DIRECTORY
420```
454```
421
455
422When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
456When you run a command with arguments, OpenCode will prompt you to enter values for each unique placeholder. Named arguments provide several benefits:
457+
423- Clear identification of what each argument represents
458- Clear identification of what each argument represents
424- Ability to use the same argument multiple times
459- Ability to use the same argument multiple times
425- Better organization for commands with multiple inputs
460- Better organization for commands with multiple inputs
cmd/root.go+56−5
@@ -12,6 +12,7 @@ import (
12 "github.com/opencode-ai/opencode/internal/app"
12 "github.com/opencode-ai/opencode/internal/app"
13 "github.com/opencode-ai/opencode/internal/config"
13 "github.com/opencode-ai/opencode/internal/config"
14 "github.com/opencode-ai/opencode/internal/db"
14 "github.com/opencode-ai/opencode/internal/db"
15+ "github.com/opencode-ai/opencode/internal/format"
15 "github.com/opencode-ai/opencode/internal/llm/agent"
16 "github.com/opencode-ai/opencode/internal/llm/agent"
16 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/pubsub"
18 "github.com/opencode-ai/opencode/internal/pubsub"
@@ -21,11 +22,30 @@ import (
21)
22)
22
23
23var rootCmd = &cobra.Command{
24var rootCmd = &cobra.Command{
24− Use: "OpenCode",
25+ Use: "opencode",
25− Short: "A terminal AI assistant for software development",
26+ Short: "Terminal-based AI assistant for software development",
26 Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
27 Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
27It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
28It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
28to assist developers in writing, debugging, and understanding code directly from the terminal.`,
29to assist developers in writing, debugging, and understanding code directly from the terminal.`,
30+ Example: `
31+ # Run in interactive mode
32+ opencode
33+
34+ # Run with debug logging
35+ opencode -d
36+
37+ # Run with debug logging in a specific directory
38+ opencode -d -c /path/to/project
39+
40+ # Print version
41+ opencode -v
42+
43+ # Run a single non-interactive prompt
44+ opencode -p "Explain the use of context in Go"
45+
46+ # Run a single non-interactive prompt with JSON output format
47+ opencode -p "Explain the use of context in Go" -f json
48+ `,
29 RunE: func(cmd *cobra.Command, args []string) error {
49 RunE: func(cmd *cobra.Command, args []string) error {
30 // If the help flag is set, show the help message
50 // If the help flag is set, show the help message
31 if cmd.Flag("help").Changed {
51 if cmd.Flag("help").Changed {
@@ -40,6 +60,15 @@ to assist developers in writing, debugging, and understanding code directly from
40 // Load the config
60 // Load the config
41 debug, _ := cmd.Flags().GetBool("debug")
61 debug, _ := cmd.Flags().GetBool("debug")
42 cwd, _ := cmd.Flags().GetString("cwd")
62 cwd, _ := cmd.Flags().GetString("cwd")
63+ prompt, _ := cmd.Flags().GetString("prompt")
64+ outputFormat, _ := cmd.Flags().GetString("output-format")
65+ quiet, _ := cmd.Flags().GetBool("quiet")
66+
67+ // Validate format option
68+ if !format.IsValid(outputFormat) {
69+ return fmt.Errorf("invalid format option: %s\n%s", outputFormat, format.GetHelpText())
70+ }
71+
43 if cwd != "" {
72 if cwd != "" {
44 err := os.Chdir(cwd)
73 err := os.Chdir(cwd)
45 if err != nil {
74 if err != nil {
@@ -73,7 +102,19 @@ to assist developers in writing, debugging, and understanding code directly from
73 logging.Error("Failed to create app: %v", err)
102 logging.Error("Failed to create app: %v", err)
74 return err
103 return err
75 }
104 }
105+ // Defer shutdown here so it runs for both interactive and non-interactive modes
106+ defer app.Shutdown()
76
107
108+ // Initialize MCP tools early for both modes
109+ initMCPTools(ctx, app)
110+
111+ // Non-interactive mode
112+ if prompt != "" {
113+ // Run non-interactive flow using the App method
114+ return app.RunNonInteractive(ctx, prompt, outputFormat, quiet)
115+ }
116+
117+ // Interactive mode
77 // Set up the TUI
118 // Set up the TUI
78 zone.NewGlobal()
119 zone.NewGlobal()
79 program := tea.NewProgram(
120 program := tea.NewProgram(
@@ -81,9 +122,6 @@ to assist developers in writing, debugging, and understanding code directly from
81 tea.WithAltScreen(),
122 tea.WithAltScreen(),
82 )
123 )
83
124
84− // Initialize MCP tools in the background
85− initMCPTools(ctx, app)
86−
87 // Setup the subscriptions, this will send services events to the TUI
125 // Setup the subscriptions, this will send services events to the TUI
88 ch, cancelSubs := setupSubscriptions(app, ctx)
126 ch, cancelSubs := setupSubscriptions(app, ctx)
89
127
@@ -255,4 +293,17 @@ func init() {
255 rootCmd.Flags().BoolP("version", "v", false, "Version")
293 rootCmd.Flags().BoolP("version", "v", false, "Version")
256 rootCmd.Flags().BoolP("debug", "d", false, "Debug")
294 rootCmd.Flags().BoolP("debug", "d", false, "Debug")
257 rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
295 rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
296+ rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
297+
298+ // Add format flag with validation logic
299+ rootCmd.Flags().StringP("output-format", "f", format.Text.String(),
300+ "Output format for non-interactive mode (text, json)")
301+
302+ // Add quiet flag to hide spinner in non-interactive mode
303+ rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
304+
305+ // Register custom validation for the format flag
306+ rootCmd.RegisterFlagCompletionFunc("output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
307+ return format.SupportedFormats, cobra.ShellCompDirectiveNoFileComp
308+ })
258}
309}
go.mod+2−2
@@ -11,8 +11,8 @@ require (
11 github.com/aymanbagabas/go-udiff v0.2.0
11 github.com/aymanbagabas/go-udiff v0.2.0
12 github.com/bmatcuk/doublestar/v4 v4.8.1
12 github.com/bmatcuk/doublestar/v4 v4.8.1
13 github.com/catppuccin/go v0.3.0
13 github.com/catppuccin/go v0.3.0
14− github.com/charmbracelet/bubbles v0.20.0
14+ github.com/charmbracelet/bubbles v0.21.0
15− github.com/charmbracelet/bubbletea v1.3.4
15+ github.com/charmbracelet/bubbletea v1.3.5
16 github.com/charmbracelet/glamour v0.9.1
16 github.com/charmbracelet/glamour v0.9.1
17 github.com/charmbracelet/lipgloss v1.1.0
17 github.com/charmbracelet/lipgloss v1.1.0
18 github.com/charmbracelet/x/ansi v0.8.0
18 github.com/charmbracelet/x/ansi v0.8.0
go.sum+6−6
@@ -68,10 +68,10 @@ github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
68github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
69github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
69github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
70github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
70github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
71−github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
71+github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
72−github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
72+github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
73−github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
73+github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
74−github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
74+github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
75github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
75github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
76github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
76github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
77github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
77github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
@@ -82,8 +82,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
82github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
82github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
83github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
83github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
84github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
84github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
85−github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
85+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
86−github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
86+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
87github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
87github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
88github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
88github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
89github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
89github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
internal/app/app.go+67
@@ -3,12 +3,15 @@ package app
3import (
3import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6+ "errors"
7+ "fmt"
6 "maps"
8 "maps"
7 "sync"
9 "sync"
8 "time"
10 "time"
9
11
10 "github.com/opencode-ai/opencode/internal/config"
12 "github.com/opencode-ai/opencode/internal/config"
11 "github.com/opencode-ai/opencode/internal/db"
13 "github.com/opencode-ai/opencode/internal/db"
14+ "github.com/opencode-ai/opencode/internal/format"
12 "github.com/opencode-ai/opencode/internal/history"
15 "github.com/opencode-ai/opencode/internal/history"
13 "github.com/opencode-ai/opencode/internal/llm/agent"
16 "github.com/opencode-ai/opencode/internal/llm/agent"
14 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/logging"
@@ -93,6 +96,70 @@ func (app *App) initTheme() {
93 }
96 }
94}
97}
95
98
99+// RunNonInteractive handles the execution flow when a prompt is provided via CLI flag.
100+func (a *App) RunNonInteractive(ctx context.Context, prompt string, outputFormat string, quiet bool) error {
101+ logging.Info("Running in non-interactive mode")
102+
103+ // Start spinner if not in quiet mode
104+ var spinner *format.Spinner
105+ if !quiet {
106+ spinner = format.NewSpinner("Thinking...")
107+ spinner.Start()
108+ defer spinner.Stop()
109+ }
110+
111+ const maxPromptLengthForTitle = 100
112+ titlePrefix := "Non-interactive: "
113+ var titleSuffix string
114+
115+ if len(prompt) > maxPromptLengthForTitle {
116+ titleSuffix = prompt[:maxPromptLengthForTitle] + "..."
117+ } else {
118+ titleSuffix = prompt
119+ }
120+ title := titlePrefix + titleSuffix
121+
122+ sess, err := a.Sessions.Create(ctx, title)
123+ if err != nil {
124+ return fmt.Errorf("failed to create session for non-interactive mode: %w", err)
125+ }
126+ logging.Info("Created session for non-interactive run", "session_id", sess.ID)
127+
128+ // Automatically approve all permission requests for this non-interactive session
129+ a.Permissions.AutoApproveSession(sess.ID)
130+
131+ done, err := a.CoderAgent.Run(ctx, sess.ID, prompt)
132+ if err != nil {
133+ return fmt.Errorf("failed to start agent processing stream: %w", err)
134+ }
135+
136+ result := <-done
137+ if result.Error != nil {
138+ if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
139+ logging.Info("Agent processing cancelled", "session_id", sess.ID)
140+ return nil
141+ }
142+ return fmt.Errorf("agent processing failed: %w", result.Error)
143+ }
144+
145+ // Stop spinner before printing output
146+ if !quiet && spinner != nil {
147+ spinner.Stop()
148+ }
149+
150+ // Get the text content from the response
151+ content := "No content available"
152+ if result.Message.Content().String() != "" {
153+ content = result.Message.Content().String()
154+ }
155+
156+ fmt.Println(format.FormatOutput(content, outputFormat))
157+
158+ logging.Info("Non-interactive run completed", "session_id", sess.ID)
159+
160+ return nil
161+}
162+
96// Shutdown performs a clean shutdown of the application
163// Shutdown performs a clean shutdown of the application
97func (app *App) Shutdown() {
164func (app *App) Shutdown() {
98 // Cancel all watcher goroutines
165 // Cancel all watcher goroutines
internal/format/format.gonew file+99
@@ -0,0 +1,99 @@
1+package format
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+)
8+
9+// OutputFormat represents the output format type for non-interactive mode
10+type OutputFormat string
11+
12+const (
13+ // Text format outputs the AI response as plain text.
14+ Text OutputFormat = "text"
15+
16+ // JSON format outputs the AI response wrapped in a JSON object.
17+ JSON OutputFormat = "json"
18+)
19+
20+// String returns the string representation of the OutputFormat
21+func (f OutputFormat) String() string {
22+ return string(f)
23+}
24+
25+// SupportedFormats is a list of all supported output formats as strings
26+var SupportedFormats = []string{
27+ string(Text),
28+ string(JSON),
29+}
30+
31+// Parse converts a string to an OutputFormat
32+func Parse(s string) (OutputFormat, error) {
33+ s = strings.ToLower(strings.TrimSpace(s))
34+
35+ switch s {
36+ case string(Text):
37+ return Text, nil
38+ case string(JSON):
39+ return JSON, nil
40+ default:
41+ return "", fmt.Errorf("invalid format: %s", s)
42+ }
43+}
44+
45+// IsValid checks if the provided format string is supported
46+func IsValid(s string) bool {
47+ _, err := Parse(s)
48+ return err == nil
49+}
50+
51+// GetHelpText returns a formatted string describing all supported formats
52+func GetHelpText() string {
53+ return fmt.Sprintf(`Supported output formats:
54+- %s: Plain text output (default)
55+- %s: Output wrapped in a JSON object`,
56+ Text, JSON)
57+}
58+
59+// FormatOutput formats the AI response according to the specified format
60+func FormatOutput(content string, formatStr string) string {
61+ format, err := Parse(formatStr)
62+ if err != nil {
63+ // Default to text format on error
64+ return content
65+ }
66+
67+ switch format {
68+ case JSON:
69+ return formatAsJSON(content)
70+ case Text:
71+ fallthrough
72+ default:
73+ return content
74+ }
75+}
76+
77+// formatAsJSON wraps the content in a simple JSON object
78+func formatAsJSON(content string) string {
79+ // Use the JSON package to properly escape the content
80+ response := struct {
81+ Response string `json:"response"`
82+ }{
83+ Response: content,
84+ }
85+
86+ jsonBytes, err := json.MarshalIndent(response, "", " ")
87+ if err != nil {
88+ // In case of an error, return a manually formatted JSON
89+ jsonEscaped := strings.Replace(content, "\\", "\\\\", -1)
90+ jsonEscaped = strings.Replace(jsonEscaped, "\"", "\\\"", -1)
91+ jsonEscaped = strings.Replace(jsonEscaped, "\n", "\\n", -1)
92+ jsonEscaped = strings.Replace(jsonEscaped, "\r", "\\r", -1)
93+ jsonEscaped = strings.Replace(jsonEscaped, "\t", "\\t", -1)
94+
95+ return fmt.Sprintf("{\n \"response\": \"%s\"\n}", jsonEscaped)
96+ }
97+
98+ return string(jsonBytes)
99+}
internal/format/spinner.gonew file+102
@@ -0,0 +1,102 @@
1+package format
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "github.com/charmbracelet/bubbles/spinner"
9+ tea "github.com/charmbracelet/bubbletea"
10+)
11+
12+// Spinner wraps the bubbles spinner for non-interactive mode
13+type Spinner struct {
14+ model spinner.Model
15+ done chan struct{}
16+ prog *tea.Program
17+ ctx context.Context
18+ cancel context.CancelFunc
19+}
20+
21+// spinnerModel is the tea.Model for the spinner
22+type spinnerModel struct {
23+ spinner spinner.Model
24+ message string
25+ quitting bool
26+}
27+
28+func (m spinnerModel) Init() tea.Cmd {
29+ return m.spinner.Tick
30+}
31+
32+func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33+ switch msg := msg.(type) {
34+ case tea.KeyMsg:
35+ m.quitting = true
36+ return m, tea.Quit
37+ case spinner.TickMsg:
38+ var cmd tea.Cmd
39+ m.spinner, cmd = m.spinner.Update(msg)
40+ return m, cmd
41+ case quitMsg:
42+ m.quitting = true
43+ return m, tea.Quit
44+ default:
45+ return m, nil
46+ }
47+}
48+
49+func (m spinnerModel) View() string {
50+ if m.quitting {
51+ return ""
52+ }
53+ return fmt.Sprintf("%s %s", m.spinner.View(), m.message)
54+}
55+
56+// quitMsg is sent when we want to quit the spinner
57+type quitMsg struct{}
58+
59+// NewSpinner creates a new spinner with the given message
60+func NewSpinner(message string) *Spinner {
61+ s := spinner.New()
62+ s.Spinner = spinner.Dot
63+ s.Style = s.Style.Foreground(s.Style.GetForeground())
64+
65+ ctx, cancel := context.WithCancel(context.Background())
66+
67+ model := spinnerModel{
68+ spinner: s,
69+ message: message,
70+ }
71+
72+ prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics())
73+
74+ return &Spinner{
75+ model: s,
76+ done: make(chan struct{}),
77+ prog: prog,
78+ ctx: ctx,
79+ cancel: cancel,
80+ }
81+}
82+
83+// Start begins the spinner animation
84+func (s *Spinner) Start() {
85+ go func() {
86+ defer close(s.done)
87+ go func() {
88+ <-s.ctx.Done()
89+ s.prog.Send(quitMsg{})
90+ }()
91+ _, err := s.prog.Run()
92+ if err != nil {
93+ fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
94+ }
95+ }()
96+}
97+
98+// Stop ends the spinner animation
99+func (s *Spinner) Stop() {
100+ s.cancel()
101+ <-s.done
102+}