diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-07 11:26:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-07 11:26:10 +0300 |
| commit | 8889949ad3851bfbf36ff5b73128286d67c88201 (patch) | |
| tree | 0f515ae6ee3da898dea113799c09e943f3e3f8fb /docs/coverage.html | |
| parent | 7c0266e94378f6121719939c6d53915eb72eed3e (diff) | |
tiding up
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 513 |
1 files changed, 412 insertions, 101 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index 6b80630..2003a0d 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,19 +59,19 @@ <option value="file1">codeberg.org/snonux/hexai/cmd/hexai/main.go (71.4%)</option> - <option value="file2">codeberg.org/snonux/hexai/cmd/internal/hexai-action/main.go (0.0%)</option> + <option value="file2">codeberg.org/snonux/hexai/cmd/internal/hexai-action/main.go (69.3%)</option> <option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (91.6%)</option> <option value="file4">codeberg.org/snonux/hexai/internal/hexaiaction/parse.go (92.6%)</option> - <option value="file5">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (81.1%)</option> + <option value="file5">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (91.9%)</option> - <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (33.3%)</option> + <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (48.7%)</option> - <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (47.3%)</option> + <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option> - <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (91.7%)</option> + <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/tui_delegate.go (100.0%)</option> <option value="file9">codeberg.org/snonux/hexai/internal/hexaicli/run.go (78.8%)</option> @@ -119,6 +119,8 @@ <option value="file31">codeberg.org/snonux/hexai/internal/textutil/textutil.go (89.0%)</option> + <option value="file32">codeberg.org/snonux/hexai/internal/tmux/tmux.go (88.6%)</option> + </select> </div> <div id="legend"> @@ -198,18 +200,240 @@ func main() <span class="cov8" title="1">{ import ( "context" + "flag" "fmt" + "io" "os" + "path/filepath" + "time" "codeberg.org/snonux/hexai/internal/hexaiaction" + "codeberg.org/snonux/hexai/internal/tmux" + "golang.org/x/term" ) func main() <span class="cov0" title="0">{ - if err := hexaiaction.Run(context.Background(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + infile := flag.String("infile", "", "Read input from this file instead of stdin") + outfile := flag.String("outfile", "", "Write output to this file instead of stdout") + // Tmux/UI flags + forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)") + noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available") + uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically") + tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)") + tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h") + tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)") + flag.Parse() + + // Child mode: run TUI and write atomically to -outfile + if *uiChild </span><span class="cov0" title="0">{ + if err := runChild(*infile, *outfile); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> + <span class="cov0" title="0">return</span> + } + + // Parent mode: decide inline vs tmux + <span class="cov0" title="0">if shouldRunInTmux(*forceTmux, *noTmux) </span><span class="cov0" title="0">{ + if err := runInTmuxParent(*tmuxTarget, *tmuxSplit, *tmuxPercent); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> + <span class="cov0" title="0">return</span> + } + + // Inline path: only if we have a TTY for UI; otherwise echo input + <span class="cov0" title="0">if isTTY(os.Stdout.Fd()) && isTTY(os.Stdin.Fd()) </span><span class="cov0" title="0">{ + in, out, closeIn, closeOut, err := openIO(*infile, *outfile) + if err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> + <span class="cov0" title="0">defer closeIn() + defer closeOut() + if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + }</span> + <span class="cov0" title="0">return</span> + } + + // Fallback: no TTY and tmux not available; echo input to output + <span class="cov0" title="0">if err := echoThrough(*infile, *outfile); err != nil </span><span class="cov0" title="0">{ fmt.Fprintln(os.Stderr, err) os.Exit(1) }</span> } + +// openIO returns readers/writers for infile/outfile flags with deferred closers. +func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) <span class="cov10" title="6">{ + in := io.Reader(os.Stdin) + out := io.Writer(os.Stdout) + closeIn := func() </span>{<span class="cov0" title="0">}</span> + <span class="cov10" title="6">closeOut := func() </span>{<span class="cov1" title="1">}</span> + + <span class="cov10" title="6">if path := infile; path != "" </span><span class="cov9" title="5">{ + f, err := os.Open(path) + if err != nil </span><span class="cov1" title="1">{ + return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-action: cannot open infile: %w", err) + } + <span class="cov7" title="4">in = f + closeIn = func() </span><span class="cov7" title="4">{ _ = f.Close() }</span> + } + <span class="cov9" title="5">if path := outfile; path != "" </span><span class="cov7" title="4">{ + f, err := os.Create(path) + if err != nil </span><span class="cov1" title="1">{ + return nil, nil, func() </span>{<span class="cov0" title="0">}</span>, func() {<span class="cov0" title="0">}</span>, fmt.Errorf("hexai-action: cannot open outfile: %w", err) + } + <span class="cov6" title="3">out = f + closeOut = func() </span><span class="cov6" title="3">{ _ = f.Close() }</span> + } + <span class="cov7" title="4">return in, out, closeIn, closeOut, nil</span> +} + +// runChild runs the interactive flow and writes the final output atomically to outfile. +var hexaiactionRun = hexaiaction.Run + +func runChild(infile, outfile string) error <span class="cov6" title="3">{ + if outfile == "" </span><span class="cov1" title="1">{ + // No atomic handoff needed; just run normally to stdout + in, out, closeIn, closeOut, err := openIO(infile, "") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov1" title="1">defer closeIn() + defer closeOut() + return hexaiactionRun(context.Background(), in, out, os.Stderr)</span> + } + <span class="cov4" title="2">tmp := outfile + ".tmp" + in, out, closeIn, closeOut, err := openIO(infile, tmp) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="2">defer closeIn() + if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil </span><span class="cov1" title="1">{ + // On error, try to echo input to tmp to avoid blocking + closeOut() + if copyErr := echoThrough(infile, tmp); copyErr != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr) + }</span> + } else<span class="cov1" title="1"> { + closeOut() + }</span> + <span class="cov4" title="2">return os.Rename(tmp, outfile)</span> +} + +var isTTYFn = isTTY +var tmuxAvailableFn = tmux.Available +var splitRunFn = tmux.SplitRun +var osExecutableFn = os.Executable + +func shouldRunInTmux(forceTmux, noTmux bool) bool <span class="cov9" title="5">{ + if noTmux </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov7" title="4">if forceTmux </span><span class="cov1" title="1">{ + return true + }</span> + // Auto: prefer tmux when stdio are not TTYs (Helix :pipe scenario) + <span class="cov6" title="3">if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() </span><span class="cov1" title="1">{ + return true + }</span> + <span class="cov4" title="2">return false</span> +} + +func isTTY(fd uintptr) bool <span class="cov0" title="0">{ return term.IsTerminal(int(fd)) }</span> + +func runInTmuxParent(target, split string, percent int) error <span class="cov6" title="3">{ + // Prepare temp files + dir, err := os.MkdirTemp("", "hexai-action-") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov6" title="3">defer func() </span><span class="cov6" title="3">{ _ = os.RemoveAll(dir) }</span>() + <span class="cov6" title="3">inPath := filepath.Join(dir, "input.txt") + outPath := filepath.Join(dir, "reply.txt") + // Read stdin and persist to inPath + if err := persistStdin(inPath); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + // Build child argv + <span class="cov6" title="3">exe, err := osExecutableFn() + if err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov4" title="2">argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath} + // Spawn tmux split + opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent} + if err := splitRunFn(opts, argv); err != nil </span><span class="cov1" title="1">{ + return err + }</span> + // Wait for outfile to appear + <span class="cov1" title="1">if err := waitForFile(outPath, 60*time.Second); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + // Print to stdout + <span class="cov1" title="1">return catFileToStdout(outPath)</span> +} + +func persistStdin(path string) error <span class="cov9" title="5">{ + f, err := os.Create(path) + if err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov7" title="4">defer func() </span><span class="cov7" title="4">{ _ = f.Close() }</span>() + <span class="cov7" title="4">if _, err := io.Copy(f, os.Stdin); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov7" title="4">return f.Sync()</span> +} + +func waitForFile(path string, timeout time.Duration) error <span class="cov6" title="3">{ + deadline := time.Now().Add(timeout) + for </span><span class="cov9" title="5">{ + if _, err := os.Stat(path); err == nil </span><span class="cov4" title="2">{ + return nil + }</span> + <span class="cov6" title="3">if time.Now().After(deadline) </span><span class="cov1" title="1">{ + return fmt.Errorf("hexai-action: timeout waiting for reply file") + }</span> + <span class="cov4" title="2">time.Sleep(200 * time.Millisecond)</span> + } +} + +func catFileToStdout(path string) error <span class="cov6" title="3">{ + f, err := os.Open(path) + if err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov4" title="2">defer func() </span><span class="cov4" title="2">{ _ = f.Close() }</span>() + <span class="cov4" title="2">_, err = io.Copy(os.Stdout, f) + return err</span> +} + +func echoThrough(infile, outfile string) error <span class="cov7" title="4">{ + // Read from infile or stdin and write to outfile or stdout + var in io.Reader = os.Stdin + var out io.Writer = os.Stdout + if infile != "" </span><span class="cov6" title="3">{ + f, err := os.Open(infile) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov6" title="3">defer func() </span><span class="cov6" title="3">{ _ = f.Close() }</span>() + <span class="cov6" title="3">in = f</span> + } + <span class="cov7" title="4">if outfile != "" </span><span class="cov6" title="3">{ + f, err := os.Create(outfile) + if err != nil </span><span class="cov1" title="1">{ + return err + }</span> + <span class="cov4" title="2">defer func() </span><span class="cov4" title="2">{ _ = f.Close() }</span>() + <span class="cov4" title="2">out = f</span> + } + <span class="cov6" title="3">_, err := io.Copy(out, in) + return err</span> +} </pre> <pre class="file" id="file3" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.toml and merges defaults. @@ -300,7 +524,7 @@ type App struct { } // Constructor: defaults for App (kept first among functions) -func newDefaultConfig() App <span class="cov5" title="13">{ +func newDefaultConfig() App <span class="cov5" title="14">{ // Coding-friendly default temperature across providers // Users can override per provider in config.toml (including 0.0). t := 0.2 @@ -351,17 +575,17 @@ func newDefaultConfig() App <span class="cov5" title="13">{ // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. -func Load(logger *log.Logger) App <span class="cov5" title="12">{ +func Load(logger *log.Logger) App <span class="cov5" title="13">{ cfg := newDefaultConfig() if logger == nil </span><span class="cov3" title="4">{ return cfg // Return defaults if no logger is provided (e.g. in tests) }</span> - <span class="cov4" title="8">configPath, err := getConfigPath() + <span class="cov4" title="9">configPath, err := getConfigPath() if err != nil </span><span class="cov0" title="0">{ logger.Printf("%v", err) // Even if config path cannot be resolved, still allow env overrides below. - }</span> else<span class="cov4" title="8"> { + }</span> else<span class="cov4" title="9"> { if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov3" title="4">{ cfg.mergeWith(fileCfg) }</span> @@ -370,10 +594,10 @@ func Load(logger *log.Logger) App <span class="cov5" title="12">{ } // Environment overrides (take precedence over file) - <span class="cov4" title="8">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{ cfg.mergeWith(envCfg) }</span> - <span class="cov4" title="8">return cfg</span> + <span class="cov4" title="9">return cfg</span> } // Private helpers @@ -644,13 +868,13 @@ func (fc *fileConfig) toApp() App <span class="cov3" title="4">{ <span class="cov3" title="4">return out</span> } -func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov4" title="9">{ +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov4" title="10">{ b, err := os.ReadFile(path) - if err != nil </span><span class="cov2" title="3">{ + if err != nil </span><span class="cov3" title="4">{ if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ logger.Printf("cannot open TOML config file %s: %v", path, err) }</span> - <span class="cov2" title="3">return nil, err</span> + <span class="cov3" title="4">return nil, err</span> } <span class="cov4" title="6">var tables fileConfig @@ -868,33 +1092,33 @@ func (a *App) mergeProviderFields(other *App) <span class="cov5" title="14">{ }</span> } -func getConfigPath() (string, error) <span class="cov4" title="9">{ +func getConfigPath() (string, error) <span class="cov4" title="10">{ var configPath string if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov4" title="7">{ configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") - }</span> else<span class="cov2" title="2"> { + }</span> else<span class="cov2" title="3"> { home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot find user home directory: %v", err) }</span> - <span class="cov2" title="2">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> + <span class="cov2" title="3">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> } - <span class="cov4" title="9">return configPath, nil</span> + <span class="cov4" title="10">return configPath, nil</span> } // --- Environment overrides --- // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. -func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="8">{ +func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="9">{ var out App var any bool // helpers - getenv := func(k string) string </span><span class="cov10" title="192">{ return strings.TrimSpace(os.Getenv(k)) }</span> - <span class="cov4" title="8">parseInt := func(k string) (int, bool) </span><span class="cov7" title="56">{ + getenv := func(k string) string </span><span class="cov10" title="216">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov4" title="9">parseInt := func(k string) (int, bool) </span><span class="cov7" title="63">{ v := getenv(k) - if v == "" </span><span class="cov7" title="49">{ + if v == "" </span><span class="cov7" title="56">{ return 0, false }</span> <span class="cov4" title="7">n, err := strconv.Atoi(v) @@ -906,9 +1130,9 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="8">{ } <span class="cov4" title="7">return n, true</span> } - <span class="cov4" title="8">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov6" title="32">{ + <span class="cov4" title="9">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov7" title="36">{ v := getenv(k) - if v == "" </span><span class="cov6" title="28">{ + if v == "" </span><span class="cov6" title="32">{ return nil, false }</span> <span class="cov3" title="4">f, err := strconv.ParseFloat(v, 64) @@ -921,43 +1145,43 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="8">{ <span class="cov3" title="4">return &f, true</span> } - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{ out.MaxTokens = n any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ out.ContextMode = s any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ out.ContextWindowLines = n any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ out.MaxContextTokens = n any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ out.LogPreviewLimit = n any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ out.ManualInvokeMinPrefix = n any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionDebounceMs = n any = true }</span> - <span class="cov4" title="8">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ out.CompletionThrottleMs = n any = true }</span> - <span class="cov4" title="8">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CodingTemperature = f any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ parts := strings.Split(s, ",") out.TriggerCharacters = nil for _, p := range parts </span><span class="cov2" title="3">{ @@ -967,19 +1191,19 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="8">{ } <span class="cov1" title="1">any = true</span> } - <span class="cov4" title="8">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="9">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="9">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="9">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + <span class="cov4" title="9">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ parts := strings.Split(s, ",") out.ChatPrefixes = nil for _, p := range parts </span><span class="cov0" title="0">{ @@ -989,52 +1213,52 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="8">{ } <span class="cov0" title="0">any = true</span> } - <span class="cov4" title="8">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{ out.Provider = s any = true }</span> // Provider-specific - <span class="cov4" title="8">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIBaseURL = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIModel = s any = true }</span> - <span class="cov4" title="8">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OpenAITemperature = f any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OllamaBaseURL = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{ out.OllamaModel = s any = true }</span> - <span class="cov4" title="8">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OllamaTemperature = f any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.CopilotBaseURL = s any = true }</span> - <span class="cov4" title="8">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{ out.CopilotModel = s any = true }</span> - <span class="cov4" title="8">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + <span class="cov4" title="9">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CopilotTemperature = f any = true }</span> - <span class="cov4" title="8">if !any </span><span class="cov4" title="7">{ + <span class="cov4" title="9">if !any </span><span class="cov4" title="8">{ return nil }</span> <span class="cov1" title="1">return &out</span> @@ -1125,16 +1349,16 @@ import ( ) // Render performs simple {{var}} replacement like LSP. -func Render(t string, vars map[string]string) string <span class="cov10" title="8">{ return textutil.RenderTemplate(t, vars) }</span> +func Render(t string, vars map[string]string) string <span class="cov9" title="8">{ return textutil.RenderTemplate(t, vars) }</span> // StripFences removes surrounding markdown code fences. -func StripFences(s string) string <span class="cov10" title="8">{ return textutil.StripCodeFences(s) }</span> +func StripFences(s string) string <span class="cov10" title="9">{ return textutil.StripCodeFences(s) }</span> type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) } -func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov4" title="2">{ +func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) <span class="cov3" title="2">{ sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) @@ -1142,11 +1366,11 @@ func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruc func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) <span class="cov1" title="1">{ var b strings.Builder - for i, d := range diags </span><span class="cov4" title="2">{ + for i, d := range diags </span><span class="cov3" title="2">{ if strings.TrimSpace(d) == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov4" title="2">b.WriteString(strings.TrimSpace(d)) + <span class="cov3" title="2">b.WriteString(strings.TrimSpace(d)) if i < len(diags)-1 </span><span class="cov1" title="1">{ b.WriteString("\n") }</span> @@ -1156,47 +1380,47 @@ func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, dia return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))</span> } -func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov4" title="2">{ +func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) <span class="cov3" title="2">{ sys := cfg.PromptCodeActionDocumentSystem user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) <span class="cov4" title="2">{ +func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) <span class="cov3" title="2">{ sys := cfg.PromptCodeActionGoTestSystem user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) }</span> -func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) <span class="cov0" title="0">{ +func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) <span class="cov1" title="1">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov0" title="0">return strings.TrimSpace(StripFences(txt)), nil</span> + <span class="cov1" title="1">return strings.TrimSpace(StripFences(txt)), nil</span> } -func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov9" title="7">{ +func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) <span class="cov8" title="7">{ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} txt, err := client.Chat(ctx, msgs, opts...) if err != nil </span><span class="cov0" title="0">{ return "", err }</span> - <span class="cov9" title="7">return strings.TrimSpace(StripFences(txt)), nil</span> + <span class="cov8" title="7">return strings.TrimSpace(StripFences(txt)), nil</span> } // reqOptsFrom builds LLM request options similar to LSP behavior. -func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov9" title="7">{ +func reqOptsFrom(cfg appconfig.App) []llm.RequestOption <span class="cov8" title="7">{ opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)} if cfg.CodingTemperature != nil </span><span class="cov5" title="3">{ opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature)) }</span> - <span class="cov9" title="7">return opts</span> + <span class="cov8" title="7">return opts</span> } // Timeout helpers to mirror LSP behavior. -func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov4" title="2">{ +func timeout10s(parent context.Context) (context.Context, context.CancelFunc) <span class="cov3" title="2">{ return context.WithTimeout(parent, 10*time.Second) }</span> @@ -1220,11 +1444,11 @@ import ( ) // Run executes the hexai-action command flow. -func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov0" title="0">{ +func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov1" title="1">{ logger := log.New(stderr, "hexai-action ", log.LstdFlags|log.Lmsgprefix) cfg := appconfig.Load(logger) client, err := llmutils.NewClientFromApp(cfg) - if err != nil </span><span class="cov0" title="0">{ + if err != nil </span><span class="cov1" title="1">{ fmt.Fprintf(stderr, logging.AnsiBase+"hexai-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err }</span> @@ -1298,9 +1522,9 @@ type item struct { hotkey rune } -func (i item) Title() string <span class="cov0" title="0">{ return i.title }</span> -func (i item) Description() string <span class="cov0" title="0">{ return i.desc }</span> -func (i item) FilterValue() string <span class="cov1" title="1">{ return i.title }</span> +func (i item) Title() string <span class="cov1" title="1">{ return i.title }</span> +func (i item) Description() string <span class="cov1" title="1">{ return i.desc }</span> +func (i item) FilterValue() string <span class="cov8" title="3">{ return i.title }</span> type model struct { list list.Model @@ -1308,7 +1532,7 @@ type model struct { done bool } -func newModel() model <span class="cov10" title="3">{ +func newModel() model <span class="cov10" title="4">{ items := []list.Item{ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'}, item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'}, @@ -1323,21 +1547,21 @@ func newModel() model <span class="cov10" title="3">{ return model{list: l} }</span> -func (m model) Init() tea.Cmd <span class="cov0" title="0">{ return nil }</span> +func (m model) Init() tea.Cmd <span class="cov1" title="1">{ return nil }</span> -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov0" title="0">{ +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov1" title="1">{ switch msg := msg.(type) </span>{ case tea.KeyMsg:<span class="cov0" title="0"> return handleKey(m, msg)</span> - case tea.WindowSizeMsg:<span class="cov0" title="0"> + case tea.WindowSizeMsg:<span class="cov1" title="1"> m.list.SetSize(msg.Width, msg.Height)</span> } - <span class="cov0" title="0">var cmd tea.Cmd + <span class="cov1" title="1">var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd</span> } -func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov10" title="3">{ +func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="3">{ raw := msg.String() low := strings.ToLower(raw) switch low </span>{ @@ -1377,11 +1601,11 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov10" <span class="cov1" title="1">return m, nil</span> } -func (m model) View() string <span class="cov0" title="0">{ +func (m model) View() string <span class="cov1" title="1">{ if m.done </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov0" title="0">return m.list.View()</span> + <span class="cov1" title="1">return m.list.View()</span> } // RunTUI returns the chosen ActionKind. @@ -1420,21 +1644,21 @@ var ( cursorStyle = lipgloss.NewStyle().Bold(true) ) -func (oneLineDelegate) Height() int <span class="cov8" title="10">{ return 1 }</span> -func (oneLineDelegate) Spacing() int <span class="cov10" title="16">{ return 0 }</span> -func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd <span class="cov0" title="0">{ return nil }</span> -func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) <span class="cov1" title="1">{ +func (oneLineDelegate) Height() int <span class="cov8" title="14">{ return 1 }</span> +func (oneLineDelegate) Spacing() int <span class="cov10" title="24">{ return 0 }</span> +func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd <span class="cov1" title="1">{ return nil }</span> +func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) <span class="cov2" title="2">{ title := listItem.FilterValue() hk := '?' - if it, ok := listItem.(item); ok </span><span class="cov1" title="1">{ + if it, ok := listItem.(item); ok </span><span class="cov2" title="2">{ hk = it.hotkey }</span> - <span class="cov1" title="1">hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) + <span class="cov2" title="2">hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk)) cursor := " " - if index == m.Index() </span><span class="cov1" title="1">{ + if index == m.Index() </span><span class="cov2" title="2">{ cursor = cursorStyle.Render("> ") }</span> - <span class="cov1" title="1">fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span> + <span class="cov2" title="2">fmt.Fprintf(w, "%s%s%s", cursor, title, hot)</span> } </pre> @@ -2736,14 +2960,14 @@ type Config struct { // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="18">{ +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="19">{ p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" </span><span class="cov6" title="7">{ + if p == "" </span><span class="cov6" title="8">{ p = "openai" }</span> - <span class="cov8" title="18">switch p </span>{ - case "openai":<span class="cov7" title="11"> - if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov4" title="4">{ + <span class="cov8" title="19">switch p </span>{ + case "openai":<span class="cov7" title="12"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="5">{ return nil, errors.New("missing OPENAI_API_KEY for provider openai") }</span> // Set coding-friendly default temperature if none provided @@ -2792,7 +3016,7 @@ import ( ) // NewClientFromApp builds an llm.Client using app config and environment keys. -func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" title="5">{ +func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" title="6">{ llmCfg := llm.Config{ Provider: cfg.Provider, OpenAIBaseURL: cfg.OpenAIBaseURL, @@ -2806,14 +3030,14 @@ func NewClientFromApp(cfg appconfig.App) (llm.Client, error) <span class="cov10" CopilotTemperature: cfg.CopilotTemperature, } oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") - if strings.TrimSpace(oaKey) == "" </span><span class="cov8" title="4">{ + if strings.TrimSpace(oaKey) == "" </span><span class="cov9" title="5">{ oaKey = os.Getenv("OPENAI_API_KEY") }</span> - <span class="cov10" title="5">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="5">{ + <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{ cpKey = os.Getenv("COPILOT_API_KEY") }</span> - <span class="cov10" title="5">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> + <span class="cov10" title="6">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> } </pre> @@ -5752,26 +5976,26 @@ func RenderTemplate(t string, vars map[string]string) string <span class="cov8" } // StripCodeFences removes surrounding Markdown triple-backtick fences. -func StripCodeFences(s string) string <span class="cov8" title="49">{ +func StripCodeFences(s string) string <span class="cov8" title="50">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov0" title="0">{ return t }</span> - <span class="cov8" title="49">lines := strings.Split(t, "\n") + <span class="cov8" title="50">lines := strings.Split(t, "\n") start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{ start++ }</span> - <span class="cov8" title="49">end := len(lines) - 1 + <span class="cov8" title="50">end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ end-- }</span> - <span class="cov8" title="49">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + <span class="cov8" title="50">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ return t }</span> - <span class="cov8" title="49">first := strings.TrimSpace(lines[start]) + <span class="cov8" title="50">first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) - if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="19">{ + if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov6" title="20">{ inner := strings.Join(lines[start+1:end], "\n") return inner }</span> @@ -5851,6 +6075,93 @@ func FindStrictInlineTag(line string) (text string, left, right int, ok bool) <s </pre> + <pre class="file" id="file32" style="display: none">package tmux + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +// Available reports whether tmux is available and we appear to be in a tmux session. +func Available() bool <span class="cov2" title="2">{ return HasBinary() && InSession() }</span> + +// HasBinary reports whether the tmux binary is on PATH. +var lookPath = exec.LookPath +var command = exec.Command + +func HasBinary() bool <span class="cov4" title="4">{ _, err := lookPath("tmux"); return err == nil }</span> + +// InSession reports whether we seem to be running inside a tmux session. +func InSession() bool <span class="cov4" title="3">{ return strings.TrimSpace(os.Getenv("TMUX")) != "" }</span> + +// SplitOpts controls how a new pane is created for running a command. +type SplitOpts struct { + Target string // optional pane target, e.g. ":." + Vertical bool // true => split vertically (-v); false => horizontally (-h) + Percent int // 1..100; 0 means use tmux default +} + +// SplitRun splits the current tmux window and runs argv in the new pane. +// It returns once tmux has launched the child process. +func SplitRun(opts SplitOpts, argv []string) error <span class="cov1" title="1">{ + if len(argv) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov1" title="1">args := []string{"split-window"} + if opts.Vertical </span><span class="cov1" title="1">{ + args = append(args, "-v") + }</span> else<span class="cov0" title="0"> { + args = append(args, "-h") + }</span> + <span class="cov1" title="1">if opts.Percent > 0 && opts.Percent <= 100 </span><span class="cov1" title="1">{ + args = append(args, "-p", strconv.Itoa(opts.Percent)) + }</span> + <span class="cov1" title="1">if strings.TrimSpace(opts.Target) != "" </span><span class="cov1" title="1">{ + args = append(args, "-t", opts.Target) + }</span> + // tmux takes a single command string. Use a conservative shell join. + <span class="cov1" title="1">cmdStr := shellJoin(argv) + args = append(args, cmdStr) + c := command("tmux", args...) + return c.Run()</span> +} + +// shellJoin quotes argv elements for safe use in a single shell command string. +// It avoids interpretation by wrapping in single quotes and escaping embedded single quotes. +func shellJoin(argv []string) string <span class="cov1" title="1">{ + out := make([]string, 0, len(argv)) + for _, a := range argv </span><span class="cov4" title="4">{ + if a == "" </span><span class="cov0" title="0">{ + out = append(out, "''") + continue</span> + } + <span class="cov4" title="4">if isSafeBare(a) </span><span class="cov2" title="2">{ + out = append(out, a) + continue</span> + } + // single-quote wrapping with escaped single quotes + // ' => '\'' (close, escaped quote, reopen) + <span class="cov2" title="2">esc := strings.ReplaceAll(a, "'", "'\\''") + out = append(out, "'"+esc+"'")</span> + } + <span class="cov1" title="1">return strings.Join(out, " ")</span> +} + +// isSafeBare returns true if a contains only safe characters for bare words. +func isSafeBare(s string) bool <span class="cov4" title="4">{ + for i := 0; i < len(s); i++ </span><span class="cov10" title="27">{ + b := s[i] + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' </span><span class="cov9" title="25">{ + continue</span> + } + <span class="cov2" title="2">return false</span> + } + <span class="cov2" title="2">return true</span> +} +</pre> + </div> </body> <script> |
