package hexaiaction import ( "context" "strings" "time" "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/textutil" "codeberg.org/snonux/hexai/internal/tmux" ) // Render performs simple {{var}} replacement like LSP. func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } // StripFences removes surrounding markdown code fences. func StripFences(s string) string { return textutil.StripCodeFences(s) } type chatDoer interface { Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) DefaultModel() string } type providerNamer interface{ Name() string } func providerOf(c any) string { if n, ok := c.(providerNamer); ok { return n.Name() } return "llm" } func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) { sys := cfg.PromptCodeActionRewriteSystem user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) { var b strings.Builder for i, d := range diags { if strings.TrimSpace(d) == "" { continue } b.WriteString(strings.TrimSpace(d)) if i < len(diags)-1 { b.WriteString("\n") } } sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionDocumentSystem user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runSimplify(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) { sys := cfg.PromptCodeActionSimplifySystem user := Render(cfg.PromptCodeActionSimplifyUser, map[string]string{"selection": selection}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) { sys := cfg.PromptCodeActionGoTestSystem user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) { // If user template is provided, prefer it and optional system if strings.TrimSpace(ca.User) != "" { sys := cfg.PromptCodeActionRewriteSystem if strings.TrimSpace(ca.System) != "" { sys = ca.System } // Currently only selection is available in tmux path; diagnostics list not wired user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")}) return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } // Else, use fixed instruction through rewrite template return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection) } func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} start := time.Now() txt, err := client.Chat(ctx, msgs) if err != nil { return "", err } out := strings.TrimSpace(StripFences(txt)) // Update tmux heartbeat with simple one-request stats sent := 0 for _, m := range msgs { sent += len(m.Content) } recv := len(out) mins := time.Since(start).Minutes() if mins <= 0 { mins = 0.001 } rpm := float64(1) / mins _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) return out, nil } func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) { msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} start := time.Now() txt, err := client.Chat(ctx, msgs, opts...) if err != nil { return "", err } out := strings.TrimSpace(StripFences(txt)) // Update tmux heartbeat with simple one-request stats sent := 0 for _, m := range msgs { sent += len(m.Content) } recv := len(out) mins := time.Since(start).Minutes() if mins <= 0 { mins = 0.001 } rpm := float64(1) / mins _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(providerOf(client), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) return out, nil } // reqOptsFrom builds LLM request options similar to LSP behavior. func reqOptsFrom(cfg appconfig.App) []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)} if cfg.CodingTemperature != nil { opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature)) } return opts } // Timeout helpers to mirror LSP behavior. func timeout10s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 10*time.Second) } func timeout8s(parent context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(parent, 8*time.Second) }