From b49bb4b5b1defe2f6d8bc36804d2c2025f6f0d6b Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 15 May 2025 20:50:30 +0300 Subject: [PATCH 1/5] Implement non-interactive mode from old PR by @radutopala --- .gitignore | 1 + README.md | 44 ++++++++++--- cmd/root.go | 110 +++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 3 + internal/format/format.go | 46 ++++++++++++++ internal/format/format_test.go | 90 +++++++++++++++++++++++++++ internal/format/spinner.go | 102 ++++++++++++++++++++++++++++++ 8 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 internal/format/format.go create mode 100644 internal/format/format_test.go create mode 100644 internal/format/spinner.go diff --git a/.gitignore b/.gitignore index 2603e630d2be..36ff9c73267b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ Thumbs.db .opencode/ +opencode diff --git a/README.md b/README.md index d7ae5e92868f..5e23aad41705 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,6 @@ You can configure OpenCode using environment variables: | `AZURE_OPENAI_ENDPOINT` | For Azure OpenAI models | | `AZURE_OPENAI_API_KEY` | For Azure OpenAI models (optional when using Entra ID) | | `AZURE_OPENAI_API_VERSION` | For Azure OpenAI models | - ### Configuration File Structure ```json @@ -189,7 +188,7 @@ OpenCode supports a variety of AI models from different providers: - O3 family (o3, o3-mini) - O4 Mini -## Usage +## Interactive Mode Usage ```bash # Start OpenCode @@ -202,13 +201,44 @@ opencode -d opencode -c /path/to/project ``` +## Non-interactive Prompt Mode + +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. + +```bash +# Run a single prompt and print the AI's response to the terminal +opencode -p "Explain the use of context in Go" + +# Get response in JSON format +opencode -p "Explain the use of context in Go" -f json + +# Run without showing the spinner +opencode -p "Explain the use of context in Go" -q +``` + +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. + +### Output Formats + +OpenCode supports the following output formats in non-interactive mode: + +| Format | Description | +| ------ | -------------------------------------- | +| `text` | Plain text output (default) | +| `json` | Output wrapped in a JSON object | + +The output format is implemented as a strongly-typed `OutputFormat` in the codebase, ensuring type safety and validation when processing outputs. + ## Command-line Flags -| Flag | Short | Description | -| --------- | ----- | ----------------------------- | -| `--help` | `-h` | Display help information | -| `--debug` | `-d` | Enable debug mode | -| `--cwd` | `-c` | Set current working directory | +| Flag | Short | Description | +| ----------------- | ----- | ------------------------------------------------------ | +| `--help` | `-h` | Display help information | +| `--debug` | `-d` | Enable debug mode | +| `--cwd` | `-c` | Set current working directory | +| `--prompt` | `-p` | Run a single prompt in non-interactive mode | +| `--output-format` | `-f` | Output format for non-interactive mode (text, json) | +| `--quiet` | `-q` | Hide spinner in non-interactive mode | ## Keyboard Shortcuts diff --git a/cmd/root.go b/cmd/root.go index 1e96e20c46ff..52d4cc12b43d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,9 +15,12 @@ import ( "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/config" "github.com/sst/opencode/internal/db" + "github.com/sst/opencode/internal/format" "github.com/sst/opencode/internal/llm/agent" "github.com/sst/opencode/internal/logging" "github.com/sst/opencode/internal/lsp/discovery" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/permission" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/tui" "github.com/sst/opencode/internal/version" @@ -88,6 +91,19 @@ to assist developers in writing, debugging, and understanding code directly from return err } + // Check if we're in non-interactive mode + prompt, _ := cmd.Flags().GetString("prompt") + if prompt != "" { + outputFormatStr, _ := cmd.Flags().GetString("output-format") + outputFormat := format.OutputFormat(outputFormatStr) + if !outputFormat.IsValid() { + return fmt.Errorf("invalid output format: %s", outputFormatStr) + } + + quiet, _ := cmd.Flags().GetBool("quiet") + return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet) + } + // Run LSP auto-discovery if err := discovery.IntegrateLSPServers(cwd); err != nil { slog.Warn("Failed to auto-discover LSP servers", "error", err) @@ -205,6 +221,97 @@ func initMCPTools(ctx context.Context, app *app.App) { }() } +// handleNonInteractiveMode processes a single prompt in non-interactive mode +func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool) error { + slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet) + + // Start spinner if not in quiet mode + var spinner *format.Spinner + if !quiet { + spinner = format.NewSpinner("Thinking...") + spinner.Start() + defer spinner.Stop() + } + + // Connect DB, this will also run migrations + conn, err := db.Connect() + if err != nil { + return err + } + + // Create a context with cancellation + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Create the app + app, err := app.New(ctx, conn) + if err != nil { + slog.Error("Failed to create app", "error", err) + return err + } + + // Auto-approve all permissions for non-interactive mode + permission.AutoApproveSession(ctx, "non-interactive") + + // Create a new session for this prompt + session, err := app.Sessions.Create(ctx, "Non-interactive prompt") + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + + // Set the session as current + app.CurrentSession = &session + + // Create the user message + _, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{ + Role: message.User, + Parts: []message.ContentPart{message.TextContent{Text: prompt}}, + }) + if err != nil { + return fmt.Errorf("failed to create message: %w", err) + } + + // Run the agent to get a response + eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt) + if err != nil { + return fmt.Errorf("failed to run agent: %w", err) + } + + // Wait for the response + var response message.Message + for event := range eventCh { + if event.Err() != nil { + return fmt.Errorf("agent error: %w", event.Err()) + } + response = event.Response() + } + + // Get the text content from the response + content := "" + if textContent := response.Content(); textContent != nil { + content = textContent.Text + } + + // Format the output according to the specified format + formattedOutput, err := format.FormatOutput(content, outputFormat) + if err != nil { + return fmt.Errorf("failed to format output: %w", err) + } + + // Stop spinner before printing output + if !quiet && spinner != nil { + spinner.Stop() + } + + // Print the formatted output to stdout + fmt.Println(formattedOutput) + + // Shutdown the app + app.Shutdown() + + return nil +} + func setupSubscriber[T any]( ctx context.Context, wg *sync.WaitGroup, @@ -296,4 +403,7 @@ func init() { rootCmd.Flags().BoolP("version", "v", false, "Version") rootCmd.Flags().BoolP("debug", "d", false, "Debug") rootCmd.Flags().StringP("cwd", "c", "", "Current working directory") + rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode") + rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)") + rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode") } diff --git a/go.mod b/go.mod index 6bc9b150a978..777ba525b91e 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 github.com/bmatcuk/doublestar/v4 v4.8.1 github.com/catppuccin/go v0.3.0 - github.com/charmbracelet/bubbles v0.20.0 + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.1 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index c6a79ab16172..ffadfcd11f19 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -84,6 +86,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/format/format.go b/internal/format/format.go new file mode 100644 index 000000000000..fe5f5e9c6a64 --- /dev/null +++ b/internal/format/format.go @@ -0,0 +1,46 @@ +package format + +import ( + "encoding/json" + "fmt" +) + +// OutputFormat represents the format for non-interactive mode output +type OutputFormat string + +const ( + // TextFormat is plain text output (default) + TextFormat OutputFormat = "text" + + // JSONFormat is output wrapped in a JSON object + JSONFormat OutputFormat = "json" +) + +// IsValid checks if the output format is valid +func (f OutputFormat) IsValid() bool { + return f == TextFormat || f == JSONFormat +} + +// String returns the string representation of the output format +func (f OutputFormat) String() string { + return string(f) +} + +// FormatOutput formats the given content according to the specified format +func FormatOutput(content string, format OutputFormat) (string, error) { + switch format { + case TextFormat: + return content, nil + case JSONFormat: + jsonData := map[string]string{ + "response": content, + } + jsonBytes, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(jsonBytes), nil + default: + return "", fmt.Errorf("unsupported output format: %s", format) + } +} \ No newline at end of file diff --git a/internal/format/format_test.go b/internal/format/format_test.go new file mode 100644 index 000000000000..75dd0a0c1bf7 --- /dev/null +++ b/internal/format/format_test.go @@ -0,0 +1,90 @@ +package format + +import ( + "testing" +) + +func TestOutputFormat_IsValid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format OutputFormat + want bool + }{ + { + name: "text format", + format: TextFormat, + want: true, + }, + { + name: "json format", + format: JSONFormat, + want: true, + }, + { + name: "invalid format", + format: "invalid", + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.format.IsValid(); got != tt.want { + t.Errorf("OutputFormat.IsValid() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFormatOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + format OutputFormat + want string + wantErr bool + }{ + { + name: "text format", + content: "test content", + format: TextFormat, + want: "test content", + wantErr: false, + }, + { + name: "json format", + content: "test content", + format: JSONFormat, + want: "{\n \"response\": \"test content\"\n}", + wantErr: false, + }, + { + name: "invalid format", + content: "test content", + format: "invalid", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := FormatOutput(tt.content, tt.format) + if (err != nil) != tt.wantErr { + t.Errorf("FormatOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("FormatOutput() = %v, want %v", got, tt.want) + } + }) + } +} \ No newline at end of file diff --git a/internal/format/spinner.go b/internal/format/spinner.go new file mode 100644 index 000000000000..8ee056870705 --- /dev/null +++ b/internal/format/spinner.go @@ -0,0 +1,102 @@ +package format + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// Spinner wraps the bubbles spinner for non-interactive mode +type Spinner struct { + model spinner.Model + done chan struct{} + prog *tea.Program + ctx context.Context + cancel context.CancelFunc +} + +// spinnerModel is the tea.Model for the spinner +type spinnerModel struct { + spinner spinner.Model + message string + quitting bool +} + +func (m spinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + m.quitting = true + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case quitMsg: + m.quitting = true + return m, tea.Quit + default: + return m, nil + } +} + +func (m spinnerModel) View() string { + if m.quitting { + return "" + } + return fmt.Sprintf("%s %s", m.spinner.View(), m.message) +} + +// quitMsg is sent when we want to quit the spinner +type quitMsg struct{} + +// NewSpinner creates a new spinner with the given message +func NewSpinner(message string) *Spinner { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = s.Style.Foreground(s.Style.GetForeground()) + + ctx, cancel := context.WithCancel(context.Background()) + + model := spinnerModel{ + spinner: s, + message: message, + } + + prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics()) + + return &Spinner{ + model: s, + done: make(chan struct{}), + prog: prog, + ctx: ctx, + cancel: cancel, + } +} + +// Start begins the spinner animation +func (s *Spinner) Start() { + go func() { + defer close(s.done) + go func() { + <-s.ctx.Done() + s.prog.Send(quitMsg{}) + }() + _, err := s.prog.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err) + } + }() +} + +// Stop ends the spinner animation +func (s *Spinner) Stop() { + s.cancel() + <-s.done +} \ No newline at end of file From 3a6fa6a08b5b785df3b93ab484c3926b2b2aad60 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 15 May 2025 20:54:19 +0300 Subject: [PATCH 2/5] remove --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 36ff9c73267b..7c9ca4582794 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,3 @@ Thumbs.db .env.local .opencode/ - -opencode From a151bece0d2d82089ef9512232e15a011134aec3 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 15 May 2025 20:55:00 +0300 Subject: [PATCH 3/5] fix --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7c9ca4582794..2603e630d2be 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ Thumbs.db .env.local .opencode/ + From 00cf453040444fee70884091157ce6a972c6a5a4 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Thu, 15 May 2025 20:58:49 +0300 Subject: [PATCH 4/5] fmt --- internal/format/format.go | 4 ++-- internal/format/format_test.go | 10 +++++----- internal/format/spinner.go | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/format/format.go b/internal/format/format.go index fe5f5e9c6a64..321f5c102662 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -11,7 +11,7 @@ type OutputFormat string const ( // TextFormat is plain text output (default) TextFormat OutputFormat = "text" - + // JSONFormat is output wrapped in a JSON object JSONFormat OutputFormat = "json" ) @@ -43,4 +43,4 @@ func FormatOutput(content string, format OutputFormat) (string, error) { default: return "", fmt.Errorf("unsupported output format: %s", format) } -} \ No newline at end of file +} diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 75dd0a0c1bf7..04054a7c4976 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -6,7 +6,7 @@ import ( func TestOutputFormat_IsValid(t *testing.T) { t.Parallel() - + tests := []struct { name string format OutputFormat @@ -28,7 +28,7 @@ func TestOutputFormat_IsValid(t *testing.T) { want: false, }, } - + for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { @@ -42,7 +42,7 @@ func TestOutputFormat_IsValid(t *testing.T) { func TestFormatOutput(t *testing.T) { t.Parallel() - + tests := []struct { name string content string @@ -72,7 +72,7 @@ func TestFormatOutput(t *testing.T) { wantErr: true, }, } - + for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { @@ -87,4 +87,4 @@ func TestFormatOutput(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 8ee056870705..083ee557f82e 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -63,14 +63,14 @@ func NewSpinner(message string) *Spinner { s.Style = s.Style.Foreground(s.Style.GetForeground()) ctx, cancel := context.WithCancel(context.Background()) - + model := spinnerModel{ spinner: s, message: message, } prog := tea.NewProgram(model, tea.WithOutput(os.Stderr), tea.WithoutCatchPanics()) - + return &Spinner{ model: s, done: make(chan struct{}), @@ -99,4 +99,4 @@ func (s *Spinner) Start() { func (s *Spinner) Stop() { s.cancel() <-s.done -} \ No newline at end of file +} From e68cf2b974cab3fb4f3af0c01f51625d7ace0682 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 16 May 2025 10:42:42 +0300 Subject: [PATCH 5/5] refactor: move spinner to tui components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved spinner from format package to tui/components/spinner - Updated imports and references in root.go - Added basic test for spinner component 🤖 Generated with opencode Co-Authored-By: opencode --- cmd/root.go | 13 +++++----- .../components/spinner}/spinner.go | 6 ++--- .../tui/components/spinner/spinner_test.go | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) rename internal/{format => tui/components/spinner}/spinner.go (95%) create mode 100644 internal/tui/components/spinner/spinner_test.go diff --git a/cmd/root.go b/cmd/root.go index 52d4cc12b43d..65da66e69f2f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "github.com/sst/opencode/internal/permission" "github.com/sst/opencode/internal/pubsub" "github.com/sst/opencode/internal/tui" + "github.com/sst/opencode/internal/tui/components/spinner" "github.com/sst/opencode/internal/version" ) @@ -226,11 +227,11 @@ func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat f slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet) // Start spinner if not in quiet mode - var spinner *format.Spinner + var s *spinner.Spinner if !quiet { - spinner = format.NewSpinner("Thinking...") - spinner.Start() - defer spinner.Stop() + s = spinner.NewSpinner("Thinking...") + s.Start() + defer s.Stop() } // Connect DB, this will also run migrations @@ -299,8 +300,8 @@ func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat f } // Stop spinner before printing output - if !quiet && spinner != nil { - spinner.Stop() + if !quiet && s != nil { + s.Stop() } // Print the formatted output to stdout diff --git a/internal/format/spinner.go b/internal/tui/components/spinner/spinner.go similarity index 95% rename from internal/format/spinner.go rename to internal/tui/components/spinner/spinner.go index 083ee557f82e..42b98810ad60 100644 --- a/internal/format/spinner.go +++ b/internal/tui/components/spinner/spinner.go @@ -1,4 +1,4 @@ -package format +package spinner import ( "context" @@ -9,7 +9,7 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// Spinner wraps the bubbles spinner for non-interactive mode +// Spinner wraps the bubbles spinner for both interactive and non-interactive mode type Spinner struct { model spinner.Model done chan struct{} @@ -99,4 +99,4 @@ func (s *Spinner) Start() { func (s *Spinner) Stop() { s.cancel() <-s.done -} +} \ No newline at end of file diff --git a/internal/tui/components/spinner/spinner_test.go b/internal/tui/components/spinner/spinner_test.go new file mode 100644 index 000000000000..065726e91608 --- /dev/null +++ b/internal/tui/components/spinner/spinner_test.go @@ -0,0 +1,24 @@ +package spinner + +import ( + "testing" + "time" +) + +func TestSpinner(t *testing.T) { + t.Parallel() + + // Create a spinner + s := NewSpinner("Test spinner") + + // Start the spinner + s.Start() + + // Wait a bit to let it run + time.Sleep(100 * time.Millisecond) + + // Stop the spinner + s.Stop() + + // If we got here without panicking, the test passes +} \ No newline at end of file