Vectrum
Sign In
329 lines · 9.9 KB · Go
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. It lives beside the
// checkout's saved sessions, under ~/.config/chats, not in the checkout itself.

// 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 chats directory.
const historyFilename = statedir.HistoryFile

// 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 kept in a checkout's chats directory, 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 chats directory would be a much larger failure than the one being
// reported.
func newHistory(chatsDir string) *history {
	h := &history{}
	// No directory 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".
	if chatsDir == "" {
		return h
	}
	h.path = historyPath(chatsDir)
	h.load()
	h.idx = len(h.entries)
	return h
}

// historyPath is where a checkout's history lives: beside its sessions.
func historyPath(chatsDir string) string {
	return filepath.Join(chatsDir, 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 chats 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
}