Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ https://github.com/user-attachments/assets/53324380-0ca7-4dfd-90d5-4f72a49cadc1
go run ./cmd/sparkle-cli --context "git log --oneline"
```

### Non-interactive mode

Use `direct` for scripts, pipes, and integrations that need a single final response on stdout.

```bash
sparkle-cli direct -m normal "Por que el cielo es azul" | less
sparkle-cli direct -m reasoning "/search de que color es el cielo"
```

`direct` supports `-m normal` and `-m reasoning`. It also accepts `thinking` as an alias for `reasoning`. In this mode, sparkle-cli resolves slash commands, waits for the full LLM response, strips visible reasoning, and prints only the final answer to stdout.

## End-User Documentation

Detailed feature documentation for end users is available in [USER_DOCS/](USER_DOCS/):
Expand Down Expand Up @@ -124,6 +135,8 @@ Key bindings inside the TUI:

`Chat` mode sends the previous user and assistant messages as conversation context on each request. `Reasoning` mode keeps the existing thinking prompt behavior without adding prior turns.

For automation, use `sparkle-cli direct -m normal|reasoning "..."`. Direct mode is non-interactive and does not expose `Chat` mode.

Supported editors for `editor` are `neovim` (default), `vim`, `vscode`/`visual studio code`, and `emacs`.

## Zsh Bridge
Expand Down
2 changes: 2 additions & 0 deletions USER_DOCS/06-interaction-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ sparkle-cli supports 3 interaction modes:

Switch mode with `Ctrl+T`.

For non-interactive automation, `sparkle-cli direct -m normal "..."` and `sparkle-cli direct -m reasoning "..."` reuse the same two single-turn modes. Direct mode does not support `Chat` and always prints only the final answer to stdout.

## Normal mode

- Standard request/response behavior.
Expand Down
168 changes: 167 additions & 1 deletion cmd/sparkle-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,63 @@
"flag"
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"

"github.com/logico/sparkle-cli/internal/config"
"github.com/logico/sparkle-cli/internal/profiler"
"github.com/logico/sparkle-cli/internal/tui"
)

func main() {
if len(os.Args) > 1 && strings.EqualFold(strings.TrimSpace(os.Args[1]), "stats") {
runStats(os.Args[2:])
return
}
if len(os.Args) > 1 {
switch strings.ToLower(strings.TrimSpace(os.Args[1])) {
case "direct", "run":
runDirect(os.Args[2:])
return
}
}

var configPath string
var initialContext string
var resultFile string
var profileEnabled bool

flag.StringVar(&configPath, "config", "", "override config file path")
flag.StringVar(&initialContext, "context", "", "seed the input with shell buffer content")
flag.StringVar(&resultFile, "result-file", "", "write accepted output to this file instead of stdout")
flag.BoolVar(&profileEnabled, "profile", false, "enable runtime profiling and metrics persistence")
flag.Parse()

cfg, loadedConfigPath, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(4)
}
if profileEnabled {
cfg.Profiler = true
}

tracker, err := profiler.New(loadedConfigPath, cfg.Profiler)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
defer func() { _ = tracker.Close() }()

output, exitCode, err := tui.Run(cfg, loadedConfigPath, initialContext)
output, exitCode, err := tui.Run(cfg, loadedConfigPath, initialContext, tracker)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
if tracker.Enabled() {
printCurrentRunSummary(os.Stderr, tracker, "search")
}

if exitCode == 0 && output != "" {
if err := emitOutput(output, resultFile); err != nil {
Expand All @@ -42,6 +72,142 @@
os.Exit(exitCode)
}

func runDirect(args []string) {
directFlags := flag.NewFlagSet("direct", flag.ContinueOnError)
directFlags.SetOutput(os.Stderr)

var configPath string
var mode string
var profileEnabled bool

directFlags.StringVar(&configPath, "config", "", "override config file path")
directFlags.StringVar(&mode, "m", "normal", "direct mode: normal or reasoning")
directFlags.BoolVar(&profileEnabled, "profile", false, "enable runtime profiling and metrics persistence")
if err := directFlags.Parse(args); err != nil {
os.Exit(2)
}

prompt := strings.TrimSpace(strings.Join(directFlags.Args(), " "))
if prompt == "" {
fmt.Fprintln(os.Stderr, "missing prompt for direct mode")
os.Exit(2)
}

cfg, loadedConfigPath, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(4)
}
if profileEnabled {
cfg.Profiler = true
}

tracker, err := profiler.New(loadedConfigPath, cfg.Profiler)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
defer func() { _ = tracker.Close() }()

output, err := tui.RunDirect(cfg, loadedConfigPath, prompt, mode, tracker)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}

if output != "" {
fmt.Print(output)
}
if tracker.Enabled() {
os.Exit(0)
}

os.Exit(0)
}

func runStats(args []string) {
statsFlags := flag.NewFlagSet("stats", flag.ContinueOnError)
statsFlags.SetOutput(os.Stderr)
var configPath string
var command string
var last int
statsFlags.StringVar(&configPath, "config", "", "override config file path")
statsFlags.StringVar(&command, "command", "search", "command to inspect")
statsFlags.IntVar(&last, "last", 10, "number of historical runs to compare")
if err := statsFlags.Parse(args); err != nil {
os.Exit(2)
}

_, loadedConfigPath, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(4)
}

tracker, err := profiler.New(loadedConfigPath, true)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
defer func() { _ = tracker.Close() }()

report, err := tracker.Comparison(strings.TrimSpace(command), last)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(3)
}
printComparisonReport(os.Stdout, report, last)
}

func printCurrentRunSummary(output *os.File, tracker profiler.Tracker, command string) {
rows := tracker.CurrentRun(command)
if len(rows) == 0 {
return
}
fmt.Fprintln(output, "\n=== Profiling Summary ===")

Check failure on line 167 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
fmt.Fprintf(output, "run_id: %s\n", tracker.RunID())

Check failure on line 168 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
fmt.Fprintf(output, "command: %s\n", command)

Check failure on line 169 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
totalDuration := int64(0)
for _, row := range rows {
totalDuration += row.DurationMS
fmt.Fprintf(output, "- %s: %dms", row.StepName, row.DurationMS)

Check failure on line 173 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
if row.TokensOut > 0 {
fmt.Fprintf(output, " | in=%d out=%d tps=%.2f", row.TokensIn, row.TokensOut, row.TPS)
}
fmt.Fprintln(output)

Check failure on line 177 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
}
fmt.Fprintf(output, "total: %dms\n", totalDuration)
}

func printComparisonReport(output *os.File, report profiler.ComparisonReport, last int) {
if len(report.Steps) == 0 {
fmt.Fprintln(output, "No profiling data found.")

Check failure on line 184 in cmd/sparkle-cli/main.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
return
}
sort.Slice(report.Steps, func(i, j int) bool {
return report.Steps[i].StepName < report.Steps[j].StepName
})
fmt.Fprintf(output, "Profiling stats for command=%s\n", report.Command)
fmt.Fprintf(output, "Current run: %s\n", report.CurrentRun)
fmt.Fprintf(output, "Compared against: last %d runs\n", last)
fmt.Fprintln(output, "")

tw := tabwriter.NewWriter(output, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "step\tcurrent_ms\tavg_ms(last)\tcurrent_out\tavg_out(last)\tcurrent_tps\tavg_tps(last)")
for _, step := range report.Steps {
fmt.Fprintf(tw, "%s\t%d\t%.1f\t%d\t%.1f\t%.2f\t%.2f\n",
step.StepName,
step.CurrentDuration,
step.DurationMS,
step.CurrentTokensOut,
step.TokensOut,
step.CurrentTPS,
step.TPS,
)
}
_ = tw.Flush()
}

func emitOutput(output, resultFile string) error {
if strings.TrimSpace(resultFile) == "" {
fmt.Print(output)
Expand Down
1 change: 1 addition & 0 deletions examples/config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ qdrant_score_threshold: 0.90
qdrant_ttl_hours: 48
qdrant_pool_size: 3
logs: false
profiler: false
editor: neovim
slash_commands_file: ./slash-commands.yaml
slash_commands_dir: ./slash-commands
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("qdrant_pool_size", defaultQdrantPoolSize)
v.SetDefault("theme", defaultTheme)
v.SetDefault("logs", false)
v.SetDefault("profiler", false)
v.SetDefault("editor", defaultEditor)

commands := make(map[string]map[string]string, len(defaultCommands))
Expand Down
1 change: 1 addition & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Config struct {
QdrantPoolSize int `mapstructure:"qdrant_pool_size"`
Theme string `mapstructure:"theme"`
Logs bool `mapstructure:"logs"`
Profiler bool `mapstructure:"profiler"`
Editor string `mapstructure:"editor"`
SlashCommandsFile string `mapstructure:"slash_commands_file"`
SlashCommandsDir string `mapstructure:"slash_commands_dir"`
Expand Down
17 changes: 11 additions & 6 deletions internal/ollama/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func (c *Client) StreamChatWithModel(ctx context.Context, model string, messages
}

func (c *Client) StreamChatWithModelWithThinking(ctx context.Context, model string, messages []ChatMessage, thinking bool, onChunk func(string) error) error {
_, err := c.StreamChatWithModelWithThinkingStats(ctx, model, messages, thinking, onChunk)
return err
}

func (c *Client) StreamChatWithModelWithThinkingStats(ctx context.Context, model string, messages []ChatMessage, thinking bool, onChunk func(string) error) (StreamStats, error) {
if strings.TrimSpace(model) == "" {
model = c.model
}
Expand All @@ -84,34 +89,34 @@ func (c *Client) StreamChatWithModelWithThinking(ctx context.Context, model stri
Stream: true,
})
if err != nil {
return err
return StreamStats{}, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create ollama request: %w", err)
return StreamStats{}, fmt.Errorf("create ollama request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request ollama: %w", err)
return StreamStats{}, fmt.Errorf("request ollama: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
payload, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return fmt.Errorf("ollama status %d", resp.StatusCode)
return StreamStats{}, fmt.Errorf("ollama status %d", resp.StatusCode)
}
message := strings.TrimSpace(string(payload))
if message == "" {
message = http.StatusText(resp.StatusCode)
}
return fmt.Errorf("ollama status %d: %s", resp.StatusCode, message)
return StreamStats{}, fmt.Errorf("ollama status %d: %s", resp.StatusCode, message)
}

return ParseStream(resp.Body, onChunk)
return ParseStreamWithStats(resp.Body, onChunk)
}

func marshalRequest(request chatRequest) ([]byte, error) {
Expand Down
26 changes: 17 additions & 9 deletions internal/ollama/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,52 +9,60 @@ import (
)

func ParseStream(reader io.Reader, onChunk func(string) error) error {
_, err := ParseStreamWithStats(reader, onChunk)
return err
}

func ParseStreamWithStats(reader io.Reader, onChunk func(string) error) (StreamStats, error) {
decoder := json.NewDecoder(reader)
thinkingOpen := false
stats := StreamStats{}
for {
var chunk chatChunk
if err := decoder.Decode(&chunk); err != nil {
if errors.Is(err, io.EOF) {
return nil
return stats, nil
}
return fmt.Errorf("decode ollama stream: %w", err)
return stats, fmt.Errorf("decode ollama stream: %w", err)
}

if strings.TrimSpace(chunk.Error) != "" {
return errors.New(chunk.Error)
return stats, errors.New(chunk.Error)
}

if chunk.Message.Thinking != "" {
if !thinkingOpen {
if err := onChunk("<|channel|>thought\n"); err != nil {
return err
return stats, err
}
thinkingOpen = true
}
if err := onChunk(chunk.Message.Thinking); err != nil {
return err
return stats, err
}
}

if chunk.Message.Content != "" {
if thinkingOpen {
if err := onChunk("<channel|>"); err != nil {
return err
return stats, err
}
thinkingOpen = false
}
if err := onChunk(chunk.Message.Content); err != nil {
return err
return stats, err
}
}

if chunk.Done {
stats.PromptEvalCount = chunk.PromptEvalCount
stats.EvalCount = chunk.EvalCount
if thinkingOpen {
if err := onChunk("<channel|>"); err != nil {
return err
return stats, err
}
}
return nil
return stats, nil
}
}
}
Loading
Loading