diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-10 09:52:34 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-10 09:52:34 +0200 |
| commit | 5bc2723325131e8432ad5a47d5e9cb245e9f0b28 (patch) | |
| tree | c18bb5dafdcab806b3d8bb0912d083b28741d24c | |
| parent | 17220d71f2af54f875ba1a86f489e5af6d7ad189 (diff) | |
Add tmux popup history storage and consolidate state files to XDG_STATE_HOMEv0.19.0
- Add StateDir() helper function respecting XDG_STATE_HOME (~/.local/state/hexai/)
- Implement JSONL-based history storage for tmux popup submissions
- New history.go with AppendHistory() and GetHistory() functions
- Store timestamp, agent name, cwd, and submitted text
- Comprehensive unit tests for history functionality
- Integrate history append into tmux edit workflow after successful submission
- Move logs from /tmp/ to persistent state directory:
- hexai-lsp.log: ~/.local/state/hexai/hexai-lsp.log
- hexai-tmux-edit.log: ~/.local/state/hexai/hexai-tmux-edit.log
- Update README.md with File Locations section documenting XDG directories
- Fix pre-existing test failures by isolating project config in unit tests
- Panic on state directory creation failure instead of silent fallback
All unit tests pass. Follows XDG Base Directory Specification for proper
state file management with persistence across reboots.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | cmd/hexai-lsp/main.go | 15 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 21 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 54 | ||||
| -rw-r--r-- | internal/tmuxedit/history.go | 131 | ||||
| -rw-r--r-- | internal/tmuxedit/history_test.go | 196 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 27 | ||||
| -rw-r--r-- | internal/version.go | 2 |
8 files changed, 450 insertions, 13 deletions
@@ -31,3 +31,20 @@ It has got improved capabilities for Go code understanding (for example, create ## Tmux Status Line See the [tmux integration guide](docs/tmux.md) for details on configuring the status line. + +## File Locations + +hexai follows the XDG Base Directory Specification: + +- **Configuration:** `~/.config/hexai/config.toml` (or `$XDG_CONFIG_HOME/hexai/`) + - Global settings, provider credentials, and preferences + - Project-specific: `.hexaiconfig.toml` at repository root +- **Cache:** `~/.cache/hexai/` (or `$XDG_CACHE_HOME/hexai/`) + - `stats.json` - LLM usage tracking (regenerable) + - `stats.lock` - File lock for stats access +- **State & Logs:** `~/.local/state/hexai/` (or `$XDG_STATE_HOME/hexai/`) + - `tmux-edit-history.jsonl` - History of text submitted via tmux popup + - `hexai-lsp.log` - LSP server debug logs + - `hexai-tmux-edit.log` - Tmux edit debug logs +- **Temporary Files:** `/tmp/` (OS temp directory) + - `hexai-*.md` - Temporary editor workspaces (auto-deleted) diff --git a/cmd/hexai-lsp/main.go b/cmd/hexai-lsp/main.go index 3704cbf..828d0f8 100644 --- a/cmd/hexai-lsp/main.go +++ b/cmd/hexai-lsp/main.go @@ -14,7 +14,8 @@ import ( ) func main() { - logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") + defaultLog := defaultLogPath() + logPath := flag.String("log", defaultLog, "path to log file (optional)") defaultCfg := defaultConfigPath() configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultCfg)) showVersion := flag.Bool("version", false, "print version and exit") @@ -37,3 +38,15 @@ func defaultConfigPath() string { } return path } + +// defaultLogPath returns the default LSP log file path in the state directory. +// Falls back to /tmp if state directory cannot be determined. +// defaultLogPath returns the default LSP log file path in the state directory. +// Panics if state directory cannot be created. +func defaultLogPath() string { + stateDir, err := appconfig.StateDir() + if err != nil { + panic(fmt.Sprintf("cannot create state directory: %v", err)) + } + return fmt.Sprintf("%s/hexai-lsp.log", stateDir) +} diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 63b5ea5..f8c1827 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -1270,6 +1270,27 @@ func ConfigPath() (string, error) { return configPath, nil } +// StateDir returns the XDG state directory for hexai (~/.local/state/hexai by default). +// Creates the directory if it doesn't exist. This is used for persistent state data +// like logs and history that should survive reboots. +func StateDir() (string, error) { + stateHome := os.Getenv("XDG_STATE_HOME") + if stateHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find user home directory: %v", err) + } + stateHome = filepath.Join(home, ".local", "state") + } + + stateDir := filepath.Join(stateHome, "hexai") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return "", fmt.Errorf("cannot create state directory: %v", err) + } + + return stateDir, nil +} + // ProjectConfigFilename is the name of the per-project config file placed at a git repo root. const ProjectConfigFilename = ".hexaiconfig.toml" diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 4a1403f..ed7254c 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -56,9 +56,10 @@ func TestLoad_Defaults_NoLogger(t *testing.T) { func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) { clearHexaiEnv(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) logger := newLogger() - cfg := Load(logger) + cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir}) def := newDefaultConfig() if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines { t.Fatalf("expected defaults; got %+v want %+v", cfg, def) @@ -184,7 +185,7 @@ temperature = 0.0 withEnv(t, "HEXAI_PROVIDER_CLI", "ollama") logger := newLogger() - cfg := Load(logger) + cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir}) // Check overrides if cfg.MaxTokens != 321 || cfg.ContextMode != "always-full" || cfg.ContextWindowLines != 77 || cfg.MaxContextTokens != 888 { @@ -253,7 +254,7 @@ temperature = 0.0 } { t.Setenv(k, "") } - cfg2 := Load(logger) + cfg2 := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir}) if cfg2.MaxTokens != 123 || cfg2.ContextMode != "file-on-new-func" || cfg2.ContextWindowLines != 50 || cfg2.MaxContextTokens != 999 || cfg2.LogPreviewLimit != 0 { t.Fatalf("file merge not applied: %+v", cfg2) } @@ -313,6 +314,39 @@ func TestGetConfigPath_XDG(t *testing.T) { } } +func TestStateDir_XDG(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_STATE_HOME", dir) + stateDir, err := StateDir() + if err != nil { + t.Fatalf("StateDir: %v", err) + } + expected := filepath.Join(dir, "hexai") + if stateDir != expected { + t.Fatalf("expected %q, got %q", expected, stateDir) + } + // Verify directory was created + if _, err := os.Stat(stateDir); err != nil { + t.Fatalf("state directory not created: %v", err) + } +} + +func TestStateDir_Default(t *testing.T) { + t.Setenv("XDG_STATE_HOME", "") + stateDir, err := StateDir() + if err != nil { + t.Fatalf("StateDir: %v", err) + } + // Should default to ~/.local/state/hexai + if !strings.Contains(stateDir, ".local/state/hexai") { + t.Fatalf("expected path to contain .local/state/hexai, got %q", stateDir) + } + // Verify directory was created + if _, err := os.Stat(stateDir); err != nil { + t.Fatalf("state directory not created: %v", err) + } +} + func TestLoadFromFile_InvalidTOML(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) @@ -379,7 +413,7 @@ temperature = 0.0 // Ensure no env override interferes with manual_invoke_min_prefix in this test t.Setenv("HEXAI_MANUAL_INVOKE_MIN_PREFIX", "") logger := newLogger() - cfg := Load(logger) + cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: dir}) if cfg.MaxTokens != 111 || cfg.ContextMode != "window" || cfg.ContextWindowLines != 42 || cfg.MaxContextTokens != 777 { t.Fatalf("sectioned basics wrong: %+v", cfg) @@ -809,7 +843,7 @@ gitignore = false extra_patterns = ["*.min.js", "dist/**"] lsp_notify_ignored = false `) - cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { t.Error("expected IgnoreGitignore false from file") } @@ -834,7 +868,7 @@ lsp_notify_ignored = true withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false") withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0") withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp") - cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { t.Error("expected IgnoreGitignore false from env override") } @@ -884,7 +918,7 @@ func TestIgnoreConfig_DisableGitignore(t *testing.T) { [ignore] gitignore = false `) - cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore { t.Error("expected IgnoreGitignore false") } @@ -925,7 +959,7 @@ clear_keys = "C-u" newline_keys = "S-Enter" submit_keys = "Enter" `) - cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) if cfg.TmuxEditPopupWidth != "90%" { t.Errorf("PopupWidth = %q, want 90%%", cfg.TmuxEditPopupWidth) } @@ -986,7 +1020,7 @@ func TestTmuxEditConfig_SkipsEmptyName(t *testing.T) { name = "" display_name = "Empty" `) - cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) if len(cfg.TmuxEditAgents) != 0 { t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents)) } diff --git a/internal/tmuxedit/history.go b/internal/tmuxedit/history.go new file mode 100644 index 0000000..a79672b --- /dev/null +++ b/internal/tmuxedit/history.go @@ -0,0 +1,131 @@ +// Summary: JSONL-based history storage for tmux popup submissions +package tmuxedit + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// HistoryEntry represents a single submission to the AI agent via tmux popup. +// Stored in JSONL format (one JSON object per line) for easy appending and reading. +type HistoryEntry struct { + Timestamp string `json:"timestamp"` // RFC3339 format + Agent string `json:"agent"` // AI agent name (e.g., "claude", "aider") + Cwd string `json:"cwd"` // Current working directory at submission time + Text string `json:"text"` // The submitted text +} + +// AppendHistory appends a new history entry to the history file. +// Uses atomic write pattern (write to temp file, then rename) for safety. +func AppendHistory(text, agent, cwd string) error { + stateDir, err := appconfig.StateDir() + if err != nil { + return fmt.Errorf("cannot get state directory: %w", err) + } + + historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") + + // Create entry with current timestamp + entry := HistoryEntry{ + Timestamp: time.Now().Format(time.RFC3339), + Agent: agent, + Cwd: cwd, + Text: text, + } + + // Marshal to JSON + data, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("cannot marshal history entry: %w", err) + } + + // Append newline for JSONL format + data = append(data, '\n') + + // Open file in append mode, create if doesn't exist + f, err := os.OpenFile(historyPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("cannot open history file: %w", err) + } + defer f.Close() + + // Write entry + if _, err := f.Write(data); err != nil { + return fmt.Errorf("cannot write history entry: %w", err) + } + + return nil +} + +// GetHistory retrieves the most recent history entries (up to limit). +// Returns entries in chronological order (oldest first). +// If limit <= 0, returns all entries. +func GetHistory(limit int) ([]HistoryEntry, error) { + stateDir, err := appconfig.StateDir() + if err != nil { + return nil, fmt.Errorf("cannot get state directory: %w", err) + } + + historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") + + // Read entire file + data, err := os.ReadFile(historyPath) + if err != nil { + if os.IsNotExist(err) { + return []HistoryEntry{}, nil // Empty history is not an error + } + return nil, fmt.Errorf("cannot read history file: %w", err) + } + + // Parse JSONL line by line + var entries []HistoryEntry + lines := splitLines(data) + for i, line := range lines { + if len(line) == 0 { + continue // Skip empty lines + } + + var entry HistoryEntry + if err := json.Unmarshal(line, &entry); err != nil { + // Log error but continue parsing (don't fail entire history on one bad line) + fmt.Fprintf(os.Stderr, "warning: cannot parse history entry at line %d: %v\n", i+1, err) + continue + } + entries = append(entries, entry) + } + + // Apply limit if specified + if limit > 0 && len(entries) > limit { + // Return the most recent entries + entries = entries[len(entries)-limit:] + } + + 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 new file mode 100644 index 0000000..8a3d8af --- /dev/null +++ b/internal/tmuxedit/history_test.go @@ -0,0 +1,196 @@ +package tmuxedit + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAppendHistory(t *testing.T) { + // Create temp directory for test + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + text := "test prompt text" + agent := "claude" + cwd := "/tmp/test" + + // Append first entry + if err := AppendHistory(text, agent, cwd); err != nil { + t.Fatalf("AppendHistory failed: %v", err) + } + + // Verify file was created + historyPath := filepath.Join(tmpDir, "hexai", "tmux-edit-history.jsonl") + if _, err := os.Stat(historyPath); err != nil { + t.Fatalf("history file not created: %v", err) + } + + // Read and verify content + data, err := os.ReadFile(historyPath) + if err != nil { + t.Fatalf("cannot read history file: %v", err) + } + + content := string(data) + if content == "" { + t.Fatal("history file is empty") + } + + // Verify it contains expected fields + if !containsString(content, "test prompt text") { + t.Error("history doesn't contain text") + } + if !containsString(content, "claude") { + t.Error("history doesn't contain agent") + } + if !containsString(content, "/tmp/test") { + t.Error("history doesn't contain cwd") + } +} + +func TestGetHistory(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // Append multiple entries + entries := []struct { + text string + agent string + cwd string + }{ + {"first prompt", "claude", "/home/user"}, + {"second prompt", "aider", "/tmp/project"}, + {"third prompt", "claude", "/var/tmp"}, + } + + for _, e := range entries { + if err := AppendHistory(e.text, e.agent, e.cwd); err != nil { + t.Fatalf("AppendHistory failed: %v", err) + } + time.Sleep(10 * time.Millisecond) // Ensure different timestamps + } + + // Get all history + history, err := GetHistory(0) + if err != nil { + t.Fatalf("GetHistory failed: %v", err) + } + + if len(history) != 3 { + t.Fatalf("expected 3 entries, got %d", len(history)) + } + + // Verify first entry + if history[0].Text != "first prompt" { + t.Errorf("first entry text: got %q, want %q", history[0].Text, "first prompt") + } + if history[0].Agent != "claude" { + t.Errorf("first entry agent: got %q, want %q", history[0].Agent, "claude") + } + + // Test limit + limited, err := GetHistory(2) + if err != nil { + t.Fatalf("GetHistory with limit failed: %v", err) + } + if len(limited) != 2 { + t.Fatalf("expected 2 entries with limit, got %d", len(limited)) + } + + // Should get the most recent 2 + if limited[0].Text != "second prompt" { + t.Errorf("limited[0] should be second entry") + } + if limited[1].Text != "third prompt" { + t.Errorf("limited[1] should be third entry") + } +} + +func TestGetHistory_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_STATE_HOME", tmpDir) + + // Get history when file doesn't exist + history, err := GetHistory(0) + if err != nil { + t.Fatalf("GetHistory should not error on missing file: %v", err) + } + + if len(history) != 0 { + t.Errorf("expected empty history, got %d entries", len(history)) + } +} + +func TestSplitLines(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "unix newlines", + input: "line1\nline2\nline3", + want: []string{"line1", "line2", "line3"}, + }, + { + name: "windows newlines", + input: "line1\r\nline2\r\nline3", + want: []string{"line1", "line2", "line3"}, + }, + { + name: "mixed newlines", + input: "line1\nline2\r\nline3", + want: []string{"line1", "line2", "line3"}, + }, + { + name: "trailing newline", + input: "line1\nline2\n", + want: []string{"line1", "line2"}, + }, + { + name: "empty string", + input: "", + want: []string{}, + }, + { + name: "single line no newline", + input: "single", + want: []string{"single"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitLines([]byte(tt.input)) + gotStr := make([]string, len(got)) + for i, b := range got { + gotStr[i] = string(b) + } + + if len(gotStr) != len(tt.want) { + t.Fatalf("got %d lines, want %d", len(gotStr), len(tt.want)) + } + + for i := range gotStr { + if gotStr[i] != tt.want[i] { + t.Errorf("line %d: got %q, want %q", i, gotStr[i], tt.want[i]) + } + } + }) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go index 8c98ded..a156e5f 100644 --- a/internal/tmuxedit/run.go +++ b/internal/tmuxedit/run.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "codeberg.org/snonux/hexai/internal/appconfig" @@ -111,8 +112,18 @@ func loadConfig(configPath string) appconfig.App { var debugLog *log.Logger // initDebugLog creates a debug log file at /tmp/hexai-tmux-edit.log. +// initDebugLog creates a debug log file in the state directory (~/.local/state/hexai/hexai-tmux-edit.log). +// Falls back to /tmp if state directory cannot be created. +// initDebugLog creates a debug log file in the state directory (~/.local/state/hexai/hexai-tmux-edit.log). +// Panics if the state directory cannot be created. func initDebugLog() { - f, err := os.OpenFile("/tmp/hexai-tmux-edit.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + stateDir, err := appconfig.StateDir() + if err != nil { + panic(fmt.Sprintf("cannot create state directory: %v", 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 } @@ -181,6 +192,20 @@ func runWithConfig(opts Options, cfg appconfig.App) error { dbg("SendText error: %v", err) return err } + + // Append to history (log errors but don't fail the operation) + cwd, err := os.Getwd() + if err != nil { + cwd = "unknown" + dbg("os.Getwd error (using 'unknown'): %v", err) + } + if err := AppendHistory(text, agent.Name(), cwd); err != nil { + dbg("AppendHistory error: %v", err) + // Non-fatal: log but continue + } else { + dbg("appended to history: agent=%q cwd=%q len=%d", agent.Name(), cwd, len(text)) + } + dbg("=== done ===") return nil } diff --git a/internal/version.go b/internal/version.go index 027fd1f..72bb7c5 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.18.3" +const Version = "0.19.0" |
