summaryrefslogtreecommitdiff
path: root/internal/tmuxedit/history.go
blob: a79672b15cb85e81bf86eaae00552db15fb0934f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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
}