package tui import ( "bufio" "os" "path/filepath" "strconv" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/johannSo/codey/internal/statedir" ) // The prompt's memory. Everything submitted from the box — a prompt to the // model, a shell line typed after !, a python line after $ — is remembered // here, and the arrow keys walk back through it. // // It is kept per checkout rather than per session or per machine. Per session // would forget the thing you are most likely to want back, which is what you // typed just before the last restart; one file for the whole machine would // offer you the test command of a project you are not in. // historyLimit is how many entries are kept. A few hundred is more than the // arrow key can practically reach and still a file small enough to read whole // at startup without anyone noticing. const historyLimit = 500 // historyFilename is what the file is called inside the state directory. const historyFilename = "history" // compactAtMultiple is how far the file may grow past the limit before it is // rewritten. Entries are appended rather than rewritten so two tabs typing at // once do not overwrite each other's lines, which means the file grows without // bound until something trims it — and trimming on every keystroke would be a // whole-file rewrite per prompt. const compactAtMultiple = 4 // history is the list and the walk over it. Entries are oldest first and are // stored exactly as they were typed, including the ! or $ that chose an // interpreter: the marker is what tells a shell line apart from a sentence // meant for the model, and the two must not be offered to each other. type history struct { path string // entries is what has been typed, oldest first. entries []string // idx is how far back the walk has got. len(entries) means no walk is in // progress, which is also what an empty history looks like. idx int // draft is what was in the box when the walk began, so that walking all // the way forward again gives back the half-written prompt rather than an // empty box. Losing that is the one thing a history feature can do that is // worse than not existing. draft string walking bool } // newHistory loads the history for a checkout, or returns an empty one. // // Every failure is silent. A history that cannot be read is a convenience that // is missing, and refusing to open the prompt box because of a stray file in // the state directory would be a much larger failure than the one being // reported. func newHistory(stateRoot string) *history { h := &history{} // A root that is not a directory that exists gets a history that lives // only as long as the process. That is the honest reading of "I do not // know where to keep this", and it keeps a mistyped -C from creating a // state directory somewhere nobody meant to have one. if fi, err := os.Stat(stateRoot); stateRoot == "" || err != nil || !fi.IsDir() { return h } h.path = historyPath(stateRoot) h.load() h.idx = len(h.entries) return h } // historyPath is where a checkout's history lives. It sits beside the sessions // and the event log, in the directory that is already ignored by git. func historyPath(stateRoot string) string { return filepath.Join(stateRoot, statedir.Dir, historyFilename) } // load reads the file, dropping anything it cannot parse, and compacts it if // appending has let it grow well past the limit. func (h *history) load() { f, err := os.Open(h.path) if err != nil { return } defer f.Close() var lines []string sc := bufio.NewScanner(f) // A pasted prompt can be long, and the default 64KB line cap would split // one into two entries that are each half a thought. sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { text, err := strconv.Unquote(sc.Text()) if err != nil || strings.TrimSpace(text) == "" { continue } lines = append(lines, text) } grown := len(lines) > historyLimit*compactAtMultiple if len(lines) > historyLimit { lines = lines[len(lines)-historyLimit:] } h.entries = lines if grown { h.rewrite() } } // record remembers one submitted line and appends it to the file. // // An immediate repeat is dropped. Running the same test command four times in // a row is normal, and a history that remembers all four is one where the // arrow key has to be pressed four times to get past a command you already // have. func (h *history) record(line string) { if h == nil { return } h.stop() line = strings.TrimRight(line, "\n") if strings.TrimSpace(line) == "" { return } if n := len(h.entries); n > 0 && h.entries[n-1] == line { return } h.entries = append(h.entries, line) if len(h.entries) > historyLimit { h.entries = h.entries[len(h.entries)-historyLimit:] } h.idx = len(h.entries) h.append(line) } // append adds one line to the file, creating the state directory if this is // the first thing ever written there. Quoted, so that a prompt with newlines // in it stays one entry. func (h *history) append(line string) { if h.path == "" { return } if err := os.MkdirAll(filepath.Dir(h.path), 0o755); err != nil { return } f, err := os.OpenFile(h.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return } defer f.Close() // Best effort, like everything else here: a full disk is not a reason to // lose the prompt that was just sent. f.WriteString(strconv.Quote(line) + "\n") } // rewrite replaces the file with the entries held in memory. It is how the // file stops growing, and it is deliberately rare: see compactAtMultiple. func (h *history) rewrite() { if h.path == "" { return } var b strings.Builder for _, e := range h.entries { b.WriteString(strconv.Quote(e) + "\n") } tmp := h.path + ".tmp" if err := os.WriteFile(tmp, []byte(b.String()), 0o600); err != nil { return } if err := os.Rename(tmp, h.path); err != nil { os.Remove(tmp) } } // prefixFor is the marker the box is currently typing behind: empty for the // prompt aimed at the model, "!" or "$" for an interpreter. It is what decides // which entries the arrow keys can see. func prefixFor(mode localMode) string { if mode == localOff { return "" } return localSpecs[mode].marker } // match reports whether an entry belongs to the mode the box is in, and what // it should read as there. In a local mode the marker is dropped, because the // box already shows it as its prompt — putting it back in the text would make // "!!go test" out of the second recall. func match(entry, prefix string) (string, bool) { if prefix != "" { if !strings.HasPrefix(entry, prefix) { return "", false } return entry[len(prefix):], true } // The model's prompt sees everything that is not an interpreter line. A // shell command recalled into it would be sent to the model as prose. if entry != "" && localFor(entry[:1]) != localOff { return "", false } return entry, true } // prev walks one step back, returning what the box should now say. current is // what is in the box, kept so that walking forward again can give it back. func (h *history) prev(prefix, current string) (string, bool) { if h == nil { return "", false } for i := h.idx - 1; i >= 0; i-- { text, ok := match(h.entries[i], prefix) if !ok { continue } if !h.walking { h.draft, h.walking = current, true } h.idx = i return text, true } return "", false } // next walks one step forward. Past the newest entry it gives back the draft // the walk started from and the walk is over. func (h *history) next(prefix string) (string, bool) { if h == nil || !h.walking { return "", false } for i := h.idx + 1; i < len(h.entries); i++ { text, ok := match(h.entries[i], prefix) if !ok { continue } h.idx = i return text, true } draft := h.draft h.stop() return draft, true } // abandon ends the walk and reports the draft it started from, for the key // that means "forget I asked". func (h *history) abandon() (string, bool) { if h == nil || !h.walking { return "", false } draft := h.draft h.stop() return draft, true } // stop ends any walk in progress without changing what is in the box: what was // recalled has been edited or sent, and is now simply what you are typing. func (h *history) stop() { if h == nil { return } h.idx, h.draft, h.walking = len(h.entries), "", false } // onHistoryKey handles the keys that walk the prompt's memory, and reports // whether it took the key. // // The arrows only mean "history" from the edge of the text: on the first line // up goes back a prompt, but anywhere below it the key is moving the cursor // through a paragraph that is being written, which is what it looks like it is // doing. That rule is what lets the same key do both jobs without a mode. func (m *model) onHistoryKey(msg tea.KeyMsg) (tea.Cmd, bool) { prefix := prefixFor(m.local) switch msg.String() { case "up", "ctrl+p": if m.input.Line() > 0 { return nil, false } text, ok := m.hist.prev(prefix, m.input.Value()) if !ok { // Nothing older to show. The key is left to the textarea rather // than swallowed, so it still does what it would have done. return nil, false } return m.recall(text), true case "down", "ctrl+n": if m.input.Line() < m.input.LineCount()-1 { return nil, false } text, ok := m.hist.next(prefix) if !ok { return nil, false } return m.recall(text), true case "esc": // Only when esc has nothing more urgent to mean. While anything is in // flight the key is the interrupt, and a walk through the history is // not a reason to take that away. if m.phase != phaseIdle || m.running != nil || m.compacting || m.worktreeing { return nil, false } text, ok := m.hist.abandon() if !ok { return nil, false } return m.recall(text), true } return nil, false } // recall puts a remembered line in the box with the cursor at the end of it. // // The menu is closed rather than refreshed: recalling "/model" would otherwise // open the completion list, which owns the up arrow, and the next press would // move a highlight instead of walking one step further back. func (m *model) recall(text string) tea.Cmd { m.clearSelection() m.input.SetValue(text) m.input.CursorEnd() m.growInput() m.closeMenu() return nil }