Files
zero/internal/tui/model_test.go
Alaa Elsamouly 30bff28efc Fix/detailed view scroll select (#498)
* fix(tui): strip all ANSI escapes from copied transcript text

renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences.

* refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems

Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function.

Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet.

Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller.

* feat(tui): scrollable detailed transcript viewport with sidebar suppression

Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys.

Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view.

Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration.

Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output.

* test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows

TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode.

TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0.

* test(tui): ANSI strip verification for markdown rendering controls and selectable text

TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped.

TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer.

* fix for coderabit, and better fewer code.

* fix(tui): address PR review feedback dead code, hover clear, and
rebindable key label

* format

* addressing the review issue

1. the ctrl + b in detailed mode, added the guard to not be able to do
it in detailed mode so it affect its state in the normal mode.

2. added a test coverage for keyDown after `keDown then keyUp` so it
   covers that.

* fix(tui): close file drill-in when entering detailed transcript

toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode.

* test(tui): verify file drill-in closes on detailed toggle

Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug.

* fix(tui): block composer hit-test while detailed transcript is active

composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed.

* test(tui): verify composer mouse is ignored in detailed mode

Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing.
2026-07-05 19:36:17 +08:00

2678 lines
92 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"bytes"
"context"
"errors"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// execCmd runs a possibly-batched command synchronously and returns the first
// substantive message. Run starts batch the agent command with the spinner
// tick, so tests unwrap the batch and skip spinner housekeeping.
func execCmd(cmd tea.Cmd) tea.Msg {
if cmd == nil {
return nil
}
msg := cmd()
batch, ok := msg.(tea.BatchMsg)
if !ok {
return msg
}
for _, sub := range batch {
if inner := execCmd(sub); inner != nil {
if _, isTick := inner.(spinner.TickMsg); !isTick {
return inner
}
}
}
return nil
}
type fakeProvider struct {
events []zeroruntime.StreamEvent
requests []zeroruntime.CompletionRequest
}
func (provider *fakeProvider) StreamCompletion(
ctx context.Context,
request zeroruntime.CompletionRequest,
) (<-chan zeroruntime.StreamEvent, error) {
provider.requests = append(provider.requests, request)
ch := make(chan zeroruntime.StreamEvent, len(provider.events))
for _, event := range provider.events {
ch <- event
}
close(ch)
return ch, nil
}
func TestPromptSubmitInjectsLiveSessionModelContext(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "I am using the active session model."},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
ProviderName: "ollama-cloud",
ModelName: "glm-5.1",
Provider: provider,
Registry: tools.NewRegistry(),
})
m.input.SetValue("which model are you")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(execCmd(cmd))
_ = updated.(model)
if len(provider.requests) != 1 {
t.Fatalf("expected one provider request, got %d", len(provider.requests))
}
if len(provider.requests[0].Messages) == 0 {
t.Fatal("expected provider request to include a system message")
}
systemPrompt := provider.requests[0].Messages[0].Content
for _, want := range []string{
"Active provider: ollama-cloud",
"Active model: glm-5.1",
"Persisted config",
} {
if !strings.Contains(systemPrompt, want) {
t.Fatalf("expected system prompt to contain %q, got:\n%s", want, systemPrompt)
}
}
}
func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventReasoning, Content: "private "},
{Type: zeroruntime.StreamEventReasoning, Content: "thought"},
{Type: zeroruntime.StreamEventText, Content: "public answer"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
ProviderName: "tokenrouter",
ModelName: "MiniMax-M3",
Provider: provider,
Registry: tools.NewRegistry(),
})
base := time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC)
times := []time.Time{
base, // run start: consumed by turnStartedAt (the working-line elapsed clock)
base,
base.Add(1 * time.Second),
base.Add(1800 * time.Millisecond),
base.Add(2500 * time.Millisecond),
base.Add(6 * time.Second),
}
m.now = func() time.Time {
if len(times) == 0 {
return base.Add(6 * time.Second)
}
next := times[0]
times = times[1:]
return next
}
m.input.SetValue("hello")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
updated, _ = next.Update(execCmd(cmd))
next = updated.(model)
reasoning, ok := findTranscriptRow(next.transcript, rowReasoning)
if !ok || reasoning.text != "private thought" {
t.Fatalf("reasoning row = %#v, ok=%v", reasoning, ok)
}
if reasoning.turnElapsed != 1500*time.Millisecond {
t.Fatalf("reasoning elapsed = %s, want 1.5s", reasoning.turnElapsed)
}
assistant, ok := findTranscriptRow(next.transcript, rowAssistant)
if !ok || assistant.text != "public answer" {
t.Fatalf("assistant row = %#v, ok=%v", assistant, ok)
}
if assistant.turnElapsed != 6*time.Second {
t.Fatalf("assistant elapsed = %s, want 6s", assistant.turnElapsed)
}
if strings.Contains(assistant.text, "private thought") {
t.Fatalf("assistant answer leaked reasoning: %#v", assistant)
}
}
func TestParseCommand(t *testing.T) {
cases := []struct {
input string
kind commandKind
text string
}{
{input: "", kind: commandEmpty},
{input: " ", kind: commandEmpty},
{input: "/help", kind: commandHelp},
{input: "/clear", kind: commandClear},
{input: "/exit", kind: commandExit},
{input: "/quit", kind: commandExit},
{input: "/tools", kind: commandTools},
{input: "/permissions", kind: commandPermissions},
{input: "/sandbox-setup", kind: commandSandboxSetup},
{input: "/context", kind: commandContext},
{input: "/model", kind: commandModel},
{input: "/model list", kind: commandModel, text: "list"},
{input: "/search needle", kind: commandSearch, text: "needle"},
{input: "/find needle", kind: commandSearch, text: "needle"},
{input: "/resume", kind: commandResume},
{input: "/sessions", kind: commandResume},
{input: "/spec add review flow", kind: commandSpec, text: "add review flow"},
{input: "/compact", kind: commandCompact},
{input: "/effort high", kind: commandEffort, text: "high"},
{input: "/style concise", kind: commandStyle, text: "concise"},
{input: "/debug-mode", kind: commandDebug},
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
command := parseCommand(tc.input)
if command.kind != tc.kind || command.text != tc.text {
t.Fatalf("expected kind=%v text=%q, got kind=%v text=%q", tc.kind, tc.text, command.kind, command.text)
}
})
}
}
func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
names := listCommandNames()
for _, name := range []string{"/help", "/model", "/provider", "/context", "/debug-mode", "/quit"} {
if !stringSliceContains(names, name) {
t.Fatalf("expected command names to contain %s, got %#v", name, names)
}
}
resolved, ok := resolveCommand("/quit")
if !ok || resolved.kind != commandExit {
t.Fatalf("expected /quit to resolve to exit, got ok=%v command=%#v", ok, resolved)
}
help := strings.Join(formatCommandHelpLines(), "\n")
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "/spec", "model"} {
assertContains(t, help, want)
}
}
func TestTranscriptReducer(t *testing.T) {
transcript := initialTranscript()
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendAssistant, text: "hi"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendSystem, text: "note"})
transcript = reduceTranscript(transcript, transcriptAction{kind: actionAppendError, text: "boom"})
if len(transcript) != 5 {
t.Fatalf("expected welcome plus four rows, got %#v", transcript)
}
if transcript[1].kind != rowUser || transcript[1].text != "hello" {
t.Fatalf("expected user row, got %#v", transcript[1])
}
if transcript[3].kind != rowSystem || transcript[3].text != "note" {
t.Fatalf("expected system row, got %#v", transcript[3])
}
cleared := reduceTranscript(transcript, transcriptAction{kind: actionClear})
if len(cleared) != 1 || cleared[0].kind != rowWelcome {
t.Fatalf("expected clear to reset to welcome row, got %#v", cleared)
}
}
func TestInitialRenderShowsLimeChatSurface(t *testing.T) {
model := newModel(context.Background(), Options{
Cwd: `/workspace/zero`,
ProviderName: "openai",
ModelName: "gpt-4.1",
})
model.width = 120
model.height = 34
view := viewString(model.View())
assertContains(t, view, `/workspace/zero`)
assertContains(t, view, "openai/gpt-4.1")
assertContains(t, view, emptyStateTagline)
assertNotContains(t, view, "running zero against ")
assertNotContains(t, view, " 0 ")
assertContains(t, view, composerPlaceholder)
assertNotContains(t, view, "interactive")
if strings.Contains(view, "Welcome to Zero") {
t.Fatalf("empty chat surface should not show welcome transcript clutter, got %q", view)
}
}
func TestEmptyStateCollapsesAfterFirstPrompt(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width = 100
m.height = 30
m.input.SetValue("inspect the repo")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
next.width = m.width
next.height = m.height
if next.pending {
t.Fatal("expected missing provider prompt not to start an agent run")
}
if !transcriptContains(next.transcript, "inspect the repo") || !transcriptContains(next.transcript, "No provider configured.") {
t.Fatalf("expected prompt and notice rows in transcript, got %#v", next.transcript)
}
if next.flushed != len(next.transcript) {
t.Fatalf("expected settled rows to flush to scrollback, flushed=%d rows=%d", next.flushed, len(next.transcript))
}
view := viewString(next.View())
if strings.Contains(view, emptyStateTagline) {
t.Fatalf("empty state should collapse after first prompt, got %q", view)
}
// Working view shows provider status and the composer divider model fallback.
assertNotContains(t, view, "interactive")
assertContains(t, view, "no model")
}
func TestEmptyStateStaysVisibleOnEmptySubmit(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width = 96
m.height = 30
m.input.SetValue(" ")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
next.width = m.width
next.height = m.height
view := viewString(next.View())
assertContains(t, view, emptyStateTagline)
assertNotContains(t, view, "❯ inspect")
}
func TestHelpCommandAppendsHelpRow(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/help")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "/tools") {
t.Fatalf("expected help transcript to mention /tools, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "/model") || !transcriptContains(next.transcript, "/context") {
t.Fatalf("expected help transcript to mention model and context commands, got %#v", next.transcript)
}
}
func TestClearCommandResetsTranscript(t *testing.T) {
m := newModel(context.Background(), Options{})
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "hello"})
m.input.SetValue("/clear")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if len(next.transcript) != 2 || next.transcript[0].kind != rowWelcome {
t.Fatalf("expected clear to reset transcript to welcome + note, got %#v", next.transcript)
}
if !transcriptContains(next.transcript, "/new") {
t.Fatalf("expected clear to point users to /new, got %#v", next.transcript)
}
}
func TestToolsCommandListsRegisteredTools(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool("."))
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/tools")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "read_file") {
t.Fatalf("expected tools transcript to list read_file, got %#v", next.transcript)
}
}
func TestPermissionsCommandListsPersistentSandboxGrants(t *testing.T) {
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{FilePath: filepath.Join(t.TempDir(), "sandbox-grants.json")})
if err != nil {
t.Fatalf("NewGrantStore returned error: %v", err)
}
if _, err := store.Grant(sandbox.GrantInput{
ToolName: "bash",
Decision: sandbox.GrantAllow,
Reason: "sk-proj-sensitive trusted shell",
}); err != nil {
t.Fatalf("Grant bash returned error: %v", err)
}
if _, err := store.Grant(sandbox.GrantInput{
ToolName: "write_file",
Decision: sandbox.GrantDeny,
}); err != nil {
t.Fatalf("Grant write_file returned error: %v", err)
}
m := newModel(context.Background(), Options{
PermissionMode: agent.PermissionModeAsk,
SandboxStore: store,
})
m.input.SetValue("/permissions")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /permissions to be handled without starting an agent run")
}
text := transcriptText(next.transcript)
for _, want := range []string{
"Permissions",
"ask permissions",
"mode ask",
"Grants",
"bash [allow]",
"write_file [deny]",
"[REDACTED]",
} {
assertContains(t, text, want)
}
assertNotContains(t, text, "sk-proj-sensitive")
assertNotContains(t, text, "status: ok")
assertNotContains(t, text, "Permission mode:")
}
func TestPlanCommandShowsCurrentPlan(t *testing.T) {
registry := tools.NewRegistry()
planTool := tools.NewUpdatePlanTool()
result := planTool.Run(context.Background(), map[string]any{
"plan": []any{
map[string]any{
"id": "one",
"content": "Wire model catalog",
"status": "completed",
},
map[string]any{
"id": "two",
"content": "Add max turns",
"status": "in_progress",
"notes": "Go exec parity",
},
},
})
if result.Status != tools.StatusOK {
t.Fatalf("update_plan setup failed: %#v", result)
}
registry.Register(planTool)
m := newModel(context.Background(), Options{Registry: registry})
m.input.SetValue("/plan")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /plan to be handled without starting an agent run")
}
for _, want := range []string{"Current Plan", "Wire model catalog", "Add max turns", "in_progress", "Go exec parity"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected plan transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestPlanCommandHandlesMissingPlanTool(t *testing.T) {
m := newModel(context.Background(), Options{Registry: tools.NewRegistry()})
m.input.SetValue("/plan")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "No plan is active") {
t.Fatalf("expected missing plan message, got %#v", next.transcript)
}
}
func TestContextCommandShowsSessionState(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool("."))
m := newModel(context.Background(), Options{
Cwd: `D:\codings\Opensource\Zero`,
ProviderName: "openai",
ModelName: "gpt-4.1",
Registry: registry,
PermissionMode: agent.PermissionModeAsk,
})
m.input.SetValue("/context")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /context to be handled without starting an agent run")
}
for _, want := range []string{
`D:\codings\Opensource\Zero`,
"go runtime | ask permissions | 1 tool",
"provider openai",
"model gpt-4.1",
"max turns ",
"root ",
"registered 1",
} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected context transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestModelCommandShowsActiveModelWithoutRunningAgent(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: &fakeProvider{},
})
m.input.SetValue("/model list")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
for _, want := range []string{"Active model: gpt-4.1", "provider: openai", "Available models", "* gpt-4.1"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
if !transcriptHasMarkedModelEntry(next.transcript) {
t.Fatalf("expected model transcript to contain a marked model entry, got %#v", next.transcript)
}
if transcriptContains(next.transcript, "Model switching") {
t.Fatalf("expected /model list to show catalog, got switching placeholder: %#v", next.transcript)
}
}
func TestModelCommandSwitchesSessionModel(t *testing.T) {
var rebuilt config.ProviderProfile
nextProvider := &fakeProvider{}
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
},
Provider: &fakeProvider{},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilt = profile
return nextProvider, nil
},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1-mini" || next.provider != nextProvider {
t.Fatalf("expected model/provider to update, got model=%q provider=%#v", next.modelName, next.provider)
}
if rebuilt.Model != "gpt-4.1-mini" {
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
}
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
}, true); err != nil {
t.Fatalf("write user config: %v", err)
}
m := newModel(context.Background(), Options{
UserConfigPath: configPath,
ProviderName: "openai",
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
},
Provider: &fakeProvider{},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1-mini" {
t.Fatalf("modelName = %q, want gpt-4.1-mini", next.modelName)
}
persisted, err := config.Resolve(config.ResolveOptions{UserConfigPath: configPath})
if err != nil {
t.Fatalf("resolve persisted config: %v", err)
}
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !transcriptContains(next.transcript, "· saved") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
}
}
type stubModelSwitchCompactionGuard struct {
decision modelSwitchCompactionDecision
requests []modelSwitchCompactionRequest
}
func (guard *stubModelSwitchCompactionGuard) BeforeModelSwitch(request modelSwitchCompactionRequest) modelSwitchCompactionDecision {
guard.requests = append(guard.requests, request)
return guard.decision
}
func TestDefaultModelSwitchCompactionPolicyRequestsCompactionForLargeTargetWindowUsage(t *testing.T) {
decision := defaultModelSwitchCompactionPolicy{}.BeforeModelSwitch(modelSwitchCompactionRequest{
CurrentModel: "gpt-4.1",
TargetModel: "small-context-model",
TargetContextWindow: 1000,
EstimatedTokens: 850,
SessionEventCount: 20,
CompactRequests: 0,
})
if !decision.RequestCompaction {
t.Fatalf("expected default policy to request compaction, got %#v", decision)
}
if !strings.Contains(decision.Reason, "target context") {
t.Fatalf("expected reason to mention target context, got %q", decision.Reason)
}
}
func TestModelCommandRequestsCompactionBeforeDirtyContextSwitch(t *testing.T) {
guard := &stubModelSwitchCompactionGuard{
decision: modelSwitchCompactionDecision{
RequestCompaction: true,
Reason: "dirty context uses most of the target window",
},
}
previousGuard := modelSwitchCompactionGuard
modelSwitchCompactionGuard = guard
defer func() { modelSwitchCompactionGuard = previousGuard }()
originalProvider := &fakeProvider{}
rebuilds := 0
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-4.1",
},
Provider: originalProvider,
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilds++
return &fakeProvider{}, nil
},
})
m.sessionEvents = []sessions.Event{{Sequence: 1, Type: sessions.EventMessage}}
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if rebuilds != 0 {
t.Fatalf("provider should not be rebuilt before requested compaction, got %d rebuilds", rebuilds)
}
if next.modelName != "gpt-4.1" || next.provider != originalProvider {
t.Fatalf("expected active model/provider to remain unchanged, got model=%q provider=%#v", next.modelName, next.provider)
}
if next.compactRequests != 1 {
t.Fatalf("expected model switch to request compaction, got %d requests", next.compactRequests)
}
if len(guard.requests) != 1 {
t.Fatalf("expected one compaction guard request, got %d", len(guard.requests))
}
request := guard.requests[0]
if request.CurrentModel != "gpt-4.1" || request.TargetModel != "gpt-4.1-mini" {
t.Fatalf("unexpected guard model transition: %#v", request)
}
if request.SessionEventCount != 1 {
t.Fatalf("expected dirty session event count in guard request, got %#v", request)
}
for _, want := range []string{
"Context compaction requested before switching models.",
"dirty context uses most of the target window",
} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
}
}
}
func TestModelCommandRequiresProviderRebuildForSwitch(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "gpt-4.1",
},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1" {
t.Fatalf("expected active model to remain unchanged, got %q", next.modelName)
}
if !transcriptContains(next.transcript, "Provider rebuild is not available") {
t.Fatalf("expected provider rebuild availability error, got %#v", next.transcript)
}
}
func TestModelCommandRejectsSwitchWhilePending(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "gpt-4.1",
},
})
m.pending = true
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1" {
t.Fatalf("expected active model to remain unchanged, got %q", next.modelName)
}
if !transcriptContains(next.transcript, "Cannot switch models while a run is active") {
t.Fatalf("expected pending switch error, got %#v", next.transcript)
}
}
func TestModelCommandReportsProviderRebuildErrors(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4.1",
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "gpt-4.1",
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return nil, errors.New("rebuild failed")
},
})
m.input.SetValue("/model gpt-4.1-mini")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.modelName != "gpt-4.1" {
t.Fatalf("expected active model to remain unchanged, got %q", next.modelName)
}
if !transcriptContains(next.transcript, "rebuild failed") {
t.Fatalf("expected rebuild error, got %#v", next.transcript)
}
}
func TestDoctorCommandUsesCurrentProviderProfile(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderProfile: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "gpt-4.1",
APIKey: "sk-test", // credentialed so provider.config passes (isolates this render test from the no-key check)
},
})
m.input.SetValue("/doctor")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /doctor to be handled without starting an agent run")
}
for _, want := range []string{"Diagnostics", "Provider", "provider.connectivity", "Actions"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected doctor transcript to contain %q, got %#v", want, next.transcript)
}
}
for _, unwanted := range []string{"provider.config", "provider.model", "Generated", "Checks"} {
if transcriptContains(next.transcript, unwanted) {
t.Fatalf("expected doctor transcript to hide %q, got %#v", unwanted, next.transcript)
}
}
}
func TestSearchCommandUsesSessionStore(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{Title: "Searchable", Cwd: "repo", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create returned error: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{
Type: sessions.EventMessage,
Payload: map[string]any{
"role": "assistant",
"content": "needle appears here",
},
}); err != nil {
t.Fatalf("AppendEvent returned error: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/search needle")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /search to be handled without starting an agent run")
}
if !transcriptContains(next.transcript, "Found 1 local session event") || !transcriptContains(next.transcript, "needle appears here") {
t.Fatalf("expected search hit in transcript, got %#v", next.transcript)
}
}
func TestSearchCommandRequiresQuery(t *testing.T) {
m := newModel(context.Background(), Options{})
m.input.SetValue("/search")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "usage: /search <query>") {
t.Fatalf("expected search usage, got %#v", next.transcript)
}
}
func TestResumeCommandListsRecentSessions(t *testing.T) {
store := testSessionStore(t)
first, err := store.Create(sessions.CreateInput{Title: "Older", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create older returned error: %v", err)
}
if _, err := store.AppendEvent(first.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "old"}}); err != nil {
t.Fatalf("Append older returned error: %v", err)
}
second, err := store.Create(sessions.CreateInput{Title: "Newer", ModelID: "claude-sonnet-4.5", Provider: "anthropic"})
if err != nil {
t.Fatalf("Create newer returned error: %v", err)
}
if _, err := store.AppendEvent(second.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "new"}}); err != nil {
t.Fatalf("Append newer returned error: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /resume to be handled without starting an agent run")
}
// Bare /resume now opens the interactive session picker (like /model & /provider).
if next.picker == nil || next.picker.kind != pickerSession {
t.Fatalf("expected /resume to open the session picker, got picker=%#v", next.picker)
}
// Every row carries the session title (in the Label, after the timestamp) and
// resolves to / shows the session id (Value + Meta), for both sessions.
findByID := func(id string) (pickerItem, bool) {
for _, item := range next.picker.items {
if item.Value == id {
return item, true
}
}
return pickerItem{}, false
}
for _, want := range []struct{ title, id string }{{"Newer", second.SessionID}, {"Older", first.SessionID}} {
item, ok := findByID(want.id)
if !ok {
t.Fatalf("picker missing session id %q; items=%#v", want.id, next.picker.items)
}
if !strings.Contains(item.Label, want.title) {
t.Fatalf("picker Label %q should contain the title %q", item.Label, want.title)
}
if !strings.Contains(item.Meta, want.id) {
t.Fatalf("picker %q Meta should show the id %q, got %q", want.title, want.id, item.Meta)
}
}
// The picker overlay renders the titles and ids.
view := viewString(next.View())
for _, want := range []string{"Resume a session", "Newer", "Older", first.SessionID, second.SessionID} {
if !strings.Contains(view, want) {
t.Fatalf("session picker view missing %q:\n%s", want, view)
}
}
}
func TestResumePickerSelectionHydratesSession(t *testing.T) {
store := testSessionStore(t)
target, err := store.Create(sessions.CreateInput{Title: "Pick me", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create target: %v", err)
}
if _, err := store.AppendEvent(target.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"content": "hello"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.Create(sessions.CreateInput{Title: "Other", ModelID: "x", Provider: "y"}); err != nil {
t.Fatalf("Create other: %v", err)
}
m := newModel(context.Background(), Options{SessionStore: store})
m.input.SetValue("/resume")
updated, _ := m.Update(testKey(tea.KeyEnter))
m = updated.(model)
if m.picker == nil || m.picker.kind != pickerSession {
t.Fatalf("expected the session picker to open, got %#v", m.picker)
}
for i, item := range m.picker.items {
if item.Value == target.SessionID {
m.picker.selected = i
}
}
updated, cmd := m.Update(testKey(tea.KeyEnter)) // choosePicker
next := updated.(model)
if cmd != nil {
t.Fatal("selecting a session to resume should not start an agent run")
}
if next.picker != nil {
t.Fatal("picker should close after a selection")
}
if next.activeSession.SessionID != target.SessionID {
t.Fatalf("active session = %q, want %q", next.activeSession.SessionID, target.SessionID)
}
if !transcriptContains(next.transcript, "Resumed Zero session") || !transcriptContains(next.transcript, target.SessionID) {
t.Fatalf("expected the resume summary in the transcript, got %#v", next.transcript)
}
}
func TestResumePickerHidesEmptyFailedSessions(t *testing.T) {
store := testSessionStore(t)
real, err := store.Create(sessions.CreateInput{Title: "Real one", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create real: %v", err)
}
if _, err := store.AppendEvent(real.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "do a thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.AppendEvent(real.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "here is the thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
// An empty/failed run: prompt + the no-output guardrail stop, nothing else.
empty, err := store.Create(sessions.CreateInput{Title: "Empty one", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create empty: %v", err)
}
if _, err := store.AppendEvent(empty.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "user", "content": "do a thing"}}); err != nil {
t.Fatalf("Append: %v", err)
}
if _, err := store.AppendEvent(empty.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]any{"role": "assistant", "content": "Agent stopped after 3 turns with no output (no visible text and no tool calls) to avoid consuming tokens without making progress."}}); err != nil {
t.Fatalf("Append: %v", err)
}
picker := newModel(context.Background(), Options{SessionStore: store}).newSessionPicker()
if picker == nil {
t.Fatal("expected a picker containing the real session")
}
for _, item := range picker.items {
if item.Value == empty.SessionID {
t.Fatalf("empty/no-output session must be hidden from the picker: %#v", picker.items)
}
}
shown := false
for _, item := range picker.items {
if item.Value == real.SessionID {
shown = true
}
}
if !shown {
t.Fatalf("the real session must be shown: %#v", picker.items)
}
}
func TestResumeHonorsPriorCompaction(t *testing.T) {
store := testSessionStore(t)
session, err := store.Create(sessions.CreateInput{Title: "Compacted", ModelID: "gpt-4.1", Provider: "openai"})
if err != nil {
t.Fatalf("Create: %v", err)
}
for _, content := range []string{"alpha", "beta", "gamma", "delta"} {
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": content}}); err != nil {
t.Fatalf("Append: %v", err)
}
}
plan, err := store.PlanCompaction(session.SessionID, sessions.CompactionOptions{PreserveLast: 2, MaxPromptChars: 500})
if err != nil {
t.Fatalf("PlanCompaction: %v", err)
}
if _, err := store.RecordCompaction(session.SessionID, sessions.RecordCompactionInput{Plan: plan, Summary: "early summary"}); err != nil {
t.Fatalf("RecordCompaction: %v", err)
}
if _, err := store.AppendEvent(session.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: map[string]string{"content": "epsilon"}}); err != nil {
t.Fatalf("Append epsilon: %v", err)
}
raw, err := store.ReadEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadEvents: %v", err)
}
rehydrated, err := store.ReadRehydratedEvents(session.SessionID)
if err != nil {
t.Fatalf("ReadRehydratedEvents: %v", err)
}
if len(rehydrated) >= len(raw) {
t.Fatalf("test setup invalid: rehydrated (%d) should be < raw (%d)", len(rehydrated), len(raw))
}
m := newModel(context.Background(), Options{SessionStore: store})
next, _ := m.handleResumeCommand(session.SessionID)
// Resume must load the rehydrated (compaction-aware) context, not the raw log —
// matching the CLI's --resume and the in-TUI /compact reload. Compare contents,
// not just length: a regression that returned a same-length but reordered or
// substituted slice (e.g. a dropped original in place of the summary) would
// slip past a length check.
if !reflect.DeepEqual(next.sessionEvents, rehydrated) {
t.Fatalf("resumed sessionEvents do not match the rehydrated context (resume must honor prior compaction)\nresumed: %+v\nrehydrated: %+v\nraw: %+v", next.sessionEvents, rehydrated, raw)
}
}
func TestResumeCommandWithUnknownIDReportsMissingSession(t *testing.T) {
m := newModel(context.Background(), Options{SessionStore: testSessionStore(t)})
m.input.SetValue("/resume zero_123")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "zero session not found: zero_123") {
t.Fatalf("expected missing session message, got %#v", next.transcript)
}
}
func TestPromptSubmitAppendsUserAndAssistantRows(t *testing.T) {
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "hello"},
{Type: zeroruntime.StreamEventText, Content: " back"},
{Type: zeroruntime.StreamEventDone},
}}
m := newModel(context.Background(), Options{
Provider: provider,
Registry: tools.NewRegistry(),
SessionStore: testSessionStore(t),
})
m.input.SetValue("say hi")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if !transcriptContains(next.transcript, "say hi") {
t.Fatalf("expected user row after submit, got %#v", next.transcript)
}
if cmd == nil {
t.Fatal("expected submit to return agent command")
}
msg := execCmd(cmd)
updated, _ = next.Update(msg)
next = updated.(model)
if !transcriptContains(next.transcript, "hello back") {
t.Fatalf("expected assistant row after agent response, got %#v", next.transcript)
}
}
func TestPromptSubmitDoesNotStartAnotherRunWhilePending(t *testing.T) {
m := newModel(context.Background(), Options{
Provider: &fakeProvider{},
Registry: tools.NewRegistry(),
})
m.pending = true
m.input.SetValue("second prompt")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected no command while another run is pending")
}
if transcriptContains(next.transcript, "second prompt") {
t.Fatalf("pending prompt should not be appended, got %#v", next.transcript)
}
if !next.pending {
t.Fatal("expected existing pending run to remain pending")
}
}
func TestEscRequiresSecondPressToCancelPendingRun(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, cmd := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if cancelled {
t.Fatal("first Esc should not cancel the pending run")
}
if !next.pending {
t.Fatal("first Esc should leave the run pending")
}
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if cmd == nil {
t.Fatal("first Esc should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, escCancelConfirmText) {
t.Fatalf("status line = %q, want cancel confirmation", status)
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if !cancelled {
t.Fatal("second Esc should cancel pending run")
}
if next.pending {
t.Fatal("expected second Esc to clear pending state")
}
if next.activeRunID != 0 || next.runCancel != nil {
t.Fatalf("expected active run state to clear, got id=%d cancel=%v", next.activeRunID, next.runCancel)
}
if next.cancelConfirmActive {
t.Fatal("cancelling should clear cancel confirmation")
}
}
// TestEscArmingCancelConfirmationPreservesComposerDraft: the first Esc only
// arms the confirmation — nothing has actually been cancelled yet, so it
// must not destroy a draft the user is still typing. Only the confirming
// second Esc (the one that actually cancels the run) clears the composer.
func TestEscArmingCancelConfirmationPreservesComposerDraft(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
m.input.SetValue("draft prompt")
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
if next.composerValue() != "draft prompt" {
t.Fatalf("first Esc should preserve the draft, got %q", next.composerValue())
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if next.pending {
t.Fatal("second Esc should cancel the pending run")
}
if next.composerValue() != "" {
t.Fatalf("the confirming second Esc should clear the draft, got %q", next.composerValue())
}
}
// TestPasteDisarmsCancelConfirmation: a paste isn't a keypress, so it never
// went through the generic "any non-Esc key disarms it" hook. Pasting is a
// deliberate action just like typing or clicking — it must disarm a stale
// confirmation too, or a later, unrelated Esc could silently cancel the run.
func TestPasteDisarmsCancelConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
updated, _ = next.Update(testPaste("pasted text"))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("a paste should disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after a paste should re-arm, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after a paste should arm a fresh confirmation")
}
}
func TestEscCancelConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 1
m.runCancel = func() {}
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
seq := next.cancelConfirmSeq
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.cancelConfirmActive {
t.Fatal("stale expiry should not clear active cancel confirmation")
}
updated, _ = next.Update(cancelConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("matching expiry should clear cancel confirmation")
}
if !next.pending {
t.Fatal("expiring the confirmation should not cancel the run")
}
}
// TestEscCancelConfirmationDisarmsOnInterveningKey mirrors
// TestCtrlCExitConfirmationDisarmsOnInterveningKey: an intervening key other
// than Esc (typing, Ctrl+O, etc.) means the user moved on to something else,
// so a later, unrelated Esc must arm a fresh confirmation instead of
// silently cancelling off a stale press from seconds ago.
func TestEscCancelConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
seq := next.cancelConfirmSeq
updated, _ = next.Update(testKey('h'))
next = updated.(model)
if next.cancelConfirmActive {
t.Fatal("an intervening non-Esc key should disarm cancel confirmation")
}
if next.cancelConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so a stale expiry tick is ignored")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("Esc after an intervening key should re-arm, not cancel")
}
if !next.cancelConfirmActive {
t.Fatal("Esc after an intervening key should arm a fresh confirmation")
}
}
// TestEscCancelConfirmationDisarmsWhenStolenByAskUser: a mid-turn ask_user
// prompt lands between the two Esc presses. The user's second Esc denies the
// questionnaire (an earlier branch in the Esc handler), not a confirm, so it
// must not leave cancelConfirmActive armed for a later, unrelated Esc to
// silently cancel the run.
func TestEscCancelConfirmationDisarmsWhenStolenByAskUser(t *testing.T) {
m := newModel(context.Background(), Options{})
cancelled := false
m.pending = true
m.activeRunID = 1
m.runCancel = func() { cancelled = true }
updated, _ := m.Update(testKey(tea.KeyEsc))
next := updated.(model)
if !next.cancelConfirmActive {
t.Fatal("first Esc should arm cancel confirmation")
}
request := agent.AskUserRequest{Questions: []agent.AskUserQuestion{{Question: "name?"}}}
next.pendingAskUser = &pendingAskUserPrompt{
request: request,
answer: func([]string) {},
states: newAskUserStates(request.Questions),
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("an Esc consumed by the ask-user prompt must not cancel the run")
}
if next.cancelConfirmActive {
t.Fatal("an Esc consumed by the ask-user prompt must disarm the stale cancel confirmation")
}
updated, _ = next.Update(testKey(tea.KeyEsc))
next = updated.(model)
if cancelled {
t.Fatal("the next Esc should arm a fresh confirmation, not immediately cancel")
}
if !next.cancelConfirmActive {
t.Fatal("the next Esc should arm a fresh confirmation")
}
}
func TestAgentResponseCompletesStuckPlan(t *testing.T) {
runningPlan := func() planPanelState {
var s planPanelState
s.updateFromItems([]tools.PlanItem{
{Content: "a", Status: "completed"},
{Content: "b", Status: "in_progress"},
{Content: "c", Status: "pending"},
}, time.Now())
return s
}
t.Run("successful turn reconciles the stuck plan to complete", func(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.plan = runningPlan()
updated, _ := m.Update(agentResponseMsg{runID: 7, rows: []transcriptRow{{kind: rowAssistant, text: "done", final: true}}})
if next := updated.(model); !next.plan.isComplete() {
t.Fatalf("a successful turn should complete the plan, steps=%+v", next.plan.steps)
}
})
t.Run("errored turn leaves the plan incomplete", func(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.plan = runningPlan()
updated, _ := m.Update(agentResponseMsg{runID: 7, err: errors.New("boom")})
if next := updated.(model); next.plan.isComplete() {
t.Error("an errored turn must not force the plan complete")
}
})
t.Run("pending ask_user leaves the plan incomplete", func(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.plan = runningPlan()
m.pendingAskUser = &pendingAskUserPrompt{}
updated, _ := m.Update(agentResponseMsg{runID: 7, rows: []transcriptRow{{kind: rowAssistant, text: "done", final: true}}})
if next := updated.(model); next.plan.isComplete() {
t.Error("a mid-plan ask_user yield must not force the plan complete")
}
})
}
// TestToolResultDetailPrefersPreview: the card body uses the rich card-only
// Display.Preview on a successful result, falls back to Output when there's no
// preview, and always uses Output (the failure) on an error.
func TestToolResultDetailPrefersPreview(t *testing.T) {
withPreview := agent.ToolResult{
Name: "write_file",
Status: tools.StatusOK,
Output: "Created x.js (300 lines).",
Display: tools.Display{Summary: "Created x.js (300 lines).", Kind: "file", Preview: "--- /dev/null\n+++ b/x.js\n@@ -0,0 +1,300 @@\n+const x = 1"},
}
if got := toolResultDetail(withPreview); !strings.Contains(got, "+const x = 1") {
t.Errorf("successful result should use the preview, got %q", got)
}
noPreview := agent.ToolResult{Name: "bash", Status: tools.StatusOK, Output: "exit 0"}
if got := toolResultDetail(noPreview); got != "exit 0" {
t.Errorf("no preview: want Output, got %q", got)
}
errResult := agent.ToolResult{Name: "write_file", Status: tools.StatusError, Output: "Error: permission denied", Display: tools.Display{Preview: "must not show on error"}}
if got := toolResultDetail(errResult); got != "Error: permission denied" {
t.Errorf("error result must use Output (the failure), got %q", got)
}
}
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
func TestReasoningRefreshesActivityClock(t *testing.T) {
base := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
now := base
m := model{now: func() time.Time { return now }}
m.activeRunID = 7
m.lastStreamActivity = base
now = base.Add(20 * time.Second)
updated, _ := m.Update(agentReasoningMsg{runID: 7, delta: "thinking…"})
if got := updated.(model).lastStreamActivity; !got.Equal(now) {
t.Errorf("reasoning delta should refresh lastStreamActivity to now, got %v", got)
}
}
// TestStaleExplanationDropped: a plan-step explanation result from a previous run
// (older planDetailGen) is ignored, so it can't repopulate the cleared cache.
func TestStaleExplanationDropped(t *testing.T) {
m := model{now: time.Now, planDetailGen: 2}
m.plan.steps = []planStep{{content: "x", status: "completed"}}
updated, _ := m.Update(planStepExplanationMsg{stepIndex: 0, key: "k", gen: 1, text: "stale"})
m = updated.(model)
if _, ok := m.stepExplanation["k"]; ok {
t.Error("a stale-generation explanation must not populate the cache")
}
updated, _ = m.Update(planStepExplanationMsg{stepIndex: 0, key: "k", gen: 2, text: "fresh"})
m = updated.(model)
if m.stepExplanation["k"] != "fresh" {
t.Errorf("a current-gen explanation should cache, got %q", m.stepExplanation["k"])
}
}
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
// and the explanation generation advances.
func TestBeginRunResetsSidebarHidden(t *testing.T) {
m := newModel(context.Background(), Options{})
m.sidebarHidden = true
gen := m.planDetailGen
m = m.beginRun(nil)
if m.sidebarHidden {
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
}
if m.planDetailGen <= gen {
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
}
}
func TestStaleAgentResponseAfterCancelIsIgnored(t *testing.T) {
m := newModel(context.Background(), Options{})
m.pending = false
m.activeRunID = 0
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: "new prompt"})
updated, _ := m.Update(agentResponseMsg{
runID: 1,
rows: []transcriptRow{{kind: rowAssistant, text: "stale response"}},
})
next := updated.(model)
if transcriptContains(next.transcript, "stale response") {
t.Fatalf("stale response should be ignored, got %#v", next.transcript)
}
}
func TestAgentResponsePreservesToolResultMetadata(t *testing.T) {
diff := strings.Join([]string{
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1 +1 @@",
"-old",
"+new",
}, "\n")
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(agentResponseMsg{
runID: 7,
rows: []transcriptRow{{
kind: rowToolResult,
text: "tool result: apply_patch error",
tool: "apply_patch",
status: tools.StatusError,
detail: diff,
}},
})
next := updated.(model)
row, ok := findTranscriptRow(next.transcript, rowToolResult)
if !ok {
t.Fatalf("expected tool result row, got %#v", next.transcript)
}
if row.tool != "apply_patch" || row.status != tools.StatusError || row.detail != diff {
t.Fatalf("tool result metadata was not preserved: %#v", row)
}
rendered := next.renderRow(row, 80, buildRowContext(next.transcript))
assertContains(t, rendered, "old")
assertContains(t, rendered, "new")
assertNotContains(t, rendered, "@@")
}
func TestAgentResponsePreservesPermissionMetadata(t *testing.T) {
event := agent.PermissionEvent{
ToolCallID: "call_1",
ToolName: "write_file",
Action: agent.PermissionActionPrompt,
Permission: "prompt",
PermissionMode: agent.PermissionModeAsk,
Autonomy: "medium",
SideEffect: "write",
Reason: "Creates or overwrites files.",
Risk: sandbox.Risk{Level: sandbox.RiskHigh},
}
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(agentResponseMsg{
runID: 7,
rows: []transcriptRow{permissionTranscriptRow(event)},
})
next := updated.(model)
row, ok := findTranscriptRow(next.transcript, rowPermission)
if !ok {
t.Fatalf("expected permission row, got %#v", next.transcript)
}
if row.tool != "write_file" || row.permission == nil || row.permission.ToolCallID != "call_1" {
t.Fatalf("permission metadata was not preserved: %#v", row)
}
rendered := next.renderRow(row, 96, buildRowContext(next.transcript))
for _, want := range []string{"permission", "write_file", "prompt", "Creates or overwrites"} {
assertContains(t, rendered, want)
}
for _, blocked := range []string{"risk:", "risk=", "mode=", "permission=", "side_effect=", "autonomy="} {
if strings.Contains(rendered, blocked) {
t.Fatalf("normal permission row must not render %q, got %q", blocked, rendered)
}
}
}
func TestPermissionRequestShowsFocusedPrompt(t *testing.T) {
request := testPromptPermissionRequest()
request.Scope = "src/main.go"
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
m.width = 96
updated, cmd := m.Update(permissionRequestMsg{
runID: 7,
request: request,
})
next := updated.(model)
if cmd != nil {
t.Fatal("expected permission request to update TUI state synchronously")
}
if next.pendingPermission == nil {
t.Fatalf("expected permission prompt to be pending, got %#v", next)
}
if countTranscriptRows(next.transcript, rowPermission) != 1 {
t.Fatalf("expected permission request to append one permission row, got %#v", next.transcript)
}
row, ok := findTranscriptRow(next.transcript, rowPermission)
if !ok || row.permission == nil || row.permission.Scope != request.Scope {
t.Fatalf("expected permission row to preserve scope %q, got %#v", request.Scope, row)
}
view := plainRender(t, next.View())
for _, want := range []string{"write_file", "Yes, proceed", "[a]", "these files in this session", "[s]", "don't ask again for this scope", "[y]", "continue without running it", "[d]", "scope: src/main.go", "Creates or overwrites files."} {
assertContains(t, view, want)
}
if strings.Contains(view, "risk:") || strings.Contains(view, "risk=") {
t.Fatalf("focused permission prompt must not render risk labels, got %q", view)
}
}
func TestPermissionPromptChoicesResolveDecision(t *testing.T) {
cases := []struct {
name string
key string
want permissionDecision
}{
{name: "allow", key: "a", want: permissionDecisionAllow},
{name: "deny", key: "d", want: permissionDecisionDeny},
{name: "session", key: "s", want: permissionDecisionAllowForSession},
{name: "always", key: "y", want: permissionDecisionAlwaysAllow},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
decisions := []permissionDecision{}
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: testPromptPermissionRequest(),
decide: func(decision agent.PermissionDecision) {
decisions = append(decisions, permissionDecision(decision.Action))
},
})
next := updated.(model)
updated, cmd := next.Update(testKeyText(tc.key))
next = updated.(model)
if cmd != nil {
t.Fatal("expected permission choice to resolve synchronously")
}
if len(decisions) != 1 || decisions[0] != tc.want {
t.Fatalf("expected decision %q, got %#v", tc.want, decisions)
}
if next.pendingPermission != nil {
t.Fatalf("expected permission prompt to clear after choice, got %#v", next.pendingPermission)
}
})
}
}
func TestPermissionPromptBlocksNormalSubmit(t *testing.T) {
decisions := []permissionDecision{}
m := newModel(context.Background(), Options{})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(permissionRequestMsg{
runID: 7,
request: testPromptPermissionRequest(),
decide: func(decision agent.PermissionDecision) {
decisions = append(decisions, permissionDecision(decision.Action))
},
})
next := updated.(model)
next.input.SetValue("second prompt")
updated, cmd := next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if cmd != nil {
t.Fatal("expected permission confirm to resolve synchronously (no cmd)")
}
// Enter confirms the highlighted option (default: approve) -- it must NOT
// submit the composer's pending text as a new prompt.
if len(decisions) != 1 || decisions[0] != permissionDecisionAllow {
t.Fatalf("expected Enter to confirm the default approval option, got %#v", decisions)
}
if transcriptContains(next.transcript, "second prompt") {
t.Fatalf("permission prompt should block normal prompt submit, got %#v", next.transcript)
}
if next.pendingPermission != nil {
t.Fatalf("expected permission prompt to clear after confirm, got %#v", next.pendingPermission)
}
}
func TestPermissionRowRendersSandboxBlocks(t *testing.T) {
block := sandbox.Block{
Code: sandbox.BlockOutsideWorkspace,
ToolName: "write_file",
Action: sandbox.ActionDeny,
Risk: sandbox.Risk{Level: sandbox.RiskCritical},
Path: "../secret.txt",
Reason: "writes must stay inside workspace",
Recoverable: false,
}
event := agent.PermissionEvent{
ToolCallID: "call_2",
ToolName: "write_file",
Action: agent.PermissionActionDeny,
Permission: "prompt",
PermissionMode: agent.PermissionModeUnsafe,
Autonomy: "high",
SideEffect: "write",
Reason: "workspace boundary enforced",
Risk: sandbox.Risk{Level: sandbox.RiskHigh},
Block: &block,
}
rendered := newModel(context.Background(), Options{}).renderRow(permissionTranscriptRow(event), 96, buildRowContext(nil))
for _, want := range []string{"write_file", "denied", "outside workspace", "../secret.txt"} {
assertContains(t, rendered, want)
}
for _, blocked := range []string{"risk:", "risk=", "block=", "mode=", "permission=", "side_effect=", "autonomy="} {
if strings.Contains(rendered, blocked) {
t.Fatalf("denied permission row must not render %q, got %q", blocked, rendered)
}
}
}
func TestAppendTranscriptRowDedupesRuntimeRowsByID(t *testing.T) {
event := agent.PermissionEvent{
ToolCallID: "call_1",
ToolName: "write_file",
Action: agent.PermissionActionPrompt,
}
rows := initialTranscript()
rows = appendTranscriptRow(rows, transcriptRow{kind: rowToolCall, id: "call_1", text: "tool call: write_file", tool: "write_file"})
rows = appendTranscriptRow(rows, permissionTranscriptRow(event))
rows = appendTranscriptRow(rows, transcriptRow{kind: rowToolResult, id: "call_1", text: "tool result: write_file error", tool: "write_file", status: tools.StatusError})
rows = appendTranscriptRow(rows, transcriptRow{kind: rowToolCall, id: "call_1", text: "tool call: write_file", tool: "write_file"})
rows = appendTranscriptRow(rows, permissionTranscriptRow(event))
rows = appendTranscriptRow(rows, transcriptRow{kind: rowToolResult, id: "call_1", text: "tool result: write_file error", tool: "write_file", status: tools.StatusError})
if len(rows) != 4 {
t.Fatalf("expected welcome plus three unique runtime rows, got %#v", rows)
}
}
func TestAgentEventRenderingMappingCoversRuntimeContract(t *testing.T) {
surfaces := map[zeroruntime.AgentEventType]string{
zeroruntime.AgentEventText: "assistant transcript row",
zeroruntime.AgentEventToolCall: "tool call transcript row",
zeroruntime.AgentEventToolResult: "tool result transcript row",
zeroruntime.AgentEventThinking: "deferred: no transcript row until runtime emits thinking deltas",
zeroruntime.AgentEventUsage: "usage tracker footer segment",
zeroruntime.AgentEventPlanUpdate: "system transcript row from /plan",
zeroruntime.AgentEventError: "error transcript row",
zeroruntime.AgentEventTurnEnd: "control boundary, no transcript row",
}
for _, eventType := range []zeroruntime.AgentEventType{
zeroruntime.AgentEventText,
zeroruntime.AgentEventToolCall,
zeroruntime.AgentEventToolResult,
zeroruntime.AgentEventThinking,
zeroruntime.AgentEventUsage,
zeroruntime.AgentEventPlanUpdate,
zeroruntime.AgentEventError,
zeroruntime.AgentEventTurnEnd,
} {
if strings.TrimSpace(surfaces[eventType]) == "" {
t.Fatalf("missing TUI rendering surface note for %s", eventType)
}
}
renderedRows := map[zeroruntime.AgentEventType]struct {
row transcriptRow
wants []string
}{
zeroruntime.AgentEventText: {
row: transcriptRow{kind: rowAssistant, text: "assistant text"},
wants: []string{"assistant text"},
},
zeroruntime.AgentEventToolCall: {
row: transcriptRow{
kind: rowToolCall,
text: "tool call: read_file",
tool: "read_file",
detail: "README.md",
},
wants: []string{"Read", "README.md"},
},
zeroruntime.AgentEventToolResult: {
row: transcriptRow{
kind: rowToolResult,
text: "tool result: apply_patch error",
tool: "apply_patch",
status: tools.StatusError,
detail: strings.Join([]string{
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1 +1 @@",
"-old",
"+new",
}, "\n"),
},
wants: []string{"Patched", "file.txt", "old", "new"},
},
zeroruntime.AgentEventPlanUpdate: {
row: transcriptRow{kind: rowSystem, text: "Plan updated\n- inspect: completed"},
wants: []string{"Plan updated", "inspect"},
},
zeroruntime.AgentEventError: {
row: transcriptRow{kind: rowError, text: "provider failed"},
wants: []string{"provider failed"},
},
}
for eventType, tc := range renderedRows {
t.Run(string(eventType), func(t *testing.T) {
rendered := newModel(context.Background(), Options{}).renderRow(tc.row, 96, buildRowContext(nil))
for _, want := range tc.wants {
assertContains(t, rendered, want)
}
})
}
m := newModel(context.Background(), Options{
ModelName: "gpt-4.1",
PermissionMode: agent.PermissionModeAsk,
})
m.width = 96
m, usageRows := m.recordUsageEvent("gpt-4.1", zeroruntime.Usage{InputTokens: 100, OutputTokens: 20})
if len(usageRows) != 0 {
t.Fatalf("valid usage should update footer without transcript rows, got %#v", usageRows)
}
assertContains(t, m.usageStatusSegment(), "120 tok")
assertContains(t, m.composerDividerLine(96), "gpt-4.1")
// Permission mode moved from the composer rule to the status line.
assertContains(t, m.statusLine(96), "ask")
}
func TestToolResultRowDefaultsEmptyStatusToOK(t *testing.T) {
text := toolResultRowText(agent.ToolResult{Name: "read_file", Output: "done"})
if !strings.Contains(text, "read_file ok done") {
t.Fatalf("expected empty status to render as ok, got %q", text)
}
}
func TestToolResultRowTruncatesLongOutput(t *testing.T) {
text := toolResultRowText(agent.ToolResult{Name: "read_file", Output: strings.Repeat("x", tuiToolOutputLimit+20)})
if !strings.Contains(text, "[truncated]") || len(text) >= tuiToolOutputLimit+80 {
t.Fatalf("expected truncated tool output, got len=%d text=%q", len(text), text)
}
}
func TestShiftTabCyclesPermissionMode(t *testing.T) {
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
m.width = 96
// shift+tab toggles Auto<->Ask only; Unsafe is intentionally NOT reachable by
// a casual keypress (it disables permission prompts).
for _, want := range []agent.PermissionMode{
agent.PermissionModeAsk,
agent.PermissionModeAuto,
} {
updated, cmd := m.Update(testKeyShift(tea.KeyTab))
m = updated.(model)
if cmd != nil {
t.Fatalf("expected shift+tab to cycle mode synchronously, got command")
}
if m.permissionMode != want {
t.Fatalf("expected permission mode %q after shift+tab, got %q", want, m.permissionMode)
}
if m.permissionMode == agent.PermissionModeUnsafe {
t.Fatalf("shift+tab must never land on Unsafe")
}
}
// The rendered status label tracks the cycled mode.
label, _ := m.modeLabel()
if label != "auto-approve" {
t.Fatalf("expected mode label to track cycled mode, got %q", label)
}
}
func TestShiftTabDoesNotCycleWhileModalsActive(t *testing.T) {
// Permission modal, ask_user prompt, and an open picker all take precedence:
// shift+tab must not change the mode while any is up.
t.Run("permission", func(t *testing.T) {
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(permissionRequestMsg{runID: 7, request: testPromptPermissionRequest()})
next := updated.(model)
updated, _ = next.Update(testKeyShift(tea.KeyTab))
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while permission modal is up, got %q", next.permissionMode)
}
if next.pendingPermission == nil {
t.Fatal("expected permission prompt to remain pending")
}
})
t.Run("ask_user", func(t *testing.T) {
m := newModel(context.Background(), Options{PermissionMode: agent.PermissionModeAuto})
m.pending = true
m.activeRunID = 7
updated, _ := m.Update(askUserRequestMsg{runID: 7, request: testAskUserRequest(), answer: func([]string) {}})
next := updated.(model)
updated, _ = next.Update(testKeyShift(tea.KeyTab))
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while ask_user prompt is up, got %q", next.permissionMode)
}
if next.pendingAskUser == nil {
t.Fatal("expected ask_user prompt to remain pending")
}
})
t.Run("picker", func(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "openai",
ModelName: "gpt-4.1",
Provider: &fakeProvider{},
PermissionMode: agent.PermissionModeAuto,
})
m.input.SetValue("/model")
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if next.picker == nil {
t.Skip("model picker unavailable in test environment")
}
updated, _ = next.Update(testKeyShift(tea.KeyTab))
next = updated.(model)
if next.permissionMode != agent.PermissionModeAuto {
t.Fatalf("expected mode unchanged while picker is open, got %q", next.permissionMode)
}
})
}
func TestCtrlCRequiresSecondPressToExit(t *testing.T) {
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
updated, cmd := m.Update(testKeyCtrl('c'))
next := updated.(model)
if next.exiting {
t.Fatal("first Ctrl+C should not mark model exiting")
}
if !next.exitConfirmActive {
t.Fatal("first Ctrl+C should arm exit confirmation")
}
if cmd == nil {
t.Fatal("first Ctrl+C should schedule confirmation expiry")
}
status := plainRender(t, next.statusLine(80))
if !strings.Contains(status, ctrlCExitConfirmText) {
t.Fatalf("status line = %q, want exit confirmation", status)
}
if strings.Contains(status, "tokenrouter") {
t.Fatalf("status line = %q, should replace provider while exit confirmation is active", status)
}
updated, cmd = next.Update(testKeyCtrl('c'))
next = updated.(model)
if !next.exiting {
t.Fatal("second Ctrl+C should mark model exiting")
}
if cmd == nil {
t.Fatal("second Ctrl+C should return quit command")
}
}
func TestCtrlCExitConfirmationDisarmsOnInterveningKey(t *testing.T) {
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
updated, _ := m.Update(testKeyCtrl('c'))
next := updated.(model)
if !next.exitConfirmActive {
t.Fatal("first Ctrl+C should arm exit confirmation")
}
seq := next.exitConfirmSeq
updated, cmd := next.Update(testKey(tea.KeyLeft))
next = updated.(model)
if cmd != nil {
t.Fatal("intervening navigation key should not return a command")
}
if next.exitConfirmActive {
t.Fatal("intervening non-Ctrl+C key should disarm exit confirmation")
}
if next.exitConfirmSeq == seq {
t.Fatal("disarming should advance the sequence so stale expiry ticks are ignored")
}
updated, cmd = next.Update(testKeyCtrl('c'))
next = updated.(model)
if next.exiting {
t.Fatal("Ctrl+C after an intervening key should re-arm, not exit")
}
if !next.exitConfirmActive {
t.Fatal("Ctrl+C after an intervening key should arm a fresh confirmation")
}
if cmd == nil {
t.Fatal("fresh Ctrl+C confirmation should schedule expiry")
}
}
func TestCtrlCClearsComposerBeforeExitConfirmation(t *testing.T) {
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
m.input.SetValue("draft prompt")
m.recomputeSuggestions()
updated, cmd := m.Update(testKeyCtrl('c'))
next := updated.(model)
if cmd != nil {
t.Fatal("Ctrl+C with a draft should clear the composer without scheduling exit confirmation")
}
if next.composerValue() != "" {
t.Fatalf("composer value = %q, want empty after Ctrl+C", next.composerValue())
}
if next.exitConfirmActive {
t.Fatal("Ctrl+C with a draft should not arm exit confirmation")
}
if next.exiting {
t.Fatal("Ctrl+C with a draft should not mark model exiting")
}
status := plainRender(t, next.statusLine(80))
// No exit confirmation armed, so the normal run-state chip shows (the status
// line carries the permission mode now, not the provider).
if strings.Contains(status, ctrlCExitConfirmText) || !strings.Contains(status, "auto-approve") {
t.Fatalf("status line = %q, want the run-state chip with no exit confirmation", status)
}
updated, cmd = next.Update(testKeyCtrl('c'))
next = updated.(model)
if !next.exitConfirmActive {
t.Fatal("Ctrl+C on an empty composer should arm exit confirmation")
}
if cmd == nil {
t.Fatal("Ctrl+C on an empty composer should schedule confirmation expiry")
}
}
func TestCtrlCExitConfirmationExpires(t *testing.T) {
m := newModel(context.Background(), Options{ProviderName: "tokenrouter"})
updated, _ := m.Update(testKeyCtrl('c'))
next := updated.(model)
seq := next.exitConfirmSeq
updated, _ = next.Update(exitConfirmExpiredMsg{seq: seq - 1})
next = updated.(model)
if !next.exitConfirmActive {
t.Fatal("stale expiry should not clear active exit confirmation")
}
updated, _ = next.Update(exitConfirmExpiredMsg{seq: seq})
next = updated.(model)
if next.exitConfirmActive {
t.Fatal("matching expiry should clear exit confirmation")
}
status := plainRender(t, next.statusLine(80))
// After expiry the warning clears and the normal run-state chip is restored
// (the status line now shows the permission mode, not the provider).
if strings.Contains(status, ctrlCExitConfirmText) || !strings.Contains(status, "auto-approve") {
t.Fatalf("status line after expiry = %q, want the run-state chip restored", status)
}
}
func TestSystemNotesRenderPlainLinesNotBoxes(t *testing.T) {
cancel := plainRender(t, renderSystemNote("Run cancelled.", 80))
if strings.ContainsAny(cancel, "│╭╮╰╯") {
t.Fatalf("cancellation should be a plain line, not a box: %q", cancel)
}
if !strings.Contains(cancel, "Run cancelled.") {
t.Fatalf("cancellation text missing: %q", cancel)
}
// Every other system notice is ALSO a boxless plain line now.
for _, note := range []string{"Mouse interaction re-enabled.", "Mode set to ask."} {
got := plainRender(t, renderSystemNote(note, 80))
if strings.ContainsAny(got, "│╭╮╰╯") {
t.Fatalf("system notice should be a plain line, not a box: %q", got)
}
if !strings.Contains(got, note) {
t.Fatalf("notice text missing: %q", got)
}
}
}
func TestSpecialistCardLinesAreSelectableForCopy(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width = 120
row := transcriptRow{kind: rowSpecialist, specialistInfo: &specialistInfo{
name: "explorer", description: "audit the code", status: specialistCompleted, childSessionID: "sess-x",
}}
rendered, selectable := m.renderSelectableSpecialistRow(0, row, 100, buildRowContext(nil), 0)
if rendered == "" || len(selectable) == 0 {
t.Fatal("expected a rendered specialist card with selectable lines")
}
hasText := false
for _, sl := range selectable {
if !sl.specialistCard {
t.Fatalf("card line lost its specialistCard click flag: %+v", sl)
}
if sl.text != "" {
hasText = true
}
}
if !hasText {
t.Fatal("specialist card lines must carry text so a dragged selection copies them")
}
}
func TestLiveReasoningExpandIsCappedAndAligned(t *testing.T) {
var m model
m.height = 20 // liveReasoningBodyCap = max(6, 10) = 10
width := 80
text := strings.Repeat("thinking about the problem in detail here\n", 30)
cap := m.liveReasoningBodyCap()
lines, selectable := m.renderSelectableReasoningBlock(-1, text, true, true, 0, width, 0)
// The selectable spans MUST stay 1:1 with the displayed lines, or the gutter
// highlighter lands on the wrong rows.
if len(lines) != len(selectable) {
t.Fatalf("display lines (%d) and selectable spans (%d) must align", len(lines), len(selectable))
}
// Capped to ~half-screen: header + marker + cap body, not all ~30 lines.
if len(lines) > cap+3 {
t.Fatalf("live reasoning not capped: %d lines for cap %d", len(lines), cap)
}
if !strings.Contains(plainRender(t, strings.Join(lines, "\n")), "earlier lines") {
t.Fatalf("expected the '… earlier lines' marker:\n%s", strings.Join(lines, "\n"))
}
// The display path must produce the same line count as the selectable path.
display := renderReasoningBlock(strings.TrimSpace(text), true, width, true, 0, cap)
if got := len(strings.Split(display, "\n")); got != len(lines) {
t.Fatalf("display path (%d lines) and selectable path (%d lines) must match", got, len(lines))
}
}
func TestSelectionHighlightUsesGutterShiftedCoordinate(t *testing.T) {
// Select screen columns 10..15 on body line 0. With a 4-cell reading gutter the
// rendered line is " Hello world" and columns 10-14 are "world", so the
// highlight must land on "world" — proving it's computed in the SAME shifted
// coordinate the mouse uses. The old bug painted it on the unshifted line, so it
// landed gutter cells off.
var m model
m.transcriptSelection = transcriptSelectionState{
active: true,
anchor: transcriptSelectionPoint{bodyY: 0, x: 10},
cursor: transcriptSelectionPoint{bodyY: 0, x: 15},
}
selectable := []transcriptSelectableLine{{bodyY: 0, rowIndex: 0, text: "Hello world", textStart: 0}}
item := m.finalizeTranscriptBodyRow("Hello world", selectable, 4, 0)
styled := strings.Join(item.lines, "\n")
if !strings.Contains(plainRender(t, styled), " Hello world") {
t.Fatalf("expected a 4-cell gutter-padded line, got %q", styled)
}
if !strings.Contains(styled, zeroTheme.selection.Render("world")) {
t.Fatalf("expected 'world' highlighted at the gutter-shifted position, got %q", styled)
}
}
// TestTranscriptSelectionPaintsHighlightOnceNotTwice guards the "two highlights"
// bug: assistant/user/reasoning rows used to self-paint the selection and
// finalizeTranscriptBodyRow re-painted it, so a single selection lit up twice.
// The highlight must land exactly once.
func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 160, 30
m.altScreen = true
m.transcript = append(m.transcript, transcriptRow{
kind: rowAssistant, text: "alpha beta gamma delta epsilon zeta", final: true,
})
width := m.chatColumnWidth()
findRow := func(mm model) (transcriptBodyItem, bool) {
for _, it := range mm.transcriptBodyItems(width, "", false) {
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
return it, true
}
}
return transcriptBodyItem{}, false
}
row, ok := findRow(m)
if !ok {
t.Fatal("no assistant row item")
}
var line transcriptSelectableLine
for _, sl := range row.render(0).selectable {
if sl.text != "" {
line = sl
break
}
}
if line.text == "" || lipgloss.Width(line.text) < 5 {
t.Fatalf("expected a selectable text line of width >= 5, got %+v", line)
}
// Select the first 5 columns of that line in the same coordinate system mouse
// selection uses.
m.transcriptSelection = transcriptSelectionState{
active: true,
anchor: transcriptSelectionPoint{bodyY: line.bodyY, x: line.textStart},
cursor: transcriptSelectionPoint{bodyY: line.bodyY, x: line.textStart + 5},
}
row2, ok := findRow(m)
if !ok {
t.Fatal("no assistant row item with selection active")
}
out := strings.Join(row2.render(0).lines, "\n")
sentinel := zeroTheme.selection.Render("\x00")
open := sentinel[:strings.IndexByte(sentinel, 0)]
if open == "" {
t.Fatal("selection style emitted no opening sequence to count")
}
if got := strings.Count(out, open); got != 1 {
t.Fatalf("selection highlight painted %d times, want exactly 1 (the two-highlights bug):\n%q", got, out)
}
}
func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 120, 40
m.altScreen = true
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "do it"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "list_directory", status: tools.StatusOK, detail: "a\nb"},
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
reasoningIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
m.transcript[items[i].rowIndex].kind == rowReasoning {
reasoningIdx = i
}
}
if reasoningIdx <= 0 {
t.Fatal("reasoning item not found in the body")
}
if items[reasoningIdx-1].kind != transcriptBodyItemSeparator {
t.Fatalf("expected a blank separator before the reasoning group, got kind %v", items[reasoningIdx-1].kind)
}
}
func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 120, 40
m.altScreen = true
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "run it"},
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
m.transcript[items[i].rowIndex].kind == rowToolResult {
toolIdx = i
}
}
if toolIdx <= 0 {
t.Fatal("tool item not found in the body")
}
if items[toolIdx-1].kind != transcriptBodyItemSeparator {
t.Fatalf("expected a blank separator between assistant narration and first tool card, got kind %v", items[toolIdx-1].kind)
}
}
func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 120, 40
m.altScreen = true
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "create the landing page"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
m.transcript[items[i].rowIndex].kind == rowToolResult {
toolIdx = i
}
}
if toolIdx <= 0 {
t.Fatal("tool item not found in the body")
}
if items[toolIdx-1].kind != transcriptBodyItemSeparator {
t.Fatalf("expected a blank separator between user prompt and first tool card, got kind %v", items[toolIdx-1].kind)
}
}
func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 120, 40
m.altScreen = true
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "run it"},
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
finalIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
m.transcript[items[i].rowIndex].kind == rowAssistant &&
m.transcript[items[i].rowIndex].final {
finalIdx = i
}
}
if finalIdx <= 0 {
t.Fatal("final assistant item not found in the body")
}
if items[finalIdx-1].kind != transcriptBodyItemRule {
t.Fatalf("expected a rule separator before assistant prose after tool output, got kind %v", items[finalIdx-1].kind)
}
body, _ := m.transcriptBody(m.chatColumnWidth(), "")
got := plainRender(t, body)
if !strings.Contains(got, "──") || !strings.Contains(got, "Done.") {
t.Fatalf("expected visible rule before final answer, got:\n%s", got)
}
}
func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 120, 40
m.altScreen = true
m.pending = true
m.streamingText = []byte("Done.")
m.transcript = append(m.transcript,
transcriptRow{kind: rowUser, text: "run it"},
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
pendingIdx := -1
for i := range items {
if items[i].kind == transcriptBodyItemPendingInterim {
pendingIdx = i
}
}
if pendingIdx <= 0 {
t.Fatal("pending interim item not found in the body")
}
if items[pendingIdx-1].kind != transcriptBodyItemRule {
t.Fatalf("expected a live rule separator before streaming assistant text after tool output, got kind %v", items[pendingIdx-1].kind)
}
}
func TestTranscriptReadingColumnHelpers(t *testing.T) {
// Wide terminal: no body gutter, full content width.
if g := transcriptGutter(160); g != 0 {
t.Fatalf("wide gutter = %d, want 0", g)
}
if cw := transcriptContentWidth(160); cw != 160 {
t.Fatalf("wide contentWidth = %d, want full 160", cw)
}
// Very wide terminal: still no gutter, so code/tool blocks use the width.
if g := transcriptGutter(400); g != 0 {
t.Fatalf("very wide gutter = %d, want 0", g)
}
// Tiny terminal: full width so prose never collapses.
if g := transcriptGutter(40); g != 0 {
t.Fatalf("tiny gutter = %d, want 0", g)
}
if cw := transcriptContentWidth(40); cw != 40 {
t.Fatalf("tiny contentWidth = %d, want full 40", cw)
}
}
func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 160, 30
m.altScreen = true
m.transcript = append(m.transcript, transcriptRow{
kind: rowAssistant, text: strings.Repeat("word ", 60), final: true,
})
width := m.chatColumnWidth()
gutter := transcriptGutter(width)
if gutter != 0 {
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
}
items := m.transcriptBodyItems(width, "", false)
var row *transcriptBodyItem
for i := range items {
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
row = &items[i]
}
}
if row == nil {
t.Fatal("no assistant row item found")
}
rendered := row.render(0)
maxLine := transcriptContentWidth(width) + gutter // content plus any left indent
sawContent := false
for _, line := range rendered.lines {
if w := lipgloss.Width(line); w > maxLine {
t.Fatalf("body line width %d exceeds chat column %d: %q", w, maxLine, line)
}
if strings.TrimSpace(line) != "" {
sawContent = true
}
}
if !sawContent {
t.Fatal("expected at least one content line")
}
// Selection alignment: text-carrying selectable lines should never start before
// the rendered text coordinate.
for _, sl := range rendered.selectable {
if sl.text != "" && sl.textStart < gutter {
t.Fatalf("selectable textStart %d < gutter %d — selection would misalign", sl.textStart, gutter)
}
}
}
func TestMarkdownAddsBlankBeforeHeadingAndParagraph(t *testing.T) {
lines := renderAssistantMarkdownText("First paragraph.\n## Heading\nSecond body.", 80, 80, false)
headingIdx := -1
for i, l := range lines {
// Headings render accent+bold+underline (per-grapheme ANSI), so strip styling
// before matching the text.
if strings.Contains(plainRender(t, l), "Heading") {
headingIdx = i
}
}
if headingIdx <= 0 || strings.TrimSpace(lines[headingIdx-1]) != "" {
t.Fatalf("expected a blank line before the heading, got %#v", lines)
}
}
func TestComposerIdleHintAndJumpCue(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.altScreen = true
m.width, m.height = 100, 30
m.transcript = append(m.transcript, transcriptRow{kind: rowAssistant, text: "hi", final: true})
// Idle, empty composer, managed mode -> the discoverability hint shows.
if h := plainRender(t, m.composerIdleHint()); !strings.Contains(h, "shortcuts") {
t.Fatalf("expected idle hint, got %q", h)
}
// Hidden during a run.
busy := m
busy.pending = true
if h := busy.composerIdleHint(); h != "" {
t.Fatalf("hint should hide during a run, got %q", h)
}
// Hidden in inline mode.
inline := m
inline.altScreen = false
if h := inline.composerIdleHint(); h != "" {
t.Fatalf("hint should hide in inline mode, got %q", h)
}
// Jump cue only when scrolled up.
if c := m.jumpToBottomHint(); c != "" {
t.Fatalf("jump cue should be empty at the bottom, got %q", c)
}
scrolled := m
scrolled.chatScrollOffset = 5
if c := plainRender(t, scrolled.jumpToBottomHint()); !strings.Contains(c, "5 more") {
t.Fatalf("expected jump cue with line count, got %q", c)
}
// The footer carrying the hint must never overflow its width.
w := m.chatColumnWidth()
for i, line := range strings.Split(plainRender(t, m.footerView(w)), "\n") {
if got := lipgloss.Width(line); got > w {
t.Fatalf("footer line %d width %d > %d: %q", i, got, w, line)
}
}
}
func assertContains(t *testing.T, text any, want string) {
t.Helper()
content := plainRender(t, text)
if !strings.Contains(content, want) {
t.Fatalf("expected %q to contain %q", content, want)
}
}
func assertNotContains(t *testing.T, text any, unwanted string) {
t.Helper()
content := plainRender(t, text)
if strings.Contains(content, unwanted) {
t.Fatalf("expected %q not to contain %q", content, unwanted)
}
}
func transcriptContains(rows []transcriptRow, want string) bool {
for _, row := range rows {
if strings.Contains(row.text, want) {
return true
}
}
return false
}
func transcriptText(rows []transcriptRow) string {
parts := make([]string, 0, len(rows))
for _, row := range rows {
parts = append(parts, row.text)
if row.detail != "" {
parts = append(parts, row.detail)
}
}
return strings.Join(parts, "\n")
}
func countTranscriptRows(rows []transcriptRow, kind rowKind) int {
count := 0
for _, row := range rows {
if row.kind == kind {
count++
}
}
return count
}
func findTranscriptRow(rows []transcriptRow, kind rowKind) (transcriptRow, bool) {
for _, row := range rows {
if row.kind == kind {
return row, true
}
}
return transcriptRow{}, false
}
func transcriptHasMarkedModelEntry(rows []transcriptRow) bool {
for _, row := range rows {
for _, line := range strings.Split(row.text, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "* ") && strings.Contains(trimmed, " (") {
return true
}
}
}
return false
}
func stringSliceContains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func testPromptPermissionEvent() agent.PermissionEvent {
return agent.PermissionEvent{
ToolCallID: "call_1",
ToolName: "write_file",
Action: agent.PermissionActionPrompt,
Permission: "prompt",
PermissionMode: agent.PermissionModeAsk,
Autonomy: "medium",
SideEffect: "write",
Reason: "Creates or overwrites files.",
Risk: sandbox.Risk{Level: sandbox.RiskHigh},
}
}
func testPromptPermissionRequest() agent.PermissionRequest {
event := testPromptPermissionEvent()
return agent.PermissionRequest{
ToolCallID: event.ToolCallID,
ToolName: event.ToolName,
Action: event.Action,
Permission: event.Permission,
PermissionMode: event.PermissionMode,
Autonomy: event.Autonomy,
SideEffect: event.SideEffect,
Reason: event.Reason,
Risk: event.Risk,
AvailableDecisions: testAllPermissionDecisions(),
}
}
func testAllPermissionDecisions() []agent.PermissionDecisionAction {
return []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowForSession,
agent.PermissionDecisionAlwaysAllow,
agent.PermissionDecisionDeny,
}
}
func testSessionStore(t *testing.T) *sessions.Store {
t.Helper()
now := time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC)
return sessions.NewStore(sessions.StoreOptions{
RootDir: t.TempDir(),
Now: func() time.Time {
now = now.Add(time.Minute)
return now
},
})
}
func TestNextPermissionModeFoldsUnsafeToAsk(t *testing.T) {
if got := nextPermissionMode(agent.PermissionModeAuto); got != agent.PermissionModeAsk {
t.Fatalf("Auto -> %s, want Ask", got)
}
if got := nextPermissionMode(agent.PermissionModeAsk); got != agent.PermissionModeAuto {
t.Fatalf("Ask -> %s, want Auto", got)
}
// Unsafe must fold to the STRICTER Ask, never Auto (toggling an Unsafe session
// must not make it less strict).
if got := nextPermissionMode(agent.PermissionModeUnsafe); got != agent.PermissionModeAsk {
t.Fatalf("Unsafe -> %s, want Ask", got)
}
}
func TestModelNotifierFocusAndCompletion(t *testing.T) {
var buf bytes.Buffer
m := model{notifier: notify.New(&buf, notify.Config{Mode: notify.ModeBell, FocusMode: notify.FocusUnfocused})}
m.notifier.SetFocused(true)
// Focused → completion under unfocused-mode is silent.
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if buf.Len() != 0 {
t.Fatalf("focused should be silent, got %q", buf.String())
}
// BlurMsg flips focus; completion now bells.
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
if buf.String() != "\x07" {
t.Fatalf("unfocused should bell, got %q", buf.String())
}
// FocusMsg flips back.
updated, _ = m.Update(tea.FocusMsg{})
m = updated.(model)
buf.Reset()
m.notifier.Notify(notify.Completion, "x")
if buf.Len() != 0 {
t.Fatalf("refocused should be silent, got %q", buf.String())
}
}
func TestScrimViewportLine(t *testing.T) {
// Blank lines are left untouched (no scrim).
if got := scrimViewportLine(" ", 10); got != " " {
t.Fatalf("blank line should be untouched, got %q", got)
}
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
// stays readable behind the overlay.
got := scrimViewportLine("transcript content", 40)
if ansi.Strip(got) != "transcript content" {
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
}
// A pre-styled line must have its OWN styling stripped (so the dim wins), while
// the text content survives. This fails if scrim stops re-styling the backdrop.
styled := "\x1b[31mred backdrop\x1b[0m text"
scrimmed := scrimViewportLine(styled, 40)
if ansi.Strip(scrimmed) != "red backdrop text" {
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
}
if strings.Contains(scrimmed, "\x1b[31m") {
t.Fatalf("scrim must strip the line's original styling, got %q", scrimmed)
}
}
func TestOverlayViewportLinesCompositesAndPreservesBackdropText(t *testing.T) {
width := 40
lines := make([]string, 9)
for i := range lines {
lines[i] = "backdrop transcript row"
}
// Narrow, indented overlay so the composited rows keep backdrop in the margins.
overlay := " ╭────╮\n │ pn │\n ╰────╯"
out := overlayViewportLines(append([]string{}, lines...), overlay, width)
joined := ansi.Strip(strings.Join(out, "\n"))
if !strings.Contains(joined, "pn") {
t.Fatalf("overlay panel should be composited, got:\n%s", joined)
}
// A non-overlaid row keeps its (dimmed) backdrop text.
if !strings.Contains(ansi.Strip(out[0]), "backdrop transcript row") {
t.Fatalf("non-overlaid backdrop should survive the scrim, got %q", ansi.Strip(out[0]))
}
// The compositing contract: the row carrying the panel must ALSO keep backdrop
// text outside the panel (left/right margins), not blank it out.
var panelRow string
for _, line := range out {
if strings.Contains(ansi.Strip(line), "pn") {
panelRow = ansi.Strip(line)
break
}
}
if panelRow == "" {
t.Fatal("expected a row containing the panel")
}
if !strings.Contains(panelRow, "backdrop") {
t.Fatalf("overlaid row should keep backdrop margin text alongside the panel, got %q", panelRow)
}
}