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
}
|