summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-08 09:50:38 +0300
committerPaul Buetow <paul@buetow.org>2025-09-08 09:50:38 +0300
commitcead3ebde8f3aee0ef8677158d37f4d04c6629dc (patch)
treeeadf4928c13e4f1fd782e8e0955116a24cef1d27
parent29b0da31acf02816ee9e8f1d5a1b9a0ad5993593 (diff)
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
-rw-r--r--README.md17
-rw-r--r--docs/configuration.md12
-rw-r--r--docs/helix-tmux-quickstart.md68
-rw-r--r--docs/usage.md18
-rw-r--r--internal/hexaiaction/prompts.go107
-rw-r--r--internal/hexaiaction/run.go131
-rw-r--r--internal/hexaicli/run.go87
-rw-r--r--internal/lsp/handlers_init.go5
-rw-r--r--internal/lsp/handlers_utils.go50
-rw-r--r--internal/textutil/human.go25
-rw-r--r--internal/tmux/status.go88
11 files changed, 441 insertions, 167 deletions
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=<color>`, `HEXAI_TMUX_STATUS_BG=<color>` (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
+}