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 /internal/tmuxedit/history.go | |
| 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>
Diffstat (limited to 'internal/tmuxedit/history.go')
| -rw-r--r-- | internal/tmuxedit/history.go | 131 |
1 files changed, 131 insertions, 0 deletions
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 +} |
