|
| 1 | +// Package history persists the user's command/message history in an |
| 2 | +// append-only file and provides cursor-based navigation and search over it. |
1 | 3 | package history |
2 | 4 |
|
3 | 5 | import ( |
| 6 | + "bytes" |
4 | 7 | "encoding/json" |
5 | 8 | "os" |
6 | 9 | "path/filepath" |
7 | 10 | "slices" |
8 | | - "strconv" |
9 | 11 | "strings" |
10 | 12 | ) |
11 | 13 |
|
| 14 | +// History is the in-memory view of a persistent message history. The cursor |
| 15 | +// (used by [History.Previous] and [History.Next]) stays in [0, len(Messages)], |
| 16 | +// where len(Messages) means "past the most recent entry". |
12 | 17 | type History struct { |
13 | | - Messages []string `json:"messages"` |
| 18 | + Messages []string |
14 | 19 |
|
15 | 20 | path string |
16 | 21 | current int |
17 | 22 | } |
18 | 23 |
|
19 | | -type options struct { |
20 | | - homeDir string |
21 | | -} |
22 | | - |
23 | | -type Opt func(*options) |
24 | | - |
25 | | -func WithBaseDir(dir string) Opt { |
26 | | - return func(o *options) { |
27 | | - o.homeDir = dir |
28 | | - } |
29 | | -} |
30 | | - |
31 | | -func New(opts ...Opt) (*History, error) { |
32 | | - o := &options{} |
33 | | - for _, opt := range opts { |
34 | | - opt(o) |
35 | | - } |
36 | | - |
37 | | - homeDir := o.homeDir |
38 | | - if homeDir == "" { |
| 24 | +// New loads the history stored under baseDir/.cagent/history. If baseDir is |
| 25 | +// empty, the user's home directory is used. |
| 26 | +func New(baseDir string) (*History, error) { |
| 27 | + if baseDir == "" { |
39 | 28 | var err error |
40 | | - if homeDir, err = os.UserHomeDir(); err != nil { |
| 29 | + if baseDir, err = os.UserHomeDir(); err != nil { |
41 | 30 | return nil, err |
42 | 31 | } |
43 | 32 | } |
44 | 33 |
|
45 | | - h := &History{ |
46 | | - path: filepath.Join(homeDir, ".cagent", "history"), |
47 | | - current: -1, |
48 | | - } |
49 | | - |
50 | | - if err := h.migrateOldHistory(homeDir); err != nil { |
| 34 | + h := &History{path: filepath.Join(baseDir, ".cagent", "history")} |
| 35 | + if err := h.migrateOldHistory(baseDir); err != nil { |
51 | 36 | return nil, err |
52 | 37 | } |
53 | | - |
54 | 38 | if err := h.load(); err != nil && !os.IsNotExist(err) { |
55 | 39 | return nil, err |
56 | 40 | } |
57 | | - |
| 41 | + h.current = len(h.Messages) |
58 | 42 | return h, nil |
59 | 43 | } |
60 | 44 |
|
61 | | -func (h *History) migrateOldHistory(homeDir string) error { |
62 | | - oldPath := filepath.Join(homeDir, ".cagent", "history.json") |
63 | | - |
64 | | - data, err := os.ReadFile(oldPath) |
65 | | - if os.IsNotExist(err) { |
66 | | - return nil |
67 | | - } |
68 | | - if err != nil { |
69 | | - return err |
70 | | - } |
71 | | - |
72 | | - var old struct { |
73 | | - Messages []string `json:"messages"` |
74 | | - } |
75 | | - if err := json.Unmarshal(data, &old); err != nil { |
76 | | - return err |
77 | | - } |
78 | | - |
79 | | - for _, msg := range old.Messages { |
80 | | - if err := h.append(msg); err != nil { |
81 | | - return err |
82 | | - } |
83 | | - } |
84 | | - |
85 | | - return os.Remove(oldPath) |
86 | | -} |
87 | | - |
| 45 | +// Add records a new message. Any prior occurrence of the same message is |
| 46 | +// removed and the new one becomes the most recent entry. |
88 | 47 | func (h *History) Add(message string) error { |
89 | | - // Update in-memory list: remove duplicate and append to end |
90 | | - h.Messages = slices.DeleteFunc(h.Messages, func(m string) bool { |
91 | | - return m == message |
92 | | - }) |
93 | | - h.Messages = append(h.Messages, message) |
| 48 | + h.addInMemory(message) |
94 | 49 | h.current = len(h.Messages) |
95 | | - |
96 | 50 | return h.append(message) |
97 | 51 | } |
98 | 52 |
|
| 53 | +// Previous moves the cursor one step toward older entries and returns the |
| 54 | +// entry under it. At the oldest entry, the cursor stays put. |
99 | 55 | func (h *History) Previous() string { |
100 | 56 | if len(h.Messages) == 0 { |
101 | 57 | return "" |
102 | 58 | } |
103 | | - |
104 | | - // If we're at -1 (initial state), start from the end |
105 | | - if h.current == -1 { |
106 | | - h.current = len(h.Messages) - 1 |
107 | | - return h.Messages[h.current] |
108 | | - } |
109 | | - |
110 | | - // If we're at the beginning, stay there |
111 | | - if h.current <= 0 { |
112 | | - return h.Messages[0] |
113 | | - } |
114 | | - |
115 | | - h.current-- |
| 59 | + h.current = max(h.current-1, 0) |
116 | 60 | return h.Messages[h.current] |
117 | 61 | } |
118 | 62 |
|
| 63 | +// Next moves the cursor one step toward newer entries and returns the entry |
| 64 | +// under it. Past the most recent entry, returns an empty string. |
119 | 65 | func (h *History) Next() string { |
120 | | - if len(h.Messages) == 0 { |
121 | | - return "" |
122 | | - } |
123 | | - |
124 | 66 | if h.current >= len(h.Messages)-1 { |
125 | 67 | h.current = len(h.Messages) |
126 | 68 | return "" |
127 | 69 | } |
128 | | - |
129 | 70 | h.current++ |
130 | 71 | return h.Messages[h.current] |
131 | 72 | } |
132 | 73 |
|
133 | | -// LatestMatch returns the most recent history entry that extends the provided |
134 | | -// prefix, or the latest message when no prefix is supplied. |
| 74 | +// SetCurrent positions the cursor at index i, clamped to [0, len(Messages)]. |
| 75 | +// Keeping the cursor in this range guarantees that subsequent Previous and |
| 76 | +// Next calls never index out of bounds. |
| 77 | +func (h *History) SetCurrent(i int) { |
| 78 | + h.current = max(0, min(i, len(h.Messages))) |
| 79 | +} |
| 80 | + |
| 81 | +// LatestMatch returns the most recent entry that strictly extends prefix, or |
| 82 | +// an empty string when none does. |
135 | 83 | func (h *History) LatestMatch(prefix string) string { |
136 | | - for i := len(h.Messages) - 1; i >= 0; i-- { |
137 | | - msg := h.Messages[i] |
| 84 | + for _, msg := range slices.Backward(h.Messages) { |
138 | 85 | if strings.HasPrefix(msg, prefix) && len(msg) > len(prefix) { |
139 | 86 | return msg |
140 | 87 | } |
141 | 88 | } |
142 | 89 | return "" |
143 | 90 | } |
144 | 91 |
|
145 | | -// FindPrevContains searches backward through history for a message containing query. |
146 | | -// from is an exclusive upper bound index. Pass len(Messages) to start from the most recent. |
147 | | -// Returns the matched message, its index, and whether a match was found. |
148 | | -// An empty query matches any entry. |
| 92 | +// FindPrevContains searches backward from index from-1 for an entry containing |
| 93 | +// query (case-insensitive). An empty query matches any entry. Pass |
| 94 | +// len(Messages) to start from the most recent entry. |
149 | 95 | func (h *History) FindPrevContains(query string, from int) (msg string, idx int, ok bool) { |
150 | | - if len(h.Messages) == 0 { |
151 | | - return "", -1, false |
152 | | - } |
153 | | - |
154 | | - start := min(from-1, len(h.Messages)-1) |
155 | | - |
156 | 96 | query = strings.ToLower(query) |
157 | | - for i := start; i >= 0; i-- { |
| 97 | + for i := min(from-1, len(h.Messages)-1); i >= 0; i-- { |
158 | 98 | if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) { |
159 | 99 | return h.Messages[i], i, true |
160 | 100 | } |
161 | 101 | } |
162 | | - |
163 | 102 | return "", -1, false |
164 | 103 | } |
165 | 104 |
|
166 | | -// FindNextContains searches forward through history for a message containing query. |
167 | | -// from is an exclusive lower bound index. Pass -1 to start from the oldest. |
168 | | -// Returns the matched message, its index, and whether a match was found. |
| 105 | +// FindNextContains searches forward from index from+1 for an entry containing |
| 106 | +// query (case-insensitive). An empty query matches any entry. Pass -1 to |
| 107 | +// start from the oldest entry. |
169 | 108 | func (h *History) FindNextContains(query string, from int) (msg string, idx int, ok bool) { |
170 | | - if len(h.Messages) == 0 { |
171 | | - return "", -1, false |
172 | | - } |
173 | | - |
174 | | - start := max(from+1, 0) |
175 | | - |
176 | 109 | query = strings.ToLower(query) |
177 | | - for i := start; i < len(h.Messages); i++ { |
| 110 | + for i := max(from+1, 0); i < len(h.Messages); i++ { |
178 | 111 | if query == "" || strings.Contains(strings.ToLower(h.Messages[i]), query) { |
179 | 112 | return h.Messages[i], i, true |
180 | 113 | } |
181 | 114 | } |
182 | | - |
183 | 115 | return "", -1, false |
184 | 116 | } |
185 | 117 |
|
186 | | -func (h *History) SetCurrent(i int) { |
187 | | - h.current = i |
| 118 | +// addInMemory removes any prior occurrence of message and appends it as the |
| 119 | +// most recent entry. |
| 120 | +func (h *History) addInMemory(message string) { |
| 121 | + h.Messages = slices.DeleteFunc(h.Messages, func(m string) bool { |
| 122 | + return m == message |
| 123 | + }) |
| 124 | + h.Messages = append(h.Messages, message) |
188 | 125 | } |
189 | 126 |
|
| 127 | +// append writes message to the persistent history file as one JSON-encoded |
| 128 | +// line. |
190 | 129 | func (h *History) append(message string) error { |
191 | 130 | if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil { |
192 | 131 | return err |
193 | 132 | } |
194 | | - |
195 | | - f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) |
| 133 | + encoded, err := json.Marshal(message) |
196 | 134 | if err != nil { |
197 | 135 | return err |
198 | 136 | } |
199 | | - defer f.Close() |
200 | 137 |
|
201 | | - encoded, err := json.Marshal(message) |
| 138 | + f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) |
202 | 139 | if err != nil { |
203 | 140 | return err |
204 | 141 | } |
| 142 | + defer f.Close() |
205 | 143 |
|
206 | 144 | _, err = f.Write(append(encoded, '\n')) |
207 | 145 | return err |
208 | 146 | } |
209 | 147 |
|
| 148 | +// load reads the persistent history file and populates Messages, deduplicating |
| 149 | +// entries while keeping the latest occurrence of each. |
210 | 150 | func (h *History) load() error { |
211 | 151 | data, err := os.ReadFile(h.path) |
212 | 152 | if err != nil { |
213 | 153 | return err |
214 | 154 | } |
215 | 155 |
|
216 | | - // Count lines to pre-size the slice. |
217 | | - n := 0 |
218 | | - for _, b := range data { |
219 | | - if b == '\n' { |
220 | | - n++ |
221 | | - } |
222 | | - } |
223 | | - |
224 | | - // Parse all lines. Each line is a JSON-encoded string (e.g. "hello"). |
225 | | - // strconv.Unquote handles the same escape sequences as JSON and is |
226 | | - // much faster than json.Unmarshal for quoted strings. |
227 | | - all := make([]string, 0, n) |
228 | | - s := string(data) |
229 | | - for s != "" { |
230 | | - i := strings.IndexByte(s, '\n') |
231 | | - var line string |
232 | | - if i < 0 { |
233 | | - line = s |
234 | | - s = "" |
235 | | - } else { |
236 | | - line = s[:i] |
237 | | - s = s[i+1:] |
238 | | - } |
239 | | - if line == "" { |
| 156 | + for line := range bytes.Lines(data) { |
| 157 | + line = bytes.TrimSuffix(line, []byte("\n")) |
| 158 | + if len(line) == 0 { |
240 | 159 | continue |
241 | 160 | } |
242 | | - |
243 | | - message, err := strconv.Unquote(line) |
244 | | - if err != nil { |
| 161 | + var msg string |
| 162 | + if err := json.Unmarshal(line, &msg); err != nil { |
245 | 163 | continue |
246 | 164 | } |
247 | | - all = append(all, message) |
| 165 | + h.addInMemory(msg) |
248 | 166 | } |
| 167 | + return nil |
| 168 | +} |
249 | 169 |
|
250 | | - // Deduplicate keeping the latest occurrence of each message. |
251 | | - seen := make(map[string]struct{}, len(all)) |
252 | | - h.Messages = make([]string, 0, len(all)) |
253 | | - for i := len(all) - 1; i >= 0; i-- { |
254 | | - if _, dup := seen[all[i]]; dup { |
255 | | - continue |
256 | | - } |
257 | | - seen[all[i]] = struct{}{} |
258 | | - h.Messages = append(h.Messages, all[i]) |
| 170 | +// migrateOldHistory imports messages from the legacy history.json file (if it |
| 171 | +// exists) into the new line-oriented format and removes the old file. |
| 172 | +func (h *History) migrateOldHistory(baseDir string) error { |
| 173 | + oldPath := filepath.Join(baseDir, ".cagent", "history.json") |
| 174 | + |
| 175 | + data, err := os.ReadFile(oldPath) |
| 176 | + if os.IsNotExist(err) { |
| 177 | + return nil |
| 178 | + } |
| 179 | + if err != nil { |
| 180 | + return err |
259 | 181 | } |
260 | | - slices.Reverse(h.Messages) |
261 | 182 |
|
262 | | - return nil |
| 183 | + var old struct { |
| 184 | + Messages []string `json:"messages"` |
| 185 | + } |
| 186 | + if err := json.Unmarshal(data, &old); err != nil { |
| 187 | + return err |
| 188 | + } |
| 189 | + |
| 190 | + for _, msg := range old.Messages { |
| 191 | + if err := h.append(msg); err != nil { |
| 192 | + return err |
| 193 | + } |
| 194 | + } |
| 195 | + return os.Remove(oldPath) |
263 | 196 | } |
0 commit comments