diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-13 22:22:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-13 22:22:38 +0200 |
| commit | 8ae4be9684a58d44985e5b5ee5e90f74555b2dde (patch) | |
| tree | 06029d949bdd549c835855d4b158e3234e277094 | |
| parent | 4d0f11822c1cdf4c51028bde8881756941314821 (diff) | |
release: v0.22.0v0.22.0
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | cmd/hexai/main.go | 4 | ||||
| -rw-r--r-- | cmd/hexai/main_test.go | 19 | ||||
| -rw-r--r-- | docs/usage.md | 11 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 12 | ||||
| -rw-r--r-- | internal/hexaicli/simulation.go | 200 | ||||
| -rw-r--r-- | internal/hexaicli/simulation_test.go | 110 | ||||
| -rw-r--r-- | internal/version.go | 2 |
8 files changed, 357 insertions, 2 deletions
@@ -12,6 +12,7 @@ It has got improved capabilities for Go code understanding (for example, create * LSP AI Code actions * LSP in-editor chat with the LLM * Stand-alone command line tool for LLM interaction + - Includes `--tps-simulation` to preview how fast a model would feel by streaming placeholder text or piped stdin at a chosen token-per-second rate * Parallel completions and CLI responses from multiple providers/models for side-by-side comparison * **MCP server for prompt/runbook management** (`hexai-mcp-server`) - **⚠️ DEPRECATED/EXPERIMENTAL** - Create, update, delete, and retrieve prompts via MCP protocol diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go index de96bbf..874c850 100644 --- a/cmd/hexai/main.go +++ b/cmd/hexai/main.go @@ -27,6 +27,7 @@ func main() { fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError) defaultPath := appconfig.DefaultConfigPath() configFlag := fs.String("config", configPath, fmt.Sprintf("path to config file (default: %s)", defaultPath)) + tpsSimulation := fs.String("tps-simulation", "", "simulate stdout at a token-per-second rate; accepts '12' or '10-20'") showVersion := fs.Bool("version", false, "print version and exit") selectedFlags := make([]bool, len(cliEntries)) for i, entry := range cliEntries { @@ -61,6 +62,9 @@ func main() { if finalPath != "" { ctx = hexaicli.WithCLIConfigPath(ctx, finalPath) } + if strings.TrimSpace(*tpsSimulation) != "" { + ctx = hexaicli.WithCLITPSSimulation(ctx, *tpsSimulation) + } if len(selection) > 0 { ctx = hexaicli.WithCLISelection(ctx, selection) } diff --git a/cmd/hexai/main_test.go b/cmd/hexai/main_test.go index 797584f..531a11f 100644 --- a/cmd/hexai/main_test.go +++ b/cmd/hexai/main_test.go @@ -3,6 +3,7 @@ package main import ( "io" "os" + "strings" "testing" ) @@ -23,3 +24,21 @@ func TestMain_Version(t *testing.T) { t.Fatalf("expected version output") } } + +func TestMain_TPSSimulation(t *testing.T) { + oldArgs := os.Args + defer func() { os.Args = oldArgs }() + os.Args = []string{"hexai", "--tps-simulation=1000000", "simulated", "output"} + r, w, _ := os.Pipe() + old := os.Stdout + os.Stdout = w + defer func() { os.Stdout = old }() + main() + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe: %v", err) + } + b, _ := io.ReadAll(r) + if !strings.Contains(string(b), "simulated output") { + t.Fatalf("expected simulation output, got %q", string(b)) + } +} diff --git a/docs/usage.md b/docs/usage.md index f4f4850..e7b59a8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -81,8 +81,11 @@ Process text via the configured LLM: - `cat SOMEFILE.txt | hexai` - `hexai 'some prompt text here'` - `cat SOMEFILE.txt | hexai 'some prompt text here'` (stdin and arg are concatenated) +- `hexai --tps-simulation 12-18` to simulate model output speed without calling a provider -Defaults: concise answers. If the prompt asks for commands, Hexai outputs only commands. Add the word `explain` to request a verbose explanation. Exit codes: `0` success, `1` provider/config error, `2` no input. +Defaults: concise answers. If the prompt asks for commands, Hexai outputs only commands. Add the word `explain` to request a verbose explanation. Exit codes: `0` success, `1` provider/config error, `2` no input`. + +`--tps-simulation` accepts either a fixed rate such as `20` or a range such as `12-18`. It streams positional arguments, piped stdin, or built-in placeholder text when no input is provided, so you can preview perceived model latency without needing a real provider or local hardware. ### Examples @@ -101,6 +104,12 @@ hexai 'install ripgrep on macOS' # Verbose explanation hexai 'install ripgrep on macOS and explain' + +# Simulate 12-18 tokens per second with placeholder text +hexai --tps-simulation 12-18 + +# Simulate how a file would feel when streamed back by a model +cat SOMEFILE.txt | hexai --tps-simulation 20 ``` ## Hexai Action (TUI) diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 06ae08a..bc0341d 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -115,6 +115,18 @@ func canonicalProvider(name string) string { // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if spec, ok, err := tpsSimulationFromContext(ctx); err != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + } else if ok { + input, inputErr := readSimulationInput(stdin, args) + if inputErr != nil { + _, _ = fmt.Fprintln(stderr, logging.AnsiBase+inputErr.Error()+logging.AnsiReset) + return inputErr + } + return runTPSSimulation(ctx, spec, input, stdout) + } + // Load configuration with a logger so file-based config is respected. logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) configPath := configPathFromContext(ctx) diff --git a/internal/hexaicli/simulation.go b/internal/hexaicli/simulation.go new file mode 100644 index 0000000..fbd7da9 --- /dev/null +++ b/internal/hexaicli/simulation.go @@ -0,0 +1,200 @@ +package hexaicli + +import ( + "context" + "fmt" + "io" + "math" + "math/rand" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +type simulationContextKey struct{} + +type tpsSimulationSpec struct { + min float64 + max float64 +} + +const defaultSimulationText = "Hexai TPS simulation mode is emitting placeholder output so you can gauge how responsive a future model might feel on your hardware. Pipe a file into stdin to preview that exact text at the configured output speed instead." + +// WithCLITPSSimulation returns a context that carries the CLI TPS simulation range. +func WithCLITPSSimulation(ctx context.Context, value string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, simulationContextKey{}, strings.TrimSpace(value)) +} + +func tpsSimulationFromContext(ctx context.Context) (tpsSimulationSpec, bool, error) { + if ctx == nil { + return tpsSimulationSpec{}, false, nil + } + value, ok := ctx.Value(simulationContextKey{}).(string) + if !ok || strings.TrimSpace(value) == "" { + return tpsSimulationSpec{}, false, nil + } + spec, err := parseTPSSimulation(value) + if err != nil { + return tpsSimulationSpec{}, true, err + } + return spec, true, nil +} + +func parseTPSSimulation(raw string) (tpsSimulationSpec, error) { + value := strings.TrimSpace(raw) + if value == "" { + return tpsSimulationSpec{}, fmt.Errorf("hexai: --tps-simulation expects <tps> or <min>-<max>") + } + if strings.Count(value, "-") == 1 && !strings.HasPrefix(value, "-") { + return parseTPSSimulationRange(value, "-") + } + if strings.Count(value, ":") == 1 { + return parseTPSSimulationRange(value, ":") + } + tps, err := parsePositiveTPS(value) + if err != nil { + return tpsSimulationSpec{}, err + } + return tpsSimulationSpec{min: tps, max: tps}, nil +} + +func parseTPSSimulationRange(value string, sep string) (tpsSimulationSpec, error) { + left, right, ok := strings.Cut(value, sep) + if !ok { + return tpsSimulationSpec{}, fmt.Errorf("hexai: invalid --tps-simulation value %q", value) + } + minTPS, err := parsePositiveTPS(left) + if err != nil { + return tpsSimulationSpec{}, err + } + maxTPS, err := parsePositiveTPS(right) + if err != nil { + return tpsSimulationSpec{}, err + } + if minTPS > maxTPS { + return tpsSimulationSpec{}, fmt.Errorf("hexai: --tps-simulation minimum %.2f exceeds maximum %.2f", minTPS, maxTPS) + } + return tpsSimulationSpec{min: minTPS, max: maxTPS}, nil +} + +func parsePositiveTPS(raw string) (float64, error) { + value := strings.TrimSpace(raw) + tps, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("hexai: invalid --tps-simulation value %q", value) + } + if tps <= 0 { + return 0, fmt.Errorf("hexai: --tps-simulation requires a positive value, got %q", value) + } + return tps, nil +} + +func readSimulationInput(stdin io.Reader, args []string) (string, error) { + input, err := readInput(stdin, args) + if err == nil { + return input, nil + } + if strings.Contains(err.Error(), "no input provided") { + return defaultSimulationText, nil + } + return "", err +} + +func runTPSSimulation(ctx context.Context, spec tpsSimulationSpec, input string, out io.Writer) error { + chunks := splitSimulationChunks(input) + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + for i, chunk := range chunks { + if err := ctx.Err(); err != nil { + return err + } + if _, err := io.WriteString(out, chunk); err != nil { + return err + } + if i == len(chunks)-1 { + continue + } + if err := sleepWithContext(ctx, simulationDelay(spec, chunk, rng)); err != nil { + return err + } + } + return nil +} + +func simulationDelay(spec tpsSimulationSpec, chunk string, rng *rand.Rand) time.Duration { + tokens := estimateSimulationTokens(chunk) + if tokens == 0 { + return 0 + } + tps := spec.min + if spec.max > spec.min { + tps += rng.Float64() * (spec.max - spec.min) + } + seconds := float64(tokens) / tps + return time.Duration(seconds * float64(time.Second)) +} + +func splitSimulationChunks(input string) []string { + if input == "" { + return nil + } + chunks := make([]string, 0, strings.Count(input, " ")+1) + start := 0 + sawWord := false + for i, r := range input { + if unicode.IsSpace(r) { + if !sawWord { + continue + } + end := advanceWhitespace(input, i) + chunks = append(chunks, input[start:end]) + start = end + sawWord = false + continue + } + sawWord = true + } + if start < len(input) { + chunks = append(chunks, input[start:]) + } + return chunks +} + +func advanceWhitespace(input string, start int) int { + end := start + for end < len(input) { + r, size := utf8.DecodeRuneInString(input[end:]) + if !unicode.IsSpace(r) { + break + } + end += size + } + return end +} + +func estimateSimulationTokens(chunk string) int { + trimmed := strings.TrimSpace(chunk) + if trimmed == "" { + return 0 + } + runes := utf8.RuneCountInString(trimmed) + return max(1, int(math.Ceil(float64(runes)/4.0))) +} + +func sleepWithContext(ctx context.Context, delay time.Duration) error { + if delay <= 0 { + return ctx.Err() + } + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/hexaicli/simulation_test.go b/internal/hexaicli/simulation_test.go new file mode 100644 index 0000000..4c3533d --- /dev/null +++ b/internal/hexaicli/simulation_test.go @@ -0,0 +1,110 @@ +package hexaicli + +import ( + "bytes" + "context" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" +) + +func TestParseTPSSimulation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + wantMin float64 + wantMax float64 + wantErr bool + }{ + {name: "single value", value: "12", wantMin: 12, wantMax: 12}, + {name: "dash range", value: "8-16", wantMin: 8, wantMax: 16}, + {name: "colon range", value: "4:9", wantMin: 4, wantMax: 9}, + {name: "empty", value: "", wantErr: true}, + {name: "zero", value: "0", wantErr: true}, + {name: "reversed", value: "20-10", wantErr: true}, + {name: "invalid", value: "fast", wantErr: true}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + spec, err := parseTPSSimulation(tc.value) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q", tc.value) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.min != tc.wantMin || spec.max != tc.wantMax { + t.Fatalf("unexpected spec: %+v", spec) + } + }) + } +} + +func TestReadSimulationInput_DefaultsToSampleText(t *testing.T) { + restore, f := setStdin(t, "") + defer restore() + + input, err := readSimulationInput(f, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(input, "Hexai TPS simulation mode") { + t.Fatalf("expected default simulation text, got %q", input) + } +} + +func TestRun_TPSSimulationBypassesClientSetup(t *testing.T) { + oldNew := newClientFromApp + defer func() { newClientFromApp = oldNew }() + newClientFromApp = func(appconfig.App) (llm.Client, error) { + t.Fatalf("client setup should not be called in TPS simulation mode") + return nil, nil + } + + var out, errb bytes.Buffer + ctx := WithCLITPSSimulation(context.Background(), "1000000") + if err := Run(ctx, []string{"simulated", "output"}, strings.NewReader(""), &out, &errb); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out.String() != "simulated output" { + t.Fatalf("unexpected simulation output: %q", out.String()) + } + if errb.Len() != 0 { + t.Fatalf("expected empty stderr, got %q", errb.String()) + } +} + +func TestRun_TPSSimulationUsesStdin(t *testing.T) { + oldNew := newClientFromApp + defer func() { newClientFromApp = oldNew }() + newClientFromApp = func(appconfig.App) (llm.Client, error) { + t.Fatalf("client setup should not be called in TPS simulation mode") + return nil, nil + } + + restore, f := setStdin(t, "from-stdin") + defer restore() + + var out, errb bytes.Buffer + ctx := WithCLITPSSimulation(context.Background(), "1000000") + if err := Run(ctx, nil, f, &out, &errb); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out.String() != "from-stdin" { + t.Fatalf("unexpected simulation output: %q", out.String()) + } + if errb.Len() != 0 { + t.Fatalf("expected empty stderr, got %q", errb.String()) + } +} diff --git a/internal/version.go b/internal/version.go index 2474bdb..141af7d 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.20.2" +const Version = "0.22.0" |
