369 lines
12 KiB
Go
369 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/agent/testutil"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/i18n"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// TestRunStatuslineCmd checks the custom status-line runner: it returns the
|
|
// first stdout line and forwards the JSON payload on stdin.
|
|
func TestRunStatuslineCmd(t *testing.T) {
|
|
firstLineCmd := "printf 'row-one\\nrow-two\\n'"
|
|
stdinCmd := "cat"
|
|
failCmd := "exit 3"
|
|
if runtime.GOOS == "windows" {
|
|
firstLineCmd = "echo row-one & echo row-two"
|
|
stdinCmd = "more"
|
|
failCmd = "exit /b 3"
|
|
}
|
|
|
|
// Multi-line output collapses to the first row.
|
|
if got := runStatuslineCmd(firstLineCmd, "{}"); got != "row-one" {
|
|
t.Errorf("multi-line output should collapse to the first row, got %q", got)
|
|
}
|
|
// The JSON payload is delivered on stdin.
|
|
if got := runStatuslineCmd(stdinCmd, `{"model":"deepseek"}`); got != `{"model":"deepseek"}` {
|
|
t.Errorf("stdin payload not forwarded, got %q", got)
|
|
}
|
|
// A failing command yields an empty line, not an error.
|
|
if got := runStatuslineCmd(failCmd, "{}"); got != "" {
|
|
t.Errorf("failed command should yield empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunStatuslineCmdNormalizesQuotedNodeEval(t *testing.T) {
|
|
if _, err := exec.LookPath("node"); err != nil {
|
|
t.Skip("node not available")
|
|
}
|
|
script := "let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { const payload = JSON.parse(input); console.log(payload.model) })"
|
|
cmd := `node -e "\"` + script + `\""`
|
|
timeout := statuslineCommandTimeout
|
|
if runtime.GOOS == "windows" {
|
|
// Windows CI cold-starts node.exe through Defender scanning while the
|
|
// rest of the module compiles and tests in parallel; a fresh toolchain
|
|
// (empty setup-go cache) pushes that past 10s. The production timeout
|
|
// is not under test here — only the quoted-eval normalization is.
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
if got := runStatuslineCmdWithTimeout(cmd, `{"model":"deepseek"}`, timeout); got != "deepseek" {
|
|
t.Fatalf("normalized statusline node -e output = %q, want deepseek", got)
|
|
}
|
|
}
|
|
|
|
// TestRunStatuslineDisabled confirms no command means no work (nil cmd), without
|
|
// touching the controller.
|
|
func TestRunStatuslineDisabled(t *testing.T) {
|
|
m := chatTUI{} // no statuslineCmd, nil ctrl
|
|
if cmd := m.runStatusline(); cmd != nil {
|
|
t.Error("an unconfigured status line must return a nil tea.Cmd")
|
|
}
|
|
}
|
|
|
|
func TestModelSwitchRefreshesCustomStatusline(t *testing.T) {
|
|
oldCtrl := control.New(control.Options{Label: "old-model"})
|
|
newCtrl := control.New(control.Options{Label: "new-model"})
|
|
m := newChatTUI(oldCtrl, "", make(chan event.Event, 1), 80)
|
|
m.statuslineCmd = "cat"
|
|
m.statuslineOut = `{"model":"old-model"}`
|
|
|
|
_, cmd := m.Update(modelSwitchMsg{
|
|
ref: "provider/new-model",
|
|
ctrl: newCtrl,
|
|
label: "new-model",
|
|
})
|
|
if cmd == nil {
|
|
t.Fatal("model switch should schedule commands")
|
|
}
|
|
if !statuslineCommandHasModel(cmd, "new-model") {
|
|
t.Fatal("model switch did not refresh custom statusline with the new model")
|
|
}
|
|
}
|
|
|
|
func statuslineCommandHasModel(cmd tea.Cmd, model string) bool {
|
|
msg := cmd()
|
|
switch msg := msg.(type) {
|
|
case statuslineMsg:
|
|
return strings.Contains(msg.out, `"model":"`+model+`"`)
|
|
case tea.BatchMsg:
|
|
for _, child := range msg {
|
|
if child == nil {
|
|
continue
|
|
}
|
|
if statuslineCommandHasModel(child, model) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestIdleStatuslineIsCompact(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineView(t, false)
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "ready") {
|
|
t.Fatalf("idle status line missing mode status:\n%s", plain)
|
|
}
|
|
if !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
|
|
t.Fatalf("idle status line missing plan-toggle hint:\n%s", plain)
|
|
}
|
|
for _, old := range []string{"Shift-Tab", "Ctrl-O", "Ctrl-D", "Enter sends", "Esc clears/exits state", "PgUp/PgDn"} {
|
|
if strings.Contains(plain, old) {
|
|
t.Fatalf("idle status line should not contain %q:\n%s", old, plain)
|
|
}
|
|
}
|
|
if strings.Contains(plain, "[auto]") {
|
|
t.Fatalf("idle status line should use pill label, not bracketed tag:\n%s", plain)
|
|
}
|
|
if !strings.Contains(content, "\x1b[48;2;245;158;11m") {
|
|
t.Fatalf("Auto status line should use amber pill background, got:\n%q", content)
|
|
}
|
|
}
|
|
|
|
func TestYoloStatuslineUsesDangerPill(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineView(t, true)
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "YOLO") || !strings.Contains(plain, "approvals skipped") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
|
|
t.Fatalf("YOLO status line missing warning text:\n%s", plain)
|
|
}
|
|
if strings.Contains(plain, "[YOLO]") {
|
|
t.Fatalf("YOLO status line should use a pill label, not bracketed tag:\n%s", plain)
|
|
}
|
|
if !strings.Contains(content, "\x1b[48;2;229;72;77m") {
|
|
t.Fatalf("YOLO status line should use danger pill background, got:\n%q", content)
|
|
}
|
|
}
|
|
|
|
func TestPlanStatuslineUsesBluePill(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderPlanStatuslineView(t)
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "Plan") || !strings.Contains(plain, "ready") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
|
|
t.Fatalf("plan status line missing mode status:\n%s", plain)
|
|
}
|
|
if !strings.Contains(content, "\x1b[48;2;37;99;235m") {
|
|
t.Fatalf("Plan status line should use blue pill background, got:\n%q", content)
|
|
}
|
|
}
|
|
|
|
func TestStatuslineCycleHintFollowsLanguage(t *testing.T) {
|
|
i18n.DetectLanguage("zh")
|
|
t.Cleanup(func() { i18n.DetectLanguage("en") })
|
|
|
|
content := renderStatuslineView(t, false)
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "Auto") || !strings.Contains(plain, "就绪") || !strings.Contains(plain, "(shift+tab 切换计划 · ctrl+y yolo)") {
|
|
t.Fatalf("localized plan-toggle hint missing:\n%s", plain)
|
|
}
|
|
if strings.Contains(plain, "ready") || strings.Contains(plain, "shift+tab toggles plan · ctrl+y yolo") {
|
|
t.Fatalf("localized status line should not fall back to English:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestDesktopShortcutStatuslineUsesPlanToggleHint(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineViewWithShortcutLayout(t, "desktop")
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "Ask") || !strings.Contains(plain, "(shift+tab toggles plan · ctrl+y yolo)") {
|
|
t.Fatalf("desktop shortcut status line missing unified plan-toggle hint:\n%s", plain)
|
|
}
|
|
if strings.Contains(plain, "ask/auto/plan") {
|
|
t.Fatalf("desktop shortcut status line should not advertise Ask/Auto/Plan cycling:\n%s", plain)
|
|
}
|
|
}
|
|
|
|
func TestStatuslineShowsEffort(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineViewWithEffort(t, "auto")
|
|
lines := bottomStatusPlainLines(content)
|
|
if len(lines) != 2 {
|
|
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
if !strings.Contains(lines[0], "effort auto") {
|
|
t.Fatalf("mode row should show effort:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if strings.Contains(lines[1], "effort auto") {
|
|
t.Fatalf("data row should not show effort:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestStatuslineKeepsCacheRatesInPrimaryDataRow(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineViewWithCache(t)
|
|
lines := bottomStatusPlainLines(content)
|
|
if len(lines) != 2 {
|
|
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
want := "deepseek-v4-flash · turn hit 90.00% · avg 90.00%"
|
|
if !strings.Contains(lines[1], want) {
|
|
t.Fatalf("data row should keep cache rates next to model:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestStatuslinePutsGitIdentityOnModeRow(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineViewWithGitAndEffort(t)
|
|
lines := bottomStatusPlainLines(content)
|
|
if len(lines) != 2 {
|
|
t.Fatalf("status block lines = %d, want 2:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
if !strings.Contains(lines[0], "effort auto · Reasonix@codex/demo (+3 -1 ?2)") {
|
|
t.Fatalf("mode row should include effort before git identity:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if strings.Contains(lines[1], "Reasonix@codex/demo") {
|
|
t.Fatalf("data row should not include git identity:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
if !strings.Contains(lines[1], "deepseek-v4-flash") || strings.Contains(lines[1], "effort auto") {
|
|
t.Fatalf("data row should keep model without effort:\n%s", strings.Join(lines, "\n"))
|
|
}
|
|
}
|
|
|
|
func TestStatuslineExplicitEffortUsesBlue(t *testing.T) {
|
|
i18n.DetectLanguage("en")
|
|
|
|
content := renderStatuslineViewWithEffort(t, "max")
|
|
plain := bottomStatusPlain(content)
|
|
if !strings.Contains(plain, "effort max") {
|
|
t.Fatalf("status data line should show explicit effort:\n%s", plain)
|
|
}
|
|
if !strings.Contains(content, "\x1b[1;38;2;37;99;235m") {
|
|
t.Fatalf("explicit effort should use blue foreground, got:\n%q", content)
|
|
}
|
|
}
|
|
|
|
func TestRefreshEffortStatusUsesCurrentModel(t *testing.T) {
|
|
isolateUserConfig(t)
|
|
|
|
ctrl := control.New(control.Options{})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
|
m.modelRef = "deepseek-flash/deepseek-v4-flash"
|
|
m.refreshEffortStatus()
|
|
if m.effortLevel != "auto" {
|
|
t.Fatalf("effortLevel = %q, want auto", m.effortLevel)
|
|
}
|
|
}
|
|
|
|
func renderStatuslineView(t *testing.T, yolo bool) string {
|
|
t.Helper()
|
|
|
|
ctrl := control.New(control.Options{})
|
|
ctrl.SetAutoApproveTools(yolo)
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func renderStatuslineViewWithShortcutLayout(t *testing.T, layout string) string {
|
|
t.Helper()
|
|
|
|
ctrl := control.New(control.Options{})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
|
m.cfg = config.Default()
|
|
if err := m.cfg.SetUIShortcutLayout(layout); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func renderStatuslineViewWithEffort(t *testing.T, effort string) string {
|
|
t.Helper()
|
|
|
|
ctrl := control.New(control.Options{})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
|
m.label = "deepseek-v4-flash"
|
|
m.effortLevel = effort
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func renderStatuslineViewWithGitAndEffort(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
ctrl := control.New(control.Options{})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 120)
|
|
m.label = "deepseek-v4-flash"
|
|
m.effortLevel = "auto"
|
|
m.gitStatus = gitStatus{
|
|
Repo: "Reasonix",
|
|
Branch: "codex/demo",
|
|
Added: 3,
|
|
Removed: 1,
|
|
Untracked: 2,
|
|
}
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func renderStatuslineViewWithCache(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
prov := testutil.NewMock("deepseek-v4-flash", testutil.Turn{
|
|
Text: "ok",
|
|
Usage: &provider.Usage{
|
|
CacheHitTokens: 900,
|
|
CacheMissTokens: 100,
|
|
CompletionTokens: 50,
|
|
PromptTokens: 1000,
|
|
TotalTokens: 1050,
|
|
},
|
|
})
|
|
exec := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{MaxSteps: 1, ContextWindow: 200_000}, event.Discard)
|
|
if err := exec.Run(context.Background(), "hello"); err != nil {
|
|
t.Fatalf("seed agent usage: %v", err)
|
|
}
|
|
ctrl := control.New(control.Options{Executor: exec})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 160)
|
|
m.label = "deepseek-v4-flash"
|
|
m.effortLevel = "auto"
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 160, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func renderPlanStatuslineView(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
ctrl := control.New(control.Options{})
|
|
m := newChatTUI(ctrl, "", make(chan event.Event, 1), 80)
|
|
m.planMode = true
|
|
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
|
return next.(chatTUI).View().Content
|
|
}
|
|
|
|
func bottomStatusPlain(content string) string {
|
|
return strings.Join(bottomStatusPlainLines(content), "\n")
|
|
}
|
|
|
|
func bottomStatusPlainLines(content string) []string {
|
|
lines := strings.Split(ansi.Strip(content), "\n")
|
|
if len(lines) < 2 {
|
|
return lines
|
|
}
|
|
return lines[len(lines)-2:]
|
|
}
|