summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-13 22:22:38 +0200
committerPaul Buetow <paul@buetow.org>2026-03-13 22:22:38 +0200
commit8ae4be9684a58d44985e5b5ee5e90f74555b2dde (patch)
tree06029d949bdd549c835855d4b158e3234e277094
parent4d0f11822c1cdf4c51028bde8881756941314821 (diff)
release: v0.22.0v0.22.0
-rw-r--r--README.md1
-rw-r--r--cmd/hexai/main.go4
-rw-r--r--cmd/hexai/main_test.go19
-rw-r--r--docs/usage.md11
-rw-r--r--internal/hexaicli/run.go12
-rw-r--r--internal/hexaicli/simulation.go200
-rw-r--r--internal/hexaicli/simulation_test.go110
-rw-r--r--internal/version.go2
8 files changed, 357 insertions, 2 deletions
diff --git a/README.md b/README.md
index 75fb6e1..4dcc881 100644
--- a/README.md
+++ b/README.md
@@ -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"