summaryrefslogtreecommitdiff
path: root/internal/tmuxedit/history.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-10 09:52:34 +0200
committerPaul Buetow <paul@buetow.org>2026-02-10 09:52:34 +0200
commit5bc2723325131e8432ad5a47d5e9cb245e9f0b28 (patch)
treec18bb5dafdcab806b3d8bb0912d083b28741d24c /internal/tmuxedit/history.go
parent17220d71f2af54f875ba1a86f489e5af6d7ad189 (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.go131
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
+}