package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"codey/internal/approval"
"codey/internal/llm"
)
func TestPrettyModel(t *testing.T) {
cases := map[string]string{
"claude-opus-5": "Opus 5",
"claude-sonnet-5": "Sonnet 5",
"claude-haiku-4-5-20251001": "Haiku 4.5",
"claude-opus-5[1m]": "Opus 5[1m]",
"claude-sonnet-4-5-latest": "Sonnet 4.5",
"gpt-4o": "gpt-4o", // not ours: left alone rather than mangled
"some/gateway/model": "some/gateway/model",
"": "",
}
for in, want := range cases {
if got := prettyModel(in); got != want {
t.Errorf("prettyModel(%q) = %q, want %q", in, got, want)
}
}
}
// The welcome block is laid out in columns, so a terminal too narrow to hold
// both must drop the mascot rather than slice it into noise.
func TestSplashDropsMascotWhenNarrow(t *testing.T) {
o := Options{Client: llm.New(llm.WithAPIKey("x")), Root: "/tmp/repo", Version: "v0.6.0"}
wide := splash(o, 100)
if !strings.Contains(wide, mascot[2]) {
t.Error("mascot missing at a comfortable width")
}
if !strings.Contains(wide, "/tmp/repo") {
t.Error("root missing from the welcome block")
}
for _, line := range strings.Split(strings.TrimRight(wide, "\n"), "\n") {
if w := lipgloss.Width(line); w > 100 {
t.Errorf("welcome line is %d cells wide, over the 100 available: %q", w, line)
}
}
// A deeply nested working directory is a long line, not a narrow
// terminal: it gets truncated, and the mascot stays.
deep := Options{Client: o.Client, Version: o.Version,
Root: "/tmp/claude-1000/-home-joni-Work-GO-coding-harness/b520442d-29dd-4870/scratchpad/demo"}
if !strings.Contains(splash(deep, 100), mascot[2]) {
t.Error("a long root path dropped the mascot on a wide terminal")
}
narrow := splash(o, 24)
if strings.Contains(narrow, mascot[2]) {
t.Error("mascot survived into a terminal too narrow for it")
}
if !strings.Contains(narrow, "codey") {
t.Error("narrow welcome block lost its name")
}
}
func TestStatusShowsContextGauge(t *testing.T) {
m := newTestModel(t)
m.opts.ContextWindow = 200_000
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m.Update(evUsage{usage: llm.Usage{InputTokens: 2_000, CacheReadInputTokens: 18_000, OutputTokens: 500}})
status := m.status()
if !strings.Contains(status, "20.0k") {
t.Errorf("context size missing from the status bar: %q", status)
}
if !strings.Contains(status, "(10%)") {
t.Errorf("context percentage wrong or missing: %q", status)
}
// A second turn replaces the gauge rather than adding to it: the context
// is how much the model read this time, not the sum of every turn.
m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000, CacheReadInputTokens: 3_000}})
if got := m.status(); !strings.Contains(got, "(2%)") {
t.Errorf("gauge accumulated across turns instead of replacing: %q", got)
}
}
func TestShiftTabCyclesApprovalMode(t *testing.T) {
m := newTestModel(t)
if strings.Contains(m.modeLine(), "accept-all") {
t.Fatal("started in accept-all without being asked to")
}
m.Update(key("shift+tab"))
if !m.opts.Policy.YOLO {
t.Error("shift+tab did not turn accept-all on")
}
if !strings.Contains(m.modeLine(), "accept-all on") {
t.Errorf("mode line does not report accept-all: %q", m.modeLine())
}
if !strings.Contains(m.tr.render(), "accept-all on") {
t.Error("a change to what the agent may do was not recorded in the transcript")
}
m.Update(key("shift+tab"))
if m.opts.Policy.YOLO {
t.Error("shift+tab did not turn accept-all back off")
}
}
func TestInputBoxGrowsWithContentAndStops(t *testing.T) {
m := newTestModel(t)
if got := m.input.Height(); got != 1 {
t.Errorf("empty prompt is %d lines tall, want 1", got)
}
m.input.SetValue("one\ntwo\nthree")
m.growInput()
if got := m.input.Height(); got != 3 {
t.Errorf("three lines of input gave a box %d tall", got)
}
m.input.SetValue(strings.Repeat("x\n", 40))
m.growInput()
if got := m.input.Height(); got != inputMaxLines {
t.Errorf("box grew to %d, want it capped at %d", got, inputMaxLines)
}
}
// The numbered options are the primary way to answer, so the number a person
// reads must reach the decision it is printed next to.
func TestApprovalNumbersMatchTheirLabels(t *testing.T) {
cases := []struct {
name string
rememberable bool
want map[string]approval.Decision
}{
{"rememberable", true, map[string]approval.Decision{
"1": approval.Once,
"2": approval.AlwaysThisCommand,
"3": approval.AlwaysThisTool,
"4": approval.Deny,
}},
{"opaque", false, map[string]approval.Decision{
"1": approval.Once,
"2": approval.AlwaysThisTool,
"3": approval.Deny,
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for k, want := range tc.want {
m := newTestModel(t)
req := approval.Request{Tool: "bash", Summary: "run it", Detail: "ls", Rememberable: tc.rememberable}
reply := pend(m, req)
view := m.approvalView()
opts := m.choices(req)
idx := int(k[0] - '1')
if !strings.Contains(view, opts[idx].label) {
t.Fatalf("option %s (%q) is not in the box:\n%s", k, opts[idx].label, view)
}
m.Update(key(k))
select {
case got := <-reply:
if got != want {
t.Errorf("key %s sent %v, want %v", k, got, want)
}
default:
t.Fatalf("key %s answered nothing", k)
}
}
})
}
}
// The chrome grows and shrinks — the prompt box with what is typed, the
// approval box when it appears — so the view has to stay exactly as tall as
// the terminal rather than pushing its own top off the screen.
func TestViewFillsExactlyTheTerminalHeight(t *testing.T) {
for _, size := range [][2]int{{80, 24}, {120, 40}, {60, 30}} {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]})
for i := 0; i < 40; i++ {
m.tr.addUser("a line of scrollback")
}
m.sync()
if got := lipgloss.Height(m.View()); got != size[1] {
t.Errorf("%dx%d: view is %d rows, want %d", size[0], size[1], got, size[1])
}
m.input.SetValue("one\ntwo\nthree\nfour")
m.growInput()
if got := lipgloss.Height(m.View()); got != size[1] {
t.Errorf("%dx%d with a grown prompt: view is %d rows, want %d", size[0], size[1], got, size[1])
}
pend(m, approval.Request{Tool: "bash", Summary: "run it", Detail: "rm -rf /", Rememberable: true})
if got := lipgloss.Height(m.View()); got != size[1] {
t.Errorf("%dx%d while approving: view is %d rows, want %d", size[0], size[1], got, size[1])
}
}
}
// Every line of chrome has to fit the terminal it is drawn in; a status bar
// one cell too wide wraps and silently eats a row of the transcript.
func TestChromeNeverExceedsTerminalWidth(t *testing.T) {
for _, w := range []int{40, 60, 80, 120} {
m := newTestModel(t)
m.opts.Effort = "high"
m.Update(tea.WindowSizeMsg{Width: w, Height: 24})
m.Update(evUsage{usage: llm.Usage{InputTokens: 24_000, CacheReadInputTokens: 180_000, OutputTokens: 4_200}})
m.phase = phaseRunning
for name, line := range map[string]string{
"activity": m.activity(),
"status": m.status(),
"mode": m.modeLine(),
"input": m.inputBox(),
} {
for _, row := range strings.Split(line, "\n") {
if got := lipgloss.Width(row); got > w {
t.Errorf("width %d: %s line is %d cells: %q", w, name, got, row)
}
}
}
}
}
// A transcript entry the renderer does not know about must not vanish; the
// welcome block is passed through verbatim.
func TestRawEntryIsPassedThrough(t *testing.T) {
tr := newTranscript(80)
tr.addRaw("▐██████▌ verbatim\n")
if got := tr.render(); !strings.Contains(got, "▐██████▌ verbatim") {
t.Errorf("raw entry was rewritten: %q", got)
}
}
func TestCamelNamesTools(t *testing.T) {
for in, want := range map[string]string{
"read_file": "ReadFile",
"bash": "Bash",
"edit_file": "EditFile",
"": "",
} {
if got := camel(in); got != want {
t.Errorf("camel(%q) = %q, want %q", in, got, want)
}
}
}
func TestNewModelOpensWithTheWelcomeBlock(t *testing.T) {
m := newModel(context.Background(), Options{
Client: llm.New(llm.WithAPIKey("x")),
Root: "/tmp/repo",
Policy: approval.New(nil),
Banner: []string{"resumed session abc"},
})
out := m.tr.render()
if !strings.Contains(out, "codey") {
t.Error("welcome block missing")
}
if i, j := strings.Index(out, "codey"), strings.Index(out, "resumed session abc"); i < 0 || j < 0 || i > j {
t.Error("the banner should follow the welcome block, not precede it")
}
}
func TestDiffStatCountsEditsNotHeaders(t *testing.T) {
diff := "--- a/x.go\n+++ b/x.go\n@@ -1,2 +1,4 @@\n ctx\n-gone\n+one\n+two\n+three\n"
if got, want := diffStat(strings.Split(diff, "\n")), "3 additions, 1 removal"; got != want {
t.Errorf("diffStat = %q, want %q", got, want)
}
// The +++/--- file headers look like edits and are not.
if got := diffStat([]string{"--- a/x.go", "+++ b/x.go"}); got != "0 additions, 0 removals" {
t.Errorf("file headers counted as edits: %q", got)
}
tr := newTranscript(80)
tr.startTool("edit_file", "path=x.go")
tr.finishTool("edit_file", diff, false)
if out := tr.render(); !strings.Contains(out, "3 additions, 1 removal") {
t.Errorf("diff rendered without its stat line:\n%s", out)
}
}
// Cache reads are the number Codey exists to move, so they belong on
// the status bar rather than only in the metrics file.
func TestStatusReportsCacheReadsSeparately(t *testing.T) {
m := newTestModel(t)
m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m.Update(evUsage{usage: llm.Usage{InputTokens: 4_200, CacheReadInputTokens: 58_000, OutputTokens: 1_340}})
status := m.status()
for _, want := range []string{"4.2k", "58.0k", "1.3k"} {
if !strings.Contains(status, want) {
t.Errorf("%s missing from the status bar: %q", want, status)
}
}
// With nothing cached there is no cache field to read past.
m2 := newTestModel(t)
m2.Update(evUsage{usage: llm.Usage{InputTokens: 900, OutputTokens: 100}})
if strings.Contains(m2.status(), "⟳") {
t.Errorf("empty cache field still drawn: %q", m2.status())
}
}