package tui
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
"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
}