From cead3ebde8f3aee0ef8677158d37f4d04c6629dc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 8 Sep 2025 09:50:38 +0300 Subject: tmux: colored LLM status with provider + stats; add start heartbeat for LSP/CLI/TUI; theme support via HEXAI_TMUX_STATUS_THEME and HEXAI_TMUX_STATUS_FG/BG; docs: update tmux options and add Helix+tmux quickstart --- README.md | 17 +++++- docs/configuration.md | 12 +++- docs/helix-tmux-quickstart.md | 68 +++++++++++++++++++++ docs/usage.md | 18 ++++++ internal/hexaiaction/prompts.go | 107 +++++++++++++++++++++----------- internal/hexaiaction/run.go | 131 ++++++++++++++++++++-------------------- internal/hexaicli/run.go | 87 ++++++++++++++------------ internal/lsp/handlers_init.go | 5 ++ internal/lsp/handlers_utils.go | 50 +++++++++++---- internal/textutil/human.go | 25 ++++++++ internal/tmux/status.go | 88 +++++++++++++++++++++++---- 11 files changed, 441 insertions(+), 167 deletions(-) create mode 100644 docs/helix-tmux-quickstart.md create mode 100644 internal/textutil/human.go diff --git a/README.md b/README.md index 9f94c2f..1be6238 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ It has got improved capabilities for Go code understanding (for example, create * [Configuration guide](docs/configuration.md) * [Usage examples](docs/usage.md) +* [Helix + tmux quickstart](docs/helix-tmux-quickstart.md) ## Build and tasks @@ -58,7 +59,17 @@ Hexai can surface live progress in tmux's status line via a user option. Add thi set -g status-right '#{@hexai_status} #[fg=colour8]| %H:%M' ``` -- CLI updates `@hexai_status` at start (⏳ provider:model) and on completion (✅ model duration). -- LSP sends a short heartbeat after logging aggregate LLM stats. -- The TUI action runner sets a ready message and a completion message. +- CLI updates `@hexai_status` at start (⏳ provider:model) and on completion with compact stats (↑sent, ↓recv, rpm, reqs). +- LSP emits an initial heartbeat on client initialize and periodic compact stats (provider, model, rpm, reqs, bytes). +- The TUI action runner sets a ready heartbeat and a completion heartbeat with stats. - Toggle with `HEXAI_TMUX_STATUS=0` to disable (enabled by default). + +The status segment supports simple theming: + +- Preset themes: + - `HEXAI_TMUX_STATUS_THEME=white-on-purple` (white fg on purple/magenta bg) + - `HEXAI_TMUX_STATUS_THEME=black-on-yellow` (black fg on yellow bg) +- Explicit colors: set any tmux color names or 256-color codes + - `HEXAI_TMUX_STATUS_FG=white` + - `HEXAI_TMUX_STATUS_BG=magenta` (or `colour5`, etc.) +- If the segment is truncated, widen it: `set -g status-right-length 120` diff --git a/docs/configuration.md b/docs/configuration.md index 690a08f..95b65de 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -69,14 +69,20 @@ Tmux status line - `set -g status-right '#{@hexai_status} #[fg=colour8]| %H:%M'` - Status content is updated best‑effort at key moments: - - CLI: start (⏳ provider:model) and completion (✅ model duration) - - LSP: after logging aggregate LLM stats (LLM:model) - - TUI action runner: ready (model) and completion (✅ model) + - CLI: start (⏳ provider:model) and completion with compact stats (↑sent, ↓recv, rpm, reqs) + - LSP: initial heartbeat on client initialize, and periodic compact stats (provider, model, rpm, reqs, bytes) + - TUI action runner: ready (provider:model) and completion with compact stats - Toggle via environment: - Enable (default): unset or `HEXAI_TMUX_STATUS=1` - Disable: `HEXAI_TMUX_STATUS=0` +- Theme and colors: + - Preset: `HEXAI_TMUX_STATUS_THEME=white-on-purple` (white fg on purple/magenta bg) + - Explicit: `HEXAI_TMUX_STATUS_FG=`, `HEXAI_TMUX_STATUS_BG=` (e.g., `white`, `magenta`, `colour5`) + - Colors use tmux’s `fg`/`bg` names; both methods wrap the entire `@hexai_status` segment. + - If truncated, increase width: `set -g status-right-length 120` + Code action prompts - All prompts can be customized under `[prompts.code_action]` in `config.toml`. In addition to `rewrite_*`, `diagnostics_*`, `document_*`, and `go_test_*`, the following templates control the “Simplify and improve” action: diff --git a/docs/helix-tmux-quickstart.md b/docs/helix-tmux-quickstart.md new file mode 100644 index 0000000..caee3f8 --- /dev/null +++ b/docs/helix-tmux-quickstart.md @@ -0,0 +1,68 @@ +# Helix + tmux Quickstart + +This guide gets you from zero to editing with Hexai in Helix, with tmux showing live LLM status. + +## 1) Install + +- Install Mage (optional for build tasks): `go install github.com/magefile/mage@latest` +- Install binaries directly: + - CLI: `go install codeberg.org/snonux/hexai/cmd/hexai@latest` + - LSP: `go install codeberg.org/snonux/hexai/cmd/hexai-lsp@latest` + - TUI: `go install codeberg.org/snonux/hexai/cmd/hexai-tmux-action@latest` + +Ensure `~/go/bin` is on your `PATH`. + +## 2) Configure Helix + +In `~/.config/helix/languages.toml`: + +```toml +[[language]] +name = "go" +auto-format = true +language-servers = ["gopls", "hexai"] + +[language-server.hexai] +command = "hexai-lsp" +``` + +Optional keybindings in `~/.config/helix/config.toml` to run code actions on the selection: + +```toml +[keys.select] +"A-a" = ":pipe hexai-tmux-action" + +[keys.normal] +"A-a" = ["select_line", ":pipe hexai-tmux-action"] +``` + +## 3) Configure tmux status + +Add this to `~/.tmux.conf` and reload with `tmux source-file ~/.tmux.conf`: + +``` +set -g status-right '#{@hexai_status} #[fg=colour8]| %H:%M' +set -g status-right-length 120 + +# Optional: theme the Hexai status segment +set-environment -g HEXAI_TMUX_STATUS_THEME white-on-purple # or black-on-yellow, white-on-blue +# Or explicit colors +# set-environment -g HEXAI_TMUX_STATUS_FG white +# set-environment -g HEXAI_TMUX_STATUS_BG magenta +``` + +## 4) Use it + +- Start tmux, open Helix on a Go file. +- Try completions or inline prompts; or select code and press Alt-a for the action menu. +- Watch the right side of your tmux status for live LLM stats: + - Start heartbeat: provider:model ⏳ + - Stats: ↑sent ↓recv rpm reqs + +## 5) Troubleshooting + +- No status? Verify: `tmux show -g -v @hexai_status` (should show text). +- Truncated? Increase width: `set -g status-right-length 120`. +- Disabled? Ensure `HEXAI_TMUX_STATUS` is not set to `0`. +- Wrong model? Rebuild/update binaries and restart Helix/LSP. + diff --git a/docs/usage.md b/docs/usage.md index 293b038..706be99 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -132,3 +132,21 @@ hexai-tmux-action --infile input.go --outfile output.go # Using shell redirection hexai-tmux-action < input.go > output.go ``` + +### Helix keybinding example + +Bind a key to pipe the current selection through the action runner and replace it in-place. In `~/.config/helix/config.toml`: + +```toml +[keys.select] +# Alt-a runs the Hexai action menu on the selection +"A-a" = ":pipe hexai-tmux-action" + +[keys.normal] +# Optional: run on the current line if no selection +"A-a" = ["select_line", ":pipe hexai-tmux-action"] +``` + +Tips: +- Ensure Helix runs inside tmux to see the status updates. +- You can also set a language-specific binding in `languages.toml` if preferred. diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go index 3c33f8a..e9d7fc6 100644 --- a/internal/hexaiaction/prompts.go +++ b/internal/hexaiaction/prompts.go @@ -1,13 +1,14 @@ package hexaiaction import ( - "context" - "strings" - "time" + "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/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. @@ -18,12 +19,22 @@ 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)) + 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) { @@ -39,52 +50,80 @@ func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, dia } sys := cfg.PromptCodeActionDiagnosticsSystem user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection}) - return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) + 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)) + 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)) + 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)) + return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg)) } func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) { - msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - txt, err := client.Chat(ctx, msgs) - if err != nil { - return "", err - } - return strings.TrimSpace(StripFences(txt)), nil + 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}} - txt, err := client.Chat(ctx, msgs, opts...) - if err != nil { - return "", err - } - return strings.TrimSpace(StripFences(txt)), nil + 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 + 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. diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index c8cfcac..4958642 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -1,63 +1,64 @@ package hexaiaction import ( - "context" - "fmt" - "io" - "log" - "strings" + "context" + "fmt" + "io" + "log" + "strings" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/logging" - "codeberg.org/snonux/hexai/internal/llmutils" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llmutils" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/tmux" ) // Run executes the hexai-tmux-action command flow. // seams for testability -var chooseActionFn = RunTUI -var newClientFromApp = llmutils.NewClientFromApp +var ( + chooseActionFn = RunTUI + newClientFromApp = llmutils.NewClientFromApp +) func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { - logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) - cli, err := newClientFromApp(cfg) - if err != nil { - fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) - return err - } - _ = tmux.SetStatus("hexai action ready " + cli.DefaultModel()) - var client chatDoer = cli - parts, err := ParseInput(stdin) + logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.Load(logger) + cli, err := newClientFromApp(cfg) if err != nil { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset) + fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + } + _ = tmux.SetStatus(tmux.FormatLLMStartStatus(cli.Name(), cli.DefaultModel())) + var client chatDoer = cli + parts, err := ParseInput(stdin) + if err != nil { + fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset) return err } if strings.TrimSpace(parts.Selection) == "" { - return fmt.Errorf("hexai-tmux-action: no input provided on stdin") + return fmt.Errorf("hexai-tmux-action: no input provided on stdin") + } + kind, err := chooseActionFn() + if err != nil { + return err + } + out, err := executeAction(ctx, kind, parts, cfg, client, stderr) + if err != nil { + return err } - kind, err := chooseActionFn() - if err != nil { - return err - } - out, err := executeAction(ctx, kind, parts, cfg, client, stderr) - if err != nil { - return err - } - io.WriteString(stdout, out) - _ = tmux.SetStatus("✅ " + cli.DefaultModel()) - return nil + io.WriteString(stdout, out) + return nil } func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) { - switch kind { - case ActionSkip: - return parts.Selection, nil - case ActionRewrite: + switch kind { + case ActionSkip: + return parts.Selection, nil + case ActionRewrite: instr, cleaned := ExtractInstruction(parts.Selection) if strings.TrimSpace(instr) == "" { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) + fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: no inline instruction found; echoing input"+logging.AnsiReset) return parts.Selection, nil } cctx, cancel := timeout10s(ctx) @@ -67,31 +68,31 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a cctx, cancel := timeout10s(ctx) defer cancel() return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection) - case ActionDocument: - cctx, cancel := timeout10s(ctx) - defer cancel() - return runDocument(cctx, cfg, client, parts.Selection) - case ActionGoTest: - cctx, cancel := timeout8s(ctx) - defer cancel() - return runGoTest(cctx, cfg, client, parts.Selection) - case ActionSimplify: - cctx, cancel := timeout10s(ctx) - defer cancel() - return runSimplify(cctx, cfg, client, parts.Selection) - case ActionCustom: - cctx, cancel := timeout10s(ctx) - defer cancel() - // Open editor for free-form instruction - prompt, err := editor.OpenTempAndEdit(nil) - if err != nil || strings.TrimSpace(prompt) == "" { - fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) - return parts.Selection, nil - } - return runRewrite(cctx, cfg, client, prompt, parts.Selection) - default: - return parts.Selection, nil - } + case ActionDocument: + cctx, cancel := timeout10s(ctx) + defer cancel() + return runDocument(cctx, cfg, client, parts.Selection) + case ActionGoTest: + cctx, cancel := timeout8s(ctx) + defer cancel() + return runGoTest(cctx, cfg, client, parts.Selection) + case ActionSimplify: + cctx, cancel := timeout10s(ctx) + defer cancel() + return runSimplify(cctx, cfg, client, parts.Selection) + case ActionCustom: + cctx, cancel := timeout10s(ctx) + defer cancel() + // Open editor for free-form instruction + prompt, err := editor.OpenTempAndEdit(nil) + if err != nil || strings.TrimSpace(prompt) == "" { + fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) + return parts.Selection, nil + } + return runRewrite(cctx, cfg, client, prompt, parts.Selection) + default: + return parts.Selection, nil + } } // client construction is shared via internal/llmutils diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 18d4289..984bc85 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -3,44 +3,44 @@ package hexaicli import ( - "bufio" - "context" - "fmt" - "io" - "log" - "os" - "strings" - "time" + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "time" - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/logging" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/llmutils" - "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/llmutils" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/tmux" ) // 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 { - // Load configuration with a logger so file-based config is respected. - logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) - client, err := newClientFromApp(cfg) + // Load configuration with a logger so file-based config is respected. + logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.Load(logger) + client, err := newClientFromApp(cfg) if err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } - // No args: open editor to capture a prompt, then combine with stdin as usual. - if len(args) == 0 { - if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" { - args = []string{prompt} - } else { - // If editor fails or empty, continue; readInput will likely error if no stdin either. - } - } - // Inline the flow here to use configured CLI prompts. - input, rerr := readInput(stdin, args) + // No args: open editor to capture a prompt, then combine with stdin as usual. + if len(args) == 0 { + if prompt, eerr := editor.OpenTempAndEdit(nil); eerr == nil && strings.TrimSpace(prompt) != "" { + args = []string{prompt} + } else { + // If editor fails or empty, continue; readInput will likely error if no stdin either. + } + } + // Inline the flow here to use configured CLI prompts. + input, rerr := readInput(stdin, args) if rerr != nil { fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr @@ -124,10 +124,10 @@ func buildMessagesFromConfig(cfg appconfig.App, input string) []llm.Message { // runChat executes the chat request, handling streaming and summary output. func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error { - start := time.Now() - // Best-effort tmux status update - _ = tmux.SetStatus("⏳ " + client.Name() + ":" + client.DefaultModel()) - var output string + start := time.Now() + // Best-effort tmux status update (colored start heartbeat) + _ = tmux.SetStatus(tmux.FormatLLMStartStatus(client.Name(), client.DefaultModel())) + var output string if s, ok := client.(llm.Streamer); ok { var b strings.Builder if err := s.ChatStream(ctx, msgs, func(chunk string) { @@ -145,16 +145,27 @@ func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input s output = txt fmt.Fprint(out, output) } - dur := time.Since(start) - fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n", - client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output)) - _ = tmux.SetStatus("✅ " + client.DefaultModel() + " " + dur.Round(time.Millisecond).String()) - return nil + dur := time.Since(start) + // Compute simple stats for tmux heartbeat + sent := 0 + for _, m := range msgs { + sent += len(m.Content) + } + recv := len(output) + mins := dur.Minutes() + if mins <= 0 { + mins = 0.001 + } + rpm := float64(1) / mins + fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n", + client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), sent, recv) + _ = tmux.SetStatus(tmux.FormatLLMStatsStatusColored(client.Name(), client.DefaultModel(), 1, rpm, int64(sent), int64(recv))) + return nil } // printProviderInfo writes the provider/model line to stderr. func printProviderInfo(errw io.Writer, client llm.Client) { - fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) + fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) } // newClientFromConfig is kept for tests; delegates to llmutils. diff --git a/internal/lsp/handlers_init.go b/internal/lsp/handlers_init.go index ac1d566..ba00333 100644 --- a/internal/lsp/handlers_init.go +++ b/internal/lsp/handlers_init.go @@ -6,6 +6,7 @@ import ( "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/logging" + tmx "codeberg.org/snonux/hexai/internal/tmux" ) func (s *Server) handleInitialize(req Request) { @@ -29,6 +30,10 @@ func (s *Server) handleInitialize(req Request) { func (s *Server) handleInitialized() { logging.Logf("lsp ", "client initialized") + // Emit an initial tmux heartbeat with provider/model + if s.llmClient != nil { + _ = tmx.SetStatus(tmx.FormatLLMStartStatus(s.llmClient.Name(), s.llmClient.DefaultModel())) + } } func (s *Server) handleShutdown(req Request) { diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 7f116cd..15f0174 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -2,13 +2,14 @@ package lsp import ( - "strings" - "time" + "context" + "strings" + "time" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "codeberg.org/snonux/hexai/internal/textutil" - tmx "codeberg.org/snonux/hexai/internal/tmux" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/textutil" + tmx "codeberg.org/snonux/hexai/internal/tmux" ) // Configurable inline trigger characters (default to '>') used by free helpers below. @@ -61,11 +62,14 @@ func (s *Server) logLLMStats() { rpm := float64(reqs) / mins sentPerMin := float64(sentTot) / mins recvPerMin := float64(recvTot) / mins - logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin) - // Best-effort tmux status update - if s.llmClient != nil { - _ = tmx.SetStatus("LLM:" + s.llmClient.DefaultModel()) - } + logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin) + // Best-effort tmux status update with a compact stats heartbeat + if s.llmClient != nil { + model := s.llmClient.DefaultModel() + provider := s.llmClient.Name() + status := tmx.FormatLLMStatsStatusColored(provider, model, reqs, rpm, sentTot, recvTot) + _ = tmx.SetStatus(status) + } } // Completion prompt builders and filters @@ -127,6 +131,30 @@ func isIdentChar(ch byte) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } +// chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. +func (s *Server) chatWithStats(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error) { + // Count bytes sent + sent := 0 + for _, m := range msgs { + sent += len(m.Content) + } + s.incSentCounters(sent) + // Debounce/throttle if configured (reuse completion gates) + s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) { + return "", context.Canceled + } + // Perform request + txt, err := s.llmClient.Chat(ctx, msgs, opts...) + if err != nil { + s.logLLMStats() + return "", err + } + s.incRecvCounters(len(txt)) + s.logLLMStats() + return txt, nil +} + // Inline prompt utilities func lineHasInlinePrompt(line string) bool { if _, _, _, ok := findStrictInlineTag(line); ok { diff --git a/internal/textutil/human.go b/internal/textutil/human.go new file mode 100644 index 0000000..21907c3 --- /dev/null +++ b/internal/textutil/human.go @@ -0,0 +1,25 @@ +package textutil + +import "fmt" + +// HumanBytes renders n in a short human-friendly form using base-1000 units. +// Examples: 999 -> 999B, 1200 -> 1.2k, 1540000 -> 1.5M +func HumanBytes(n int64) string { + if n < 1000 { + return fmt.Sprintf("%dB", n) + } + const unit = 1000.0 + v := float64(n) + suffix := []string{"k", "M", "G", "T"} + i := 0 + for v >= unit && i < len(suffix)-1 { + v /= unit + i++ + } + s := fmt.Sprintf("%.1f%s", v, suffix[i]) + // Strip trailing ".0" + if len(s) >= 3 && s[len(s)-2:] == ".0" { + s = fmt.Sprintf("%d%s", int(v), suffix[i]) + } + return s +} diff --git a/internal/tmux/status.go b/internal/tmux/status.go index 4e1f9e4..1d2a8ee 100644 --- a/internal/tmux/status.go +++ b/internal/tmux/status.go @@ -1,28 +1,90 @@ package tmux import ( - "os" - "os/exec" - "strings" + "fmt" + "os" + "os/exec" + "strings" + + "codeberg.org/snonux/hexai/internal/textutil" ) // Enabled reports whether tmux status updates are enabled via env (default: on). func Enabled() bool { - v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) - if v == "" { return true } - v = strings.ToLower(v) - return v == "1" || v == "true" || v == "yes" || v == "on" + v := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS")) + if v == "" { + return true + } + v = strings.ToLower(v) + return v == "1" || v == "true" || v == "yes" || v == "on" } // SetUserOption sets a global tmux user option like @hexai_status to value. func SetUserOption(key, value string) error { - if !Enabled() || !HasBinary() || !InSession() { return nil } - k := strings.TrimPrefix(strings.TrimSpace(key), "@") - if k == "" { return nil } - // Use set-option -g so it appears for all windows - return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run() + if !Enabled() || !HasBinary() || !InSession() { + return nil + } + k := strings.TrimPrefix(strings.TrimSpace(key), "@") + if k == "" { + return nil + } + // Use set-option -g so it appears for all windows + return exec.Command("tmux", "set-option", "-g", "@"+k, value).Run() } // SetStatus is a convenience for setting @hexai_status. -func SetStatus(value string) error { return SetUserOption("hexai_status", value) } +func SetStatus(value string) error { return SetUserOption("hexai_status", applyTheme(value)) } + +// FormatLLMStatsStatus builds a compact tmux status string for LLM heartbeats. +// Example: "LLM:gpt-4.1 5r 0.8rpm in12k out34k" +func FormatLLMStatsStatus(model string, reqs int64, rpm float64, inBytes, outBytes int64) string { + return fmt.Sprintf("LLM:%s %dr %.1frpm in%s out%s", model, reqs, rpm, textutil.HumanBytes(inBytes), textutil.HumanBytes(outBytes)) +} + +// FormatLLMStatsStatusColored is like FormatLLMStatsStatus but includes provider and +// tmux color segments for readability. Uses up/down arrows for bytes. +// Example (with colors): "LLM:openai:gpt-4.1 ↑12k ↓34k 0.8rpm 5r" +func FormatLLMStatsStatusColored(provider, model string, reqs int64, rpm float64, inBytes, outBytes int64) string { + in := textutil.HumanBytes(inBytes) + out := textutil.HumanBytes(outBytes) + // Keep it compact; colorize prefix and arrows; use fg resets so a themed bg can persist. + // colour8 = dim grey, colour3 = yellow (up), colour2 = green (down) + return fmt.Sprintf( + "#[fg=colour8]LLM:#[fg=default]%s:%s #[fg=colour3]↑%s#[fg=default] #[fg=colour2]↓%s#[fg=default] %.1frpm %dr", + provider, model, in, out, rpm, reqs, + ) +} + +// FormatLLMStartStatus renders a short colored heartbeat at start/initialize time. +// Example: "LLM:openai:gpt-4.1 ⏳" +func FormatLLMStartStatus(provider, model string) string { + return fmt.Sprintf("#[fg=colour8]LLM:#[fg=default]%s:%s #[fg=colour11]⏳#[fg=default]", provider, model) +} +// applyTheme wraps the status string with a user-selected tmux style if requested. +// Set HEXAI_TMUX_STATUS_THEME=white-on-purple to get white-on-purple background. +func applyTheme(s string) string { + theme := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_THEME"))) + // Allow explicit fg/bg override + fg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_FG")) + bg := strings.TrimSpace(os.Getenv("HEXAI_TMUX_STATUS_BG")) + if fg != "" || bg != "" { + if fg == "" { + fg = "default" + } + if bg == "" { + bg = "default" + } + return "#[fg=" + fg + ",bg=" + bg + "]" + s + "#[fg=default,bg=default]" + } + if theme == "white-on-purple" || theme == "purple" || theme == "magenta" || theme == "white-on-magenta" { + return "#[fg=white,bg=magenta]" + s + "#[fg=default,bg=default]" + } + if theme == "black-on-yellow" || theme == "yellow" || theme == "black-on-gold" { + return "#[fg=black,bg=yellow]" + s + "#[fg=default,bg=default]" + } + if theme == "white-on-blue" || theme == "blue" || theme == "white-on-navy" { + return "#[fg=white,bg=blue]" + s + "#[fg=default,bg=default]" + } + return s +} -- cgit v1.2.3