summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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
Diffstat (limited to 'internal')
-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
7 files changed, 332 insertions, 161 deletions
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
+}