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.
13package history
24
35import (
@@ -9,105 +11,58 @@ import (
911 "strings"
1012)
1113
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".
1217type History struct {
13- Messages []string `json:"messages"`
18+ Messages []string
1419
1520 path string
1621 current int
1722}
1823
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 == "" {
3928 var err error
40- if homeDir , err = os .UserHomeDir (); err != nil {
29+ if baseDir , err = os .UserHomeDir (); err != nil {
4130 return nil , err
4231 }
4332 }
4433
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 {
5136 return nil , err
5237 }
53-
5438 if err := h .load (); err != nil && ! os .IsNotExist (err ) {
5539 return nil , err
5640 }
57-
41+ h . current = len ( h . Messages )
5842 return h , nil
5943}
6044
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.
8847func (h * History ) Add (message string ) error {
8948 h .addInMemory (message )
9049 h .current = len (h .Messages )
9150 return h .append (message )
9251}
9352
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.
9455func (h * History ) Previous () string {
9556 if len (h .Messages ) == 0 {
9657 return ""
9758 }
98- switch {
99- case h .current == - 1 :
100- h .current = len (h .Messages ) - 1
101- case h .current > 0 :
102- h .current --
103- }
59+ h .current = max (h .current - 1 , 0 )
10460 return h .Messages [h .current ]
10561}
10662
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.
10765func (h * History ) Next () string {
108- if len (h .Messages ) == 0 {
109- return ""
110- }
11166 if h .current >= len (h .Messages )- 1 {
11267 h .current = len (h .Messages )
11368 return ""
@@ -116,8 +71,13 @@ func (h *History) Next() string {
11671 return h .Messages [h .current ]
11772}
11873
119- // LatestMatch returns the most recent history entry that extends the provided
120- // prefix, or an empty string when none does.
74+ // SetCurrent positions the cursor at index i.
75+ func (h * History ) SetCurrent (i int ) {
76+ h .current = i
77+ }
78+
79+ // LatestMatch returns the most recent entry that strictly extends prefix, or
80+ // an empty string when none does.
12181func (h * History ) LatestMatch (prefix string ) string {
12282 for _ , msg := range slices .Backward (h .Messages ) {
12383 if strings .HasPrefix (msg , prefix ) && len (msg ) > len (prefix ) {
@@ -127,10 +87,9 @@ func (h *History) LatestMatch(prefix string) string {
12787 return ""
12888}
12989
130- // FindPrevContains searches backward through history for a message containing query.
131- // from is an exclusive upper bound index. Pass len(Messages) to start from the most recent.
132- // Returns the matched message, its index, and whether a match was found.
133- // An empty query matches any entry.
90+ // FindPrevContains searches backward from index from-1 for an entry containing
91+ // query (case-insensitive). An empty query matches any entry. Pass
92+ // len(Messages) to start from the most recent entry.
13493func (h * History ) FindPrevContains (query string , from int ) (msg string , idx int , ok bool ) {
13594 query = strings .ToLower (query )
13695 for i := min (from - 1 , len (h .Messages )- 1 ); i >= 0 ; i -- {
@@ -141,10 +100,9 @@ func (h *History) FindPrevContains(query string, from int) (msg string, idx int,
141100 return "" , - 1 , false
142101}
143102
144- // FindNextContains searches forward through history for a message containing query.
145- // from is an exclusive lower bound index. Pass -1 to start from the oldest.
146- // Returns the matched message, its index, and whether a match was found.
147- // An empty query matches any entry.
103+ // FindNextContains searches forward from index from+1 for an entry containing
104+ // query (case-insensitive). An empty query matches any entry. Pass -1 to
105+ // start from the oldest entry.
148106func (h * History ) FindNextContains (query string , from int ) (msg string , idx int , ok bool ) {
149107 query = strings .ToLower (query )
150108 for i := max (from + 1 , 0 ); i < len (h .Messages ); i ++ {
@@ -155,10 +113,6 @@ func (h *History) FindNextContains(query string, from int) (msg string, idx int,
155113 return "" , - 1 , false
156114}
157115
158- func (h * History ) SetCurrent (i int ) {
159- h .current = i
160- }
161-
162116// addInMemory removes any prior occurrence of message and appends it as the
163117// most recent entry.
164118func (h * History ) addInMemory (message string ) {
@@ -168,35 +122,35 @@ func (h *History) addInMemory(message string) {
168122 h .Messages = append (h .Messages , message )
169123}
170124
125+ // append writes message to the persistent history file as one JSON-encoded
126+ // line.
171127func (h * History ) append (message string ) error {
172128 if err := os .MkdirAll (filepath .Dir (h .path ), 0o755 ); err != nil {
173129 return err
174130 }
175-
176- f , err := os .OpenFile (h .path , os .O_APPEND | os .O_CREATE | os .O_WRONLY , 0o644 )
131+ encoded , err := json .Marshal (message )
177132 if err != nil {
178133 return err
179134 }
180- defer f .Close ()
181135
182- encoded , err := json . Marshal ( message )
136+ f , err := os . OpenFile ( h . path , os . O_APPEND | os . O_CREATE | os . O_WRONLY , 0o644 )
183137 if err != nil {
184138 return err
185139 }
140+ defer f .Close ()
186141
187142 _ , err = f .Write (append (encoded , '\n' ))
188143 return err
189144}
190145
146+ // load reads the persistent history file and populates Messages, deduplicating
147+ // entries while keeping the latest occurrence of each.
191148func (h * History ) load () error {
192149 data , err := os .ReadFile (h .path )
193150 if err != nil {
194151 return err
195152 }
196153
197- // The file is append-only with one JSON-encoded string per line.
198- // Replaying each entry through addInMemory naturally deduplicates,
199- // keeping the latest occurrence of each message.
200154 for line := range bytes .Lines (data ) {
201155 line = bytes .TrimRight (line , "\n " )
202156 if len (line ) == 0 {
@@ -210,3 +164,31 @@ func (h *History) load() error {
210164 }
211165 return nil
212166}
167+
168+ // migrateOldHistory imports messages from the legacy history.json file (if it
169+ // exists) into the new line-oriented format and removes the old file.
170+ func (h * History ) migrateOldHistory (baseDir string ) error {
171+ oldPath := filepath .Join (baseDir , ".cagent" , "history.json" )
172+
173+ data , err := os .ReadFile (oldPath )
174+ if os .IsNotExist (err ) {
175+ return nil
176+ }
177+ if err != nil {
178+ return err
179+ }
180+
181+ var old struct {
182+ Messages []string `json:"messages"`
183+ }
184+ if err := json .Unmarshal (data , & old ); err != nil {
185+ return err
186+ }
187+
188+ for _ , msg := range old .Messages {
189+ if err := h .append (msg ); err != nil {
190+ return err
191+ }
192+ }
193+ return os .Remove (oldPath )
194+ }
0 commit comments