package tui
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/johannSo/codey/internal/approval"
"github.com/johannSo/codey/internal/llm"
"github.com/johannSo/codey/internal/statedir"
)
// newHistoryModel builds a model whose history has a real directory to live
// in, so a test can look at what was written as well as at what was recalled.
func newHistoryModel(t *testing.T) (*model, string) {
t.Helper()
root := t.TempDir()
m := newModel(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: root,
HomeRoot: root,
Policy: approval.New(nil),
})
m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
return m, root
}
// The whole point of the feature: what was sent comes back.
func TestUpRecallsThePreviousPrompt(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("fix the failing test")
m.Update(key("up"))
if v := m.input.Value(); v != "fix the failing test" {
t.Errorf("prompt = %q, want the previous one back", v)
}
}
func TestUpWalksBackThroughSeveralPrompts(t *testing.T) {
m, _ := newHistoryModel(t)
for _, line := range []string{"first", "second", "third"} {
m.hist.record(line)
}
want := []string{"third", "second", "first", "first"}
for i, w := range want {
m.Update(key("up"))
if v := m.input.Value(); v != w {
t.Errorf("after %d ups prompt = %q, want %q", i+1, v, w)
}
}
}
// Walking forward again has to give back what was half-written, or the feature
// costs you the prompt you were in the middle of.
func TestDownReturnsTheDraftTheWalkStartedFrom(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("an old prompt")
typeIn(m, "half a thought")
m.Update(key("up"))
if v := m.input.Value(); v != "an old prompt" {
t.Fatalf("prompt = %q, want the recalled entry", v)
}
m.Update(key("down"))
if v := m.input.Value(); v != "half a thought" {
t.Errorf("prompt = %q, want the draft back", v)
}
}
func TestEscAbandonsTheWalk(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("an old prompt")
typeIn(m, "draft")
m.Update(key("up"))
m.Update(key("esc"))
if v := m.input.Value(); v != "draft" {
t.Errorf("prompt = %q, want the draft back after esc", v)
}
}
// Once a recalled line has been edited it is yours, and the draft it displaced
// is gone. What must not happen is down quietly wiping the edit.
func TestEditingARecalledLineEndsTheWalk(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("go test ./...")
m.Update(key("up"))
typeIn(m, " -run X")
m.Update(key("down"))
if v := m.input.Value(); v != "go test ./... -run X" {
t.Errorf("prompt = %q, want the edited line left alone", v)
}
}
// The arrows are only history at the edge of the text. In the middle of a
// paragraph they are what they look like: cursor movement.
func TestUpInsideAMultiLinePromptMovesTheCursor(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("an old prompt")
typeIn(m, "first line")
m.Update(key("ctrl+j")) // the textarea's newline
typeIn(m, "second line")
m.Update(key("up"))
if v := m.input.Value(); v != "first line\nsecond line" {
t.Errorf("prompt = %q, want the typed paragraph untouched", v)
}
if m.input.Line() != 0 {
t.Errorf("cursor line = %d, want the key to have moved it to the first line", m.input.Line())
}
// From the first line the same key now means history, and the paragraph
// is the draft it can come back to.
m.Update(key("up"))
if v := m.input.Value(); v != "an old prompt" {
t.Errorf("prompt = %q, want the recall from the first line", v)
}
}
// A shell command and a prompt are different kinds of thing, and offering one
// where the other belongs would send `go test` to the model as prose.
func TestModesKeepSeparateHistories(t *testing.T) {
m, _ := newHistoryModel(t)
m.hist.record("a prompt for the model")
m.hist.record("!go test ./...")
m.hist.record("$print(1)")
m.Update(key("up"))
if v := m.input.Value(); v != "a prompt for the model" {
t.Errorf("prompt = %q, want the model's own history and nothing else", v)
}
m.Update(key("esc"))
m.Update(key("!"))
m.Update(key("up"))
if v := m.input.Value(); v != "go test ./..." {
t.Errorf("shell prompt = %q, want the shell line without its marker", v)
}
}
func TestSubmittingAPromptRecordsIt(t *testing.T) {
m, _ := newHistoryModel(t)
typeIn(m, "say something")
m.Update(key("enter"))
if got := m.hist.entries; len(got) != 1 || got[0] != "say something" {
t.Errorf("entries = %v, want the submitted line", got)
}
if m.hist.walking {
t.Error("still walking after a submit")
}
}
// Entries survive a restart, which is the case the file exists for at all.
func TestHistoryIsReadBackFromDisk(t *testing.T) {
m, root := newHistoryModel(t)
m.hist.record("remembered")
again := newHistory(root)
if got := again.entries; len(got) != 1 || got[0] != "remembered" {
t.Errorf("entries after reload = %v, want the recorded line", got)
}
}
// A prompt with newlines in it is one entry, not three.
func TestAMultiLinePromptIsOneEntry(t *testing.T) {
_, root := newHistoryModel(t)
h := newHistory(root)
h.record("do this:\n and this\n and that")
again := newHistory(root)
if got := again.entries; len(got) != 1 || got[0] != "do this:\n and this\n and that" {
t.Errorf("entries = %q, want one entry holding every line", got)
}
}
func TestRepeatsAndBlanksAreNotRecorded(t *testing.T) {
_, root := newHistoryModel(t)
h := newHistory(root)
h.record("go test")
h.record("go test")
h.record(" ")
h.record("")
h.record("go build")
h.record("go test")
want := []string{"go test", "go build", "go test"}
if got := h.entries; !equalStrings(got, want) {
t.Errorf("entries = %v, want %v — only an immediate repeat is dropped", got, want)
}
}
func TestHistoryIsCappedAndCompacted(t *testing.T) {
_, root := newHistoryModel(t)
path := historyPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
var b strings.Builder
total := historyLimit*compactAtMultiple + 10
for i := range total {
b.WriteString(strconv.Quote("line "+strconv.Itoa(i)) + "\n")
}
if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil {
t.Fatal(err)
}
h := newHistory(root)
if len(h.entries) != historyLimit {
t.Errorf("entries = %d, want the last %d", len(h.entries), historyLimit)
}
if h.entries[len(h.entries)-1] != "line "+strconv.Itoa(total-1) {
t.Errorf("newest entry = %q, want the last line of the file", h.entries[len(h.entries)-1])
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(string(raw), "\n"); n != historyLimit {
t.Errorf("file has %d lines, want it compacted to %d", n, historyLimit)
}
}
// Nothing here may be a reason the prompt box does not work. A history with
// nowhere to live still walks; it just forgets at the end of the session.
func TestAHistoryWithNoDirectoryStillWorks(t *testing.T) {
h := newHistory(filepath.Join(t.TempDir(), "does-not-exist"))
if h.path != "" {
t.Errorf("path = %q, want no file for a root that is not there", h.path)
}
h.record("kept in memory")
if text, ok := h.prev("", ""); !ok || text != "kept in memory" {
t.Errorf("prev = %q, %v; want the in-memory entry", text, ok)
}
}
func TestGarbageLinesAreSkippedRatherThanFatal(t *testing.T) {
_, root := newHistoryModel(t)
path := historyPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
body := strconv.Quote("good one") + "\nnot quoted at all\n\"unterminated\n" + strconv.Quote("another") + "\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
h := newHistory(root)
if want := []string{"good one", "another"}; !equalStrings(h.entries, want) {
t.Errorf("entries = %v, want %v", h.entries, want)
}
}
// The file belongs where the sessions and the event log are, so it is covered
// by the ignore rule that is already there.
func TestTheFileLivesInTheStateDirectory(t *testing.T) {
root := t.TempDir()
want := filepath.Join(root, statedir.Dir, historyFilename)
if got := historyPath(root); got != want {
t.Errorf("historyPath = %q, want %q", got, want)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// The prompt's hint is advertising for keys nothing else on screen mentions,
// so it loses whole clauses on a narrow terminal rather than the ends of
// words.
func TestThePlaceholderFitsTheBox(t *testing.T) {
for _, width := range []int{100, 60, 48, 34, 20, 1} {
p := placeholderFor(width)
if w := lipgloss.Width(p); w > width && p != placeholders[len(placeholders)-1] {
t.Errorf("placeholder for width %d is %d cells: %q", width, w, p)
}
if !strings.Contains(p, "ask") {
t.Errorf("placeholder for width %d does not say what the box is for: %q", width, p)
}
}
// The widest one names the key this history feature added, because there
// is nowhere else on the idle screen that it is written down except the
// welcome block, which scrolls away.
if !strings.Contains(placeholderFor(100), "↑") {
t.Error("the full placeholder does not mention the up arrow")
}
}
func TestResizingNarrowsThePlaceholder(t *testing.T) {
m, _ := newHistoryModel(t)
wide := m.input.Placeholder
m.Update(tea.WindowSizeMsg{Width: 40, Height: 24})
if m.input.Placeholder == wide {
t.Errorf("the placeholder did not shorten for a 40-column terminal: %q", m.input.Placeholder)
}
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
if m.input.Placeholder != defaultPlaceholder {
t.Errorf("the placeholder did not come back on a wide terminal: %q", m.input.Placeholder)
}
}
// A local mode has a placeholder of its own, and resizing must not replace it
// with the model's.
func TestResizingLeavesALocalModePlaceholderAlone(t *testing.T) {
m, _ := newHistoryModel(t)
m.Update(key("!"))
want := m.input.Placeholder
m.Update(tea.WindowSizeMsg{Width: 40, Height: 24})
if m.input.Placeholder != want {
t.Errorf("shell placeholder = %q, want %q", m.input.Placeholder, want)
}
}