summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-17 11:28:19 +0200
committerPaul Buetow <paul@buetow.org>2026-03-17 11:28:19 +0200
commit6f1c8bf7a36eb7044ed7aad30f84664cbbf0d303 (patch)
treedd2ac6e1433177fb59c167a12fa0b4b91132f34a
parent10562cc510f64d5ac38aeb76f03e18eb76cca40f (diff)
Fix bugs, remove duplication, and clean up code quality issues
- Log swallowed JSON unmarshal errors in stats and LSP handlers - Fix debug log file handle leak in tmuxedit (return closer from initDebugLog) - Check f.Close() errors on write paths in promptstore and tmuxedit - Fix cacheGet TOCTOU race by using single write lock - Fix readInput to use passed stdin reader instead of os.Stdin.Stat() - Remove 45 'moved to' comment tombstones from lsp/handlers.go - Deduplicate canonicalProvider wrappers (use llmutils.CanonicalProvider directly) - Remove SetWindow side effect from stats.TakeSnapshot (pure read now) - Move duplicated splitLines to textutil.SplitLinesBytes - Collapse StatusSink.SetGlobal 10 params into GlobalStatus struct - Simplify LRU touchLocked to in-place delete-and-append Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
-rw-r--r--internal/hexaiaction/prompts.go8
-rw-r--r--internal/hexaiaction/run.go32
-rw-r--r--internal/hexaicli/run.go27
-rw-r--r--internal/hexailsp/run.go7
-rw-r--r--internal/lsp/completion_state.go20
-rw-r--r--internal/lsp/document_test.go3
-rw-r--r--internal/lsp/handlers.go174
-rw-r--r--internal/lsp/handlers_codeaction.go95
-rw-r--r--internal/lsp/handlers_completion.go25
-rw-r--r--internal/lsp/handlers_utils.go171
-rw-r--r--internal/lsp/llm_client_registry.go9
-rw-r--r--internal/lsp/server.go20
-rw-r--r--internal/promptstore/store.go30
-rw-r--r--internal/stats/stats.go13
-rw-r--r--internal/textutil/textutil.go26
-rw-r--r--internal/tmuxedit/history.go30
-rw-r--r--internal/tmuxedit/history_test.go4
-rw-r--r--internal/tmuxedit/run.go19
18 files changed, 356 insertions, 357 deletions
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go
index 8cf8c48..03a9441 100644
--- a/internal/hexaiaction/prompts.go
+++ b/internal/hexaiaction/prompts.go
@@ -38,10 +38,6 @@ func providerOf(c any) string {
return "llm"
}
-func canonicalProvider(name string) string {
- return llmutils.CanonicalProvider(name)
-}
-
// selectActionTemperature resolves the effective temperature for a code action,
// delegating GPT-5 override logic to llmutils.ResolveTemperature.
func selectActionTemperature(cfg actionConfig, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) {
@@ -148,14 +144,14 @@ func reqOptsFrom(cfg actionConfig) requestArgs {
if core.MaxTokens > 0 {
opts = append(opts, llm.WithMaxTokens(core.MaxTokens))
}
- provider := canonicalProvider(core.Provider)
+ provider := llmutils.CanonicalProvider(core.Provider)
entries := providers.CodeActionConfigs
if len(entries) == 0 {
entries = []appconfig.SurfaceConfig{{Provider: core.Provider, Model: strings.TrimSpace(llmutils.DefaultModelForProvider(fullCfg, provider))}}
}
primary := entries[0]
if strings.TrimSpace(primary.Provider) != "" {
- provider = canonicalProvider(primary.Provider)
+ provider = llmutils.CanonicalProvider(primary.Provider)
}
model := strings.TrimSpace(primary.Model)
if model == "" {
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
index 5b8bbc2..84cb9b1 100644
--- a/internal/hexaiaction/run.go
+++ b/internal/hexaiaction/run.go
@@ -16,6 +16,38 @@ import (
"codeberg.org/snonux/hexai/internal/tmux"
)
+// tmuxActionError formats an error with the hexai-tmux-action prefix for stderr output.
+type tmuxActionError struct {
+ inner error
+}
+
+func (e tmuxActionError) Error() string {
+ return logging.AnsiBase + "hexai-tmux-action: " + e.inner.Error() + logging.AnsiReset
+}
+
+func (e tmuxActionError) Unwrap() error {
+ return e.inner
+}
+
+// logTmuxActionError logs an error to stderr with the hexai-tmux-action prefix.
+func logTmuxActionError(stderr io.Writer, err error) error {
+ _, _ = fmt.Fprintf(stderr, "%v\n", tmuxActionError{err})
+ return err
+}
+
+// requireInput validates that input selection is not empty.
+func requireInput(sel string) error {
+ if strings.TrimSpace(sel) == "" {
+ return fmt.Errorf("no input provided on stdin")
+ }
+ return nil
+}
+
+// logAndReturnError logs an error to stderr and returns it (hexaiaction pattern).
+func logAndReturnError(stderr io.Writer, err error) error {
+ return logTmuxActionError(stderr, err)
+}
+
type configPathKey struct{}
type actionChoice struct {
diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go
index 806f824..9c7ba73 100644
--- a/internal/hexaicli/run.go
+++ b/internal/hexaicli/run.go
@@ -55,7 +55,7 @@ func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) {
if provider == "" {
provider = cfg.Provider
}
- provider = canonicalProvider(provider)
+ provider = llmutils.CanonicalProvider(provider)
derived := llmutils.ConfigForProvider(cfg, provider, entry.Model)
req := buildCLIRequest(entry, provider, derived)
jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, cfg: derived, req: req})
@@ -92,10 +92,6 @@ func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig
return llmutils.ResolveTemperature(provider, model, entry.Temperature, cfg.CodingTemperature)
}
-func canonicalProvider(name string) string {
- return llmutils.CanonicalProvider(name)
-}
-
// 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 {
@@ -461,10 +457,29 @@ func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) {
return filtered, nil
}
+// stater is the subset of *os.File needed to detect piped vs terminal input.
+type stater interface {
+ Stat() (os.FileInfo, error)
+}
+
+// isPipedInput reports whether stdin is a pipe or file (not a terminal).
+// For *os.File it checks the file mode; for any other io.Reader it
+// optimistically returns true since non-file readers are typically
+// in-memory buffers used in tests.
+func isPipedInput(stdin io.Reader) bool {
+ if f, ok := stdin.(stater); ok {
+ fi, err := f.Stat()
+ return err == nil && (fi.Mode()&os.ModeCharDevice) == 0
+ }
+ // Non-file readers (e.g. strings.NewReader in tests) always have data.
+ return true
+}
+
// readInput reads from stdin and args, then combines them per CLI rules.
+// It uses the passed stdin reader (not os.Stdin) to detect piped input.
func readInput(stdin io.Reader, args []string) (string, error) {
var stdinData string
- if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 {
+ if isPipedInput(stdin) {
data, readErr := io.ReadAll(stdin)
if readErr != nil {
return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr)
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go
index 68d2954..ec5dbba 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -36,8 +36,11 @@ func (tmuxStatusSink) SetLLMStart(provider, model string) error {
return tmx.SetStatus(tmx.FormatLLMStartStatus(provider, model))
}
-func (tmuxStatusSink) SetGlobal(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) error {
- status := tmx.FormatGlobalStatusColored(reqs, rpm, sent, recv, provider, model, scopeRPM, scopeReqs, window)
+func (tmuxStatusSink) SetGlobal(gs lsp.GlobalStatus) error {
+ status := tmx.FormatGlobalStatusColored(
+ gs.Reqs, gs.RPM, gs.Sent, gs.Recv,
+ gs.Provider, gs.Model, gs.ScopeRPM, gs.ScopeReqs, gs.Window,
+ )
return tmx.SetStatus(status)
}
diff --git a/internal/lsp/completion_state.go b/internal/lsp/completion_state.go
index 692eafe..5c2716f 100644
--- a/internal/lsp/completion_state.go
+++ b/internal/lsp/completion_state.go
@@ -69,18 +69,16 @@ func (s *completionState) takePendingCompletion(key string) []CompletionItem {
return cpy
}
-// cacheGet returns the cached value for key. A read lock is sufficient for
-// cache misses. On a hit we must promote to a write lock so touchLocked can
-// update the LRU order.
+// cacheGet returns the cached value for key. Uses a single write lock to
+// avoid a TOCTOU race between the lookup and the LRU touch — the key could
+// be evicted between an RUnlock and a subsequent Lock promotion.
func (s *completionState) cacheGet(key string) (string, bool) {
- s.stateMu.RLock()
+ s.stateMu.Lock()
+ defer s.stateMu.Unlock()
v, ok := s.compCache[key]
- s.stateMu.RUnlock()
if !ok {
return "", false
}
- s.stateMu.Lock()
- defer s.stateMu.Unlock()
s.touchLocked(key)
return v, true
}
@@ -105,17 +103,15 @@ func (s *completionState) cachePut(key, value string) {
s.touchLocked(key)
}
+// touchLocked moves key to the end of the LRU order list.
+// Uses delete-and-append: remove the existing entry in-place, then append.
func (s *completionState) touchLocked(key string) {
- idx := -1
for i, k := range s.compCacheOrder {
if k == key {
- idx = i
+ s.compCacheOrder = append(s.compCacheOrder[:i], s.compCacheOrder[i+1:]...)
break
}
}
- if idx >= 0 {
- s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...)
- }
s.compCacheOrder = append(s.compCacheOrder, key)
}
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index 0d19f29..e6fba40 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llmutils"
)
func newTestServer() *Server {
@@ -42,7 +43,7 @@ func newTestServer() *Server {
docs: make(map[string]*document),
cfg: cfg,
codeActionSubsystem: codeActionSubsystem{
- llmClientRegistry: llmClientRegistry{llmProvider: canonicalProvider(cfg.Provider)},
+ llmClientRegistry: llmClientRegistry{llmProvider: llmutils.CanonicalProvider(cfg.Provider)},
},
completionSubsystem: completionSubsystem{completionState: completionState{}},
}
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index ad2f98d..3b3f8e0 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"unicode/utf8"
+
+ "codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) handle(req Request) {
@@ -18,10 +20,6 @@ func (s *Server) handle(req Request) {
}
}
-// handleInitialize moved to handlers_init.go
-
-// llmRequestOpts moved to handlers_utils.go
-
// instructionFromSelection extracts the first instruction from selection text.
// Preference order on each line: strict ;text; marker (no inner spaces), then
// a line comment (//, #, --). Returns the instruction string and the selection
@@ -95,99 +93,11 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned
return best.text, cleaned, true
}
-// diagnosticsInRange parses the CodeAction context and returns diagnostics
-// that overlap the given selection range. If the context is missing or does
-// not contain diagnostics, returns an empty slice.
-// CodeAction-related handlers and helpers moved to handlers_codeaction.go
-
-// extractRangeText moved to handlers_utils.go
-
-// handleInitialized moved to handlers_init.go
-
-// handleShutdown moved to handlers_init.go
-
-// handleExit moved to handlers_init.go
-
-// handleDidOpen moved to handlers_document.go
-
-// handleDidChange moved to handlers_document.go
-
-// handleDidClose moved to handlers_document.go
-
-// handleCompletion moved to handlers_completion.go
-
func (s *Server) reply(id json.RawMessage, result any, err *RespError) {
resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err}
s.writeMessage(resp)
}
-// docBeforeAfter returns the full document text split at the given position.
-// The returned strings are the text before the cursor (inclusive of anything
-// left of the position) and the text after the cursor.
-// docBeforeAfter moved to handlers_document.go
-
-// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter
-// if provided by the client; when absent it returns zeros.
-// extractTriggerInfo moved to handlers_completion.go
-
-// --- in-editor chat (";C ...") ---
-
-// detectAndHandleChat scans the current document for any line that starts with
-// ";C" and appears to be awaiting a response (i.e., followed by a blank line
-// and no non-empty answer line yet). If found, it asks the LLM and inserts the
-// answer below the blank line, leaving exactly one empty line between prompt
-// and response.
-// detectAndHandleChat moved to handlers_document.go
-
-// applyChatEdits removes the triggering punctuation at end of the line and
-// inserts two newlines followed by a new line with the response prefixed.
-// applyChatEdits moved to handlers_document.go
-
-// buildChatHistory walks upwards from the current line to collect the most recent
-// Q/A pairs in the in-editor transcript. It returns messages in chronological order
-// ending with the current user prompt. Limits to a small number of pairs to control tokens.
-// buildChatHistory moved to handlers_document.go
-
-// stripTrailingTrigger removes a single trailing punctuation from the set
-// [?,!,:] or both semicolons if present at end, mirroring the inline trigger rules.
-// stripTrailingTrigger moved to handlers_document.go
-
-// clientApplyEdit sends a workspace/applyEdit request to the client.
-// clientApplyEdit moved to handlers_document.go
-
-// nextReqID returns a unique json.RawMessage id for server-initiated requests.
-// nextReqID moved to handlers_document.go
-
-// --- completion helpers ---
-
-// buildDocString moved to handlers_completion.go
-
-// logCompletionContext moved to handlers_completion.go
-
-// tryLLMCompletion moved to handlers_completion.go
-
-// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion.
-// parseManualInvoke moved to handlers_completion.go
-
-// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL.
-// shouldSuppressForChatTriggerEOL moved to handlers_completion.go
-
-// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply.
-// prefixHeuristicAllows moved to handlers_completion.go
-
-// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
-// tryProviderNativeCompletion moved to handlers_completion.go
-
-// buildCompletionMessages constructs the LLM messages for completion.
-// buildCompletionMessages moved to handlers_completion.go
-
-// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules.
-// postProcessCompletion moved to handlers_completion.go
-
-// busyCompletionItem builds a visible, non-inserting completion item indicating
-// that an LLM request is already in flight.
-// removed: previous single in-flight LLM busy gate and busy item
-
func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string {
// Normalize left-of-cursor by trimming trailing spaces/tabs
idx := p.Position.Character
@@ -246,10 +156,14 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &ctx)
+ if err := json.Unmarshal(raw, &ctx); err != nil {
+ logging.Logf("lsp ", "handleCompletion: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(p.Context)
- _ = json.Unmarshal(b, &ctx)
+ if err := json.Unmarshal(b, &ctx); err != nil {
+ logging.Logf("lsp ", "handleCompletion: unmarshal context: %v", err)
+ }
}
// If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'),
// do not treat as a trigger source.
@@ -330,78 +244,6 @@ func containsAny(haystack string, seqs []string) bool {
return false
}
-// small helpers to keep tryLLMCompletion short
-// LLM stats helpers moved to handlers_utils.go
-
-// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
-// Supported form (inclusive):
-// - ";...;" where there is no space immediately after the first ';'
-// and no space immediately before the last ';'. An optional single space
-// after the trailing ';' is also removed for cleanliness.
-//
-// Multiple markers per line are supported.
-// Inline prompt removal helpers moved to handlers_utils.go
-
-// inParamList moved to handlers_utils.go
-
-// buildPrompts moved to handlers_utils.go
-
-// computeTextEditAndFilter moved to handlers_utils.go
-
-// computeWordStart moved to handlers_utils.go
-
-// isIdentChar moved to handlers_utils.go
-
-// lineHasInlinePrompt returns true if the line contains an inline strict
-// semicolon marker ;text; (no spaces at boundaries) or a double-semicolon
-// pattern recognized by hasDoubleSemicolonTrigger.
-// lineHasInlinePrompt moved to handlers_utils.go
-
-// leadingIndent returns the run of leading spaces/tabs from the provided line.
-// leadingIndent moved to handlers_utils.go
-
-// applyIndent prefixes each non-empty line of suggestion with the given indent
-// unless it already starts with that indent.
-// applyIndent moved to handlers_utils.go
-
-// isBareDoubleSemicolon reports whether the line contains a standalone
-// double-semicolon marker with no inline content (";;" possibly with only
-// whitespace after it). It explicitly excludes the valid form ";;text;".
-// isBareDoubleSemicolon moved to handlers_utils.go
-
-// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g.,
-// "name :=") from the beginning of the model suggestion when that same prefix
-// already appears immediately to the left of the cursor on the current line.
-// Also handles simple '=' assignments.
-// stripDuplicateAssignmentPrefix moved to handlers_utils.go
-
-// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated
-// at the beginning of its suggestion. It compares the entire text to the left of the
-// cursor (prefixBeforeCursor) against the suggestion, trimming whitespace appropriately,
-// and strips the longest sensible overlap. This prevents cases like:
-//
-// prefix: "func New "
-// suggestion:"func New() *Type"
-//
-// resulting in duplicates like "func New func New() *Type".
-// stripDuplicateGeneralPrefix moved to handlers_utils.go
-
-// isIdentBoundary moved to handlers_utils.go
-
-// stripCodeFences removes surrounding Markdown code fences from a model
-// response when the entire output is wrapped, e.g. starting with "```go" or
-// "```" and ending with "```". It returns the inner content unchanged.
-// stripCodeFences moved to handlers_utils.go
-
-// stripInlineCodeSpan returns only the contents of the first inline backtick
-// code span if present, e.g., "some text `x := y()` more" -> "x := y()".
-// If no matching pair of backticks exists, it returns the input unchanged.
-// This is intended for code completion responses where the model may wrap a
-// small snippet in single backticks among prose.
-// stripInlineCodeSpan moved to handlers_utils.go
-
-// labelForCompletion moved to handlers_utils.go
-
func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem {
return []CompletionItem{{
Label: "hexai-complete",
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 8b16fcd..1d8a36f 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -25,6 +25,15 @@ type codeActionPayload struct {
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
+type customActionPayload struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
+}
+
// CodeActionHandler builds and resolves code actions for a specific action type.
type CodeActionHandler interface {
Build(s *Server, p CodeActionParams, selection string) []CodeAction
@@ -103,58 +112,64 @@ func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams,
return
}
diags := s.diagnosticsInRange(p.Context, p.Range)
+
for _, ca := range customs {
title := strings.TrimSpace(ca.Title)
if title == "" {
continue
}
+
scope := strings.TrimSpace(strings.ToLower(ca.Scope))
if scope == "diagnostics" {
- if len(diags) == 0 {
- continue
- }
- payload := struct {
- Type string `json:"type"`
- ID string `json:"id"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- Diagnostics []Diagnostic `json:"diagnostics"`
- }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags}
- raw, ok := s.marshalCodeActionData(payload)
- if !ok {
- continue
- }
- kind := ca.Kind
- if strings.TrimSpace(kind) == "" {
- kind = "quickfix"
- }
- *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
- continue
- }
- // default: selection
- if strings.TrimSpace(sel) == "" {
- continue
- }
- payload := struct {
- Type string `json:"type"`
- ID string `json:"id"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel}
- raw, ok := s.marshalCodeActionData(payload)
- if !ok {
- continue
- }
- kind := ca.Kind
- if strings.TrimSpace(kind) == "" {
- kind = "refactor"
+ s.appendCustomActionForDiagnostics(actions, p, sel, diags, ca, title)
+ } else {
+ s.appendCustomActionForSelection(actions, p, sel, ca, title)
}
+ }
+}
+
+func (s *Server) appendCustomActionForDiagnostics(actions *[]CodeAction, p CodeActionParams, sel string, diags []Diagnostic, ca appconfig.CustomAction, title string) {
+ if len(diags) == 0 {
+ return
+ }
+ payload := customActionPayload{
+ Type: "custom",
+ ID: ca.ID,
+ URI: p.TextDocument.URI,
+ Range: p.Range,
+ Selection: sel,
+ Diagnostics: diags,
+ }
+ if raw, ok := s.marshalCodeActionData(payload); ok {
+ kind := s.resolveCodeActionKind(ca.Kind, "quickfix")
+ *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
+ }
+}
+
+func (s *Server) appendCustomActionForSelection(actions *[]CodeAction, p CodeActionParams, sel string, ca appconfig.CustomAction, title string) {
+ if strings.TrimSpace(sel) == "" {
+ return
+ }
+ payload := customActionPayload{
+ Type: "custom",
+ ID: ca.ID,
+ URI: p.TextDocument.URI,
+ Range: p.Range,
+ Selection: sel,
+ }
+ if raw, ok := s.marshalCodeActionData(payload); ok {
+ kind := s.resolveCodeActionKind(ca.Kind, "refactor")
*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
}
}
+func (s *Server) resolveCodeActionKind(kind, fallback string) string {
+ if strings.TrimSpace(kind) == "" {
+ return fallback
+ }
+ return kind
+}
+
func (s *Server) codeActionHandlers() map[string]CodeActionHandler {
return map[string]CodeActionHandler{
"rewrite": codeActionHandler{build: buildRewriteActions, resolve: resolveRewriteCodeAction},
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index 8ef67ab..aa22fc2 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -10,6 +10,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/llmutils"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/stats"
)
@@ -88,10 +89,14 @@ func extractTriggerInfo(p CompletionParams) (kind int, ch string) {
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &ctx)
+ if err := json.Unmarshal(raw, &ctx); err != nil {
+ logging.Logf("lsp ", "extractTriggerInfo: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(p.Context)
- _ = json.Unmarshal(b, &ctx)
+ if err := json.Unmarshal(b, &ctx); err != nil {
+ logging.Logf("lsp ", "extractTriggerInfo: unmarshal context: %v", err)
+ }
}
return ctx.TriggerKind, ctx.TriggerCharacter
}
@@ -283,7 +288,7 @@ func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan,
modelKey := spec.effectiveModel(client.DefaultModel())
providerKey := spec.provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelKey
if cached, ok := s.completionCacheGet(cacheKey); ok && strings.TrimSpace(cached) != "" {
@@ -326,7 +331,7 @@ func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan,
detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed)
providerKey := spec.provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed
s.completionCachePut(cacheKey, cleaned)
@@ -343,10 +348,14 @@ func parseManualInvoke(ctx any) bool {
TriggerKind int `json:"triggerKind"`
}
if raw, ok := ctx.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &c)
+ if err := json.Unmarshal(raw, &c); err != nil {
+ logging.Logf("lsp ", "parseManualInvoke: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(ctx)
- _ = json.Unmarshal(b, &c)
+ if err := json.Unmarshal(b, &c); err != nil {
+ logging.Logf("lsp ", "parseManualInvoke: unmarshal context: %v", err)
+ }
}
return c.TriggerKind == 1
}
@@ -429,7 +438,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
})
provider := spec.provider
if provider == "" {
- provider = canonicalProvider(cfg.Provider)
+ provider = llmutils.CanonicalProvider(cfg.Provider)
}
logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path)
ctx2, cancel2 := context.WithTimeout(ctx, 15*time.Second)
@@ -476,7 +485,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed)
providerKey := provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed
s.completionCachePut(cacheKey, cleaned)
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index bede7a0..66e2ed1 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -4,6 +4,7 @@ package lsp
import (
"context"
"fmt"
+ "os"
"strings"
"time"
"unicode/utf8"
@@ -60,7 +61,7 @@ func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec {
if provider == "" {
provider = cfg.Provider
}
- provider = canonicalProvider(provider)
+ provider = llmutils.CanonicalProvider(provider)
fallbackModel := entry.Model
if fallbackModel == "" {
fallbackModel = strings.TrimSpace(llmutils.DefaultModelForProvider(cfg, provider))
@@ -87,7 +88,7 @@ func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec {
specs := s.buildRequestSpecs(surface)
if len(specs) == 0 {
cfg := s.currentConfig()
- provider := canonicalProvider(cfg.Provider)
+ provider := llmutils.CanonicalProvider(cfg.Provider)
fallback := strings.TrimSpace(llmutils.DefaultModelForProvider(cfg, provider))
return requestSpec{provider: provider, fallbackModel: fallback, options: []llm.RequestOption{llm.WithMaxTokens(s.maxTokens())}}
}
@@ -99,10 +100,6 @@ func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec {
return s.primaryRequestSpec(surface)
}
-func canonicalProvider(name string) string {
- return llmutils.CanonicalProvider(name)
-}
-
func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig {
switch surface {
case surfaceCompletion:
@@ -173,7 +170,17 @@ func (s *Server) logLLMStats(model string) {
}
scopeReqs := snap.ScopeReqs(provider, modelName)
scopeRPM := snap.ScopeRPM(provider, modelName)
- s.emitGlobalStatus(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, modelName, scopeRPM, scopeReqs, snap.Window)
+ s.emitGlobalStatus(GlobalStatus{
+ Reqs: snap.Global.Reqs,
+ RPM: snap.RPM,
+ Sent: snap.Global.Sent,
+ Recv: snap.Global.Recv,
+ Provider: provider,
+ Model: modelName,
+ ScopeRPM: scopeRPM,
+ ScopeReqs: scopeReqs,
+ Window: snap.Window,
+ })
}
}
}
@@ -342,17 +349,8 @@ func applyIndent(indent, suggestion string) string {
// opening marker and no space immediately before the closing marker. Returns the
// text between markers, the start index, the end index just after closing, and ok.
func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) {
- if openStr == "" {
- openStr = string(open)
- }
- if openStr == "" {
- return "", 0, 0, false
- }
- openChar := open
- if openChar == 0 {
- openChar = openStr[0]
- }
- doubleSeqs := doubleOpenSequences(openStr, openChar, close)
+ openChar, doubleSeqs := prepareInlineTagParsing(openStr, open, close)
+
pos := 0
for pos < len(line) {
j := strings.IndexByte(line[pos:], openChar)
@@ -364,10 +362,12 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
pos = j + 1
continue
}
+
contentStart := j + len(openStr)
if contentStart >= len(line) {
return "", 0, 0, false
}
+
doubleHit := false
for _, seq := range doubleSeqs {
if strings.HasPrefix(line[j:], seq) {
@@ -379,6 +379,7 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
break
}
}
+
next := line[contentStart]
if next == ' ' {
pos = contentStart + 1
@@ -388,26 +389,55 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
pos = contentStart + 1
continue
}
+
k := strings.IndexByte(line[contentStart:], close)
if k < 0 {
return "", 0, 0, false
}
closeIdx := contentStart + k
- if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' {
+
+ if closeIdx > contentStart && line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
+
inner := strings.TrimSpace(line[contentStart:closeIdx])
if inner == "" {
pos = closeIdx + 1
continue
}
- end := closeIdx + 1
- return inner, j, end, true
+
+ return inner, j, closeIdx + 1, true
}
return "", 0, 0, false
}
+// prepareInlineTagParsing initializes parsing state. Returns openChar and doubleSeqs.
+func prepareInlineTagParsing(openStr string, open, close byte) (byte, []string) {
+ if openStr == "" {
+ openStr = string(open)
+ }
+ if openStr == "" {
+ return 0, nil
+ }
+ openChar := open
+ if openChar == 0 {
+ openChar = openStr[0]
+ }
+ return openChar, doubleOpenSequences(openStr, openChar, close)
+}
+
+// handleDoubleSequence checks for and handles double-open sequences.
+// Returns (doubleHit, adjustedContentStart).
+func handleDoubleSequence(line string, markerPos int, doubleSeqs []string, contentStart int, openStr string) (bool, int) {
+ for _, seq := range doubleSeqs {
+ if strings.HasPrefix(line[markerPos:], seq) {
+ return true, contentStart + len(seq) - len(openStr)
+ }
+ }
+ return false, contentStart
+}
+
// isBareDoubleSemicolon reports whether the line contains a standalone
// double-semicolon marker with no inline content (";;" possibly with only
// whitespace after it). It explicitly excludes the valid form ";;text;".
@@ -622,62 +652,86 @@ func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, c
return collectSemicolonMarkers(line, lineNum, openStr, open, close)
}
+// hasDoubleOpenTrigger reports whether line contains a valid double-open trigger.
func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool {
- if openStr == "" {
- openStr = string(open)
- }
- if openStr == "" {
- return false
- }
- seqs := doubleOpenSequences(openStr, open, close)
+ seqs := validDoubleOpenSequences(openStr, open, close)
if len(seqs) == 0 {
return false
}
+
pos := 0
for pos < len(line) {
- found := -1
- var seq string
- for _, cand := range seqs {
- if cand == "" {
- continue
- }
- if idx := strings.Index(line[pos:], cand); idx >= 0 {
- abs := pos + idx
- if found < 0 || abs < found {
- found = abs
- seq = cand
- }
- }
- }
- if found < 0 {
+ foundAt, seq := findEarliestSequence(line, pos, seqs)
+ if foundAt < 0 {
return false
}
- contentStart := found + len(seq)
+
+ contentStart := foundAt + len(seq)
if contentStart >= len(line) {
return false
}
+
first := line[contentStart]
if first == ' ' || first == close || first == open {
pos = contentStart + 1
continue
}
+
if contentStart+1 >= len(line) {
return false
}
+
k := strings.IndexByte(line[contentStart+1:], close)
if k < 0 {
return false
}
+
closeIdx := contentStart + 1 + k
- if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' {
+ if closeIdx > 0 && line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
+
return true
}
+
return false
}
+// validDoubleOpenSequences returns non-empty double-open sequences.
+func validDoubleOpenSequences(openStr string, open, close byte) []string {
+ seqs := doubleOpenSequences(openStr, open, close)
+ var result []string
+ for _, s := range seqs {
+ if s != "" {
+ result = append(result, s)
+ }
+ }
+ return result
+}
+
+// findEarliestSequence finds the earliest sequence in line starting at pos.
+// Returns (position, sequence) or (-1, "") if none found.
+func findEarliestSequence(line string, pos int, seqs []string) (int, string) {
+ foundAt := -1
+ var foundSeq string
+
+ for _, cand := range seqs {
+ if idx := strings.Index(line[pos:], cand); idx >= 0 {
+ abs := pos + idx
+ if foundAt < 0 || abs < foundAt {
+ foundAt = abs
+ foundSeq = cand
+ }
+ }
+ }
+
+ if foundAt < 0 {
+ return -1, ""
+ }
+ return foundAt, foundSeq
+}
+
func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit {
if openStr == "" {
openStr = string(open)
@@ -755,3 +809,30 @@ func utf16OffsetToByteOffset(s string, utf16Offset int) int {
}
return byteIdx
}
+
+// --- Error handling helpers ---
+
+// fileOpenError formats an error for file opening failures.
+// Wraps the original error with path context.
+func fileOpenError(path string, err error) error {
+ return fmt.Errorf("cannot open %s: %w", path, err)
+}
+
+// ensureDirectory creates a directory if it doesn't exist.
+// Returns an error if directory creation fails.
+func ensureDirectory(path string) error {
+ return os.MkdirAll(path, 0o755)
+}
+
+// directoryCreateError formats an error for directory creation failures.
+func directoryCreateError(path string, err error) error {
+ return fmt.Errorf("cannot create %s: %w", path, err)
+}
+
+// requireLLMClient checks if LLM client is available, returning an error if not.
+func requireLLMClient(client llm.Client) error {
+ if client == nil {
+ return fmt.Errorf("llm client unavailable")
+ }
+ return nil
+}
diff --git a/internal/lsp/llm_client_registry.go b/internal/lsp/llm_client_registry.go
index 53fa25f..6b9c722 100644
--- a/internal/lsp/llm_client_registry.go
+++ b/internal/lsp/llm_client_registry.go
@@ -6,6 +6,7 @@ import (
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/llmutils"
"codeberg.org/snonux/hexai/internal/logging"
)
@@ -25,9 +26,9 @@ func newLLMClientRegistry() llmClientRegistry {
}
func (r *llmClientRegistry) applyOptions(client llm.Client, configuredProvider string) {
- provider := canonicalProvider(configuredProvider)
+ provider := llmutils.CanonicalProvider(configuredProvider)
if client != nil {
- if name := canonicalProvider(client.Name()); name != "" {
+ if name := llmutils.CanonicalProvider(client.Name()); name != "" {
provider = name
}
}
@@ -45,13 +46,13 @@ func (r *llmClientRegistry) current() llm.Client {
}
func (r *llmClientRegistry) clientFor(spec requestSpec, cfg appconfig.App, build llmClientBuilder) llm.Client {
- provider := canonicalProvider(spec.provider)
+ provider := llmutils.CanonicalProvider(spec.provider)
r.clientsMu.RLock()
baseProvider := r.llmProvider
baseClient := r.llmClient
if baseClient != nil && strings.TrimSpace(baseProvider) == "" {
- baseProvider = canonicalProvider(baseClient.Name())
+ baseProvider = llmutils.CanonicalProvider(baseClient.Name())
}
if provider == "" {
provider = baseProvider
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 9c476ed..c266e91 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -77,10 +77,24 @@ type llmStatsSubsystem struct {
startTime time.Time
}
+// GlobalStatus bundles the fields for a global status update,
+// replacing a long parameter list.
+type GlobalStatus struct {
+ Reqs int64
+ RPM float64
+ Sent int64
+ Recv int64
+ Provider string
+ Model string
+ ScopeRPM float64
+ ScopeReqs int64
+ Window time.Duration
+}
+
// StatusSink receives status updates from the LSP server.
type StatusSink interface {
SetLLMStart(provider, model string) error
- SetGlobal(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) error
+ SetGlobal(gs GlobalStatus) error
}
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
@@ -334,9 +348,9 @@ func (s *Server) emitLLMStartStatus(provider, model string) {
}
}
-func (s *Server) emitGlobalStatus(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) {
+func (s *Server) emitGlobalStatus(gs GlobalStatus) {
if s.statusSink != nil {
- _ = s.statusSink.SetGlobal(reqs, rpm, sent, recv, provider, model, scopeRPM, scopeReqs, window)
+ _ = s.statusSink.SetGlobal(gs)
}
}
diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go
index 1597b36..6cabebd 100644
--- a/internal/promptstore/store.go
+++ b/internal/promptstore/store.go
@@ -10,6 +10,8 @@ import (
"strings"
"sync"
"time"
+
+ "codeberg.org/snonux/hexai/internal/textutil"
)
// PromptStore defines the interface for prompt storage operations.
@@ -182,13 +184,14 @@ func (s *JSONLStore) Create(prompt *Prompt) error {
if err != nil {
return fmt.Errorf("open user.jsonl: %w", err)
}
- defer f.Close()
+ defer func() { _ = f.Close() }() // best-effort on error paths
if _, err := f.Write(data); err != nil {
return fmt.Errorf("write user.jsonl: %w", err)
}
- return nil
+ // Check Close error to catch deferred-write failures (e.g. disk full).
+ return f.Close()
}
// Update modifies an existing prompt in user.jsonl.
@@ -377,7 +380,7 @@ func (s *JSONLStore) loadPromptsFromFile(filename string) ([]Prompt, error) {
}
var prompts []Prompt
- lines := splitLines(data)
+ lines := textutil.SplitLinesBytes(data)
for i, line := range lines {
if len(line) == 0 {
continue
@@ -416,27 +419,6 @@ func (s *JSONLStore) writePromptsToFile(filename string, prompts []Prompt) error
return nil
}
-// splitLines splits data into lines (handles both \n and \r\n).
-// Copied from tmuxedit/history.go pattern.
-func splitLines(data []byte) [][]byte {
- var lines [][]byte
- start := 0
- for i := 0; i < len(data); i++ {
- if data[i] == '\n' {
- end := i
- if end > start && data[end-1] == '\r' {
- end--
- }
- lines = append(lines, data[start:end])
- start = i + 1
- }
- }
- if start < len(data) {
- lines = append(lines, data[start:])
- }
- return lines
-}
-
// backupUserPrompts creates a timestamped backup of user.jsonl before any write operation.
// Automatically manages backup retention based on maxBackups setting.
func (s *JSONLStore) backupUserPrompts() error {
diff --git a/internal/stats/stats.go b/internal/stats/stats.go
index 742a5be..d79025a 100644
--- a/internal/stats/stats.go
+++ b/internal/stats/stats.go
@@ -154,11 +154,14 @@ func lockStatsFile(ctx context.Context, dir string) (func() error, error) {
}
// readStatsFile loads the on-disk stats file, returning a fresh File if it is
-// missing or has an incompatible version.
+// missing, corrupt, or has an incompatible version.
func readStatsFile(path string) File {
var sf File
if b, err := os.ReadFile(path); err == nil {
- _ = json.Unmarshal(b, &sf)
+ if uerr := json.Unmarshal(b, &sf); uerr != nil {
+ fmt.Fprintf(os.Stderr, "stats: corrupt stats file %s: %v, starting fresh\n", path, uerr)
+ return File{Version: fileVersion}
+ }
}
if sf.Version != fileVersion {
sf = File{Version: fileVersion}
@@ -233,7 +236,9 @@ func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) {
}
}
-// Snapshot reads and aggregates events within the configured window.
+// TakeSnapshot reads the stats file and aggregates events within the stored
+// window (falling back to the process-level Window() if the file has none).
+// This is a pure read — it does not mutate global state.
func TakeSnapshot() (Snapshot, error) {
dir, err := CacheDir()
if err != nil {
@@ -254,8 +259,6 @@ func TakeSnapshot() (Snapshot, error) {
win := time.Duration(sf.WindowSeconds) * time.Second
if win <= 0 {
win = Window()
- } else {
- SetWindow(win) // align process with file window if changed elsewhere
}
cutoff := time.Now().Add(-win)
snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win}
diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go
index 1e9da3c..e68353d 100644
--- a/internal/textutil/textutil.go
+++ b/internal/textutil/textutil.go
@@ -1,6 +1,30 @@
package textutil
-import "strings"
+import (
+ "strings"
+)
+
+// SplitLinesBytes splits data into lines, handling both \n and \r\n endings.
+// The returned slices share the underlying data array.
+func SplitLinesBytes(data []byte) [][]byte {
+ var lines [][]byte
+ start := 0
+ for i := 0; i < len(data); i++ {
+ if data[i] == '\n' {
+ end := i
+ if end > start && data[end-1] == '\r' {
+ end--
+ }
+ lines = append(lines, data[start:end])
+ start = i + 1
+ }
+ }
+ // Handle last line if it doesn't end with newline
+ if start < len(data) {
+ lines = append(lines, data[start:])
+ }
+ return lines
+}
// RenderTemplate performs simple {{var}} replacement in a template string.
func RenderTemplate(t string, vars map[string]string) string {
diff --git a/internal/tmuxedit/history.go b/internal/tmuxedit/history.go
index eab4db2..eac3114 100644
--- a/internal/tmuxedit/history.go
+++ b/internal/tmuxedit/history.go
@@ -9,6 +9,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/textutil"
)
// HistoryEntry represents a single submission to the AI agent via tmux popup.
@@ -52,14 +53,15 @@ func AppendHistory(text, agent, cwd string) error {
if err != nil {
return fmt.Errorf("cannot open history file: %w", err)
}
- defer f.Close()
+ defer func() { _ = f.Close() }() // best-effort on error paths
// Write entry
if _, err := f.Write(data); err != nil {
return fmt.Errorf("cannot write history entry: %w", err)
}
- return nil
+ // Check Close error to catch deferred-write failures (e.g. disk full).
+ return f.Close()
}
// GetHistory retrieves the most recent history entries (up to limit).
@@ -84,7 +86,7 @@ func GetHistory(limit int) ([]HistoryEntry, error) {
// Parse JSONL line by line
var entries []HistoryEntry
- lines := splitLines(data)
+ lines := textutil.SplitLinesBytes(data)
for i, line := range lines {
if len(line) == 0 {
continue // Skip empty lines
@@ -107,25 +109,3 @@ func GetHistory(limit int) ([]HistoryEntry, error) {
return entries, nil
}
-
-// splitLines splits data into lines (handles both \n and \r\n)
-func splitLines(data []byte) [][]byte {
- var lines [][]byte
- start := 0
- for i := 0; i < len(data); i++ {
- if data[i] == '\n' {
- // Include everything before the newline (excluding \r if present)
- end := i
- if end > start && data[end-1] == '\r' {
- end--
- }
- lines = append(lines, data[start:end])
- start = i + 1
- }
- }
- // Handle last line if it doesn't end with newline
- if start < len(data) {
- lines = append(lines, data[start:])
- }
- return lines
-}
diff --git a/internal/tmuxedit/history_test.go b/internal/tmuxedit/history_test.go
index b9d59d3..f6d6d7d 100644
--- a/internal/tmuxedit/history_test.go
+++ b/internal/tmuxedit/history_test.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"testing"
"time"
+
+ "codeberg.org/snonux/hexai/internal/textutil"
)
func TestAppendHistory(t *testing.T) {
@@ -164,7 +166,7 @@ func TestSplitLines(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := splitLines([]byte(tt.input))
+ got := textutil.SplitLinesBytes([]byte(tt.input))
gotStr := make([]string, len(got))
for i, b := range got {
gotStr[i] = string(b)
diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go
index efa503b..da484df 100644
--- a/internal/tmuxedit/run.go
+++ b/internal/tmuxedit/run.go
@@ -112,22 +112,23 @@ func loadConfig(configPath string) appconfig.App {
var debugLog *log.Logger
// initDebugLog creates a debug log file in the state directory
-// (~/.local/state/hexai/hexai-tmux-edit.log). Returns an error if the
-// state directory cannot be resolved. Silently skips logging if the
-// log file itself cannot be opened.
-func initDebugLog() error {
+// (~/.local/state/hexai/hexai-tmux-edit.log). Returns a closer for the
+// log file handle and an error if the state directory cannot be resolved.
+// Silently skips logging (returns a no-op closer) if the log file cannot
+// be opened.
+func initDebugLog() (func(), error) {
stateDir, err := appconfig.StateDir()
if err != nil {
- return fmt.Errorf("cannot create state directory: %w", err)
+ return nil, fmt.Errorf("cannot create state directory: %w", err)
}
logPath := filepath.Join(stateDir, "hexai-tmux-edit.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
- return nil
+ return func() {}, nil
}
debugLog = log.New(f, "", log.LstdFlags|log.Lmicroseconds)
- return nil
+ return func() { _ = f.Close() }, nil
}
func dbg(format string, args ...any) {
@@ -140,9 +141,11 @@ func dbg(format string, args ...any) {
// It resolves the agent (by name or auto-detect), extracts the current
// prompt, opens the editor popup, then clears and sends the result.
func runWithConfig(opts Options, cfg appconfig.App) error {
- if err := initDebugLog(); err != nil {
+ closeLog, err := initDebugLog()
+ if err != nil {
return fmt.Errorf("init debug log: %w", err)
}
+ defer closeLog()
dbg("=== hexai-tmux-edit start ===")
dbg("opts: pane=%q agent=%q config=%q", opts.Pane, opts.Agent, opts.ConfigPath)