mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a454931e66 | ||
|
|
35145a519b | ||
|
|
1282a23ab1 |
No files matched your search
+15
-62
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/notify"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/providers"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
@@ -502,7 +500,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
defer lspShutdown()
|
||||
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
|
||||
MaxTurns: resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
|
||||
ContextWindow: modelContextWindow(modelRegistry, resolved.Provider.Model),
|
||||
DeferThreshold: effectiveDeferThreshold,
|
||||
Specialists: specialistRuntime.specialistInfos(),
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
@@ -1000,71 +998,30 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
|
||||
return entry.ID, notice
|
||||
}
|
||||
|
||||
// modelContextWindow returns the resolved model's exact context window (max input
|
||||
// tokens) from the registry, or 0 when the model isn't catalogued. Compaction call
|
||||
// sites wrap this in modelregistry.AgentContextWindow to apply a positive fallback;
|
||||
// display/report sites use the raw value so an unknown model shows no denominator.
|
||||
// modelContextWindow returns the resolved model's context window (max input
|
||||
// tokens) from the model registry, used to enable agent-loop compaction. An
|
||||
// unknown model (e.g. a custom openai-compatible name) returns 0, which leaves
|
||||
// compaction DISABLED — a safe default that never compacts unexpectedly.
|
||||
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
if entry, ok := registry.Resolve(trimmed); ok {
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveAgentContextWindow returns the context window used to enable/size agent
|
||||
// compaction: the exact registry value when catalogued, else a window learned from
|
||||
// live provider discovery (so an uncatalogued proxy/custom model gets its real
|
||||
// window instead of the generic fallback), else the positive FallbackContextWindow.
|
||||
// Discovery runs only on a registry miss (catalogued models pay no latency), is
|
||||
// bounded by a short timeout, and degrades to the fallback on any error — so a
|
||||
// headless run is never blocked or failed by it.
|
||||
func resolveAgentContextWindow(ctx context.Context, registry modelregistry.Registry, profile config.ProviderProfile) int {
|
||||
if window := modelContextWindow(registry, profile.Model); window > 0 {
|
||||
return window
|
||||
}
|
||||
if window := discoveredModelContextWindow(ctx, profile); window > 0 {
|
||||
return window
|
||||
}
|
||||
return modelregistry.AgentContextWindow(0)
|
||||
}
|
||||
|
||||
// discoveredModelContextWindow queries the provider's live model list for the
|
||||
// active model's context window. Returns 0 when the provider isn't catalogued, the
|
||||
// credential can't be resolved, discovery fails, or the model reports no window.
|
||||
func discoveredModelContextWindow(ctx context.Context, profile config.ProviderProfile) int {
|
||||
descriptor, ok := providercatalog.Get(strings.TrimSpace(profile.CatalogID))
|
||||
entry, ok := registry.Resolve(trimmed)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
// Authenticate discovery with the resolved key (inline, then stored, then env).
|
||||
authed := profile
|
||||
if strings.TrimSpace(authed.APIKey) == "" {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(authed.APIKey) == "" && strings.TrimSpace(authed.APIKeyEnv) != "" {
|
||||
authed.APIKey = strings.TrimSpace(os.Getenv(authed.APIKeyEnv))
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
models, err := providermodeldiscovery.DiscoverCatalog(dctx, descriptor, authed, providermodeldiscovery.Options{})
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
target := strings.TrimSpace(profile.Model)
|
||||
for _, model := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(model.ID), target) && model.ContextWindow > 0 {
|
||||
return model.ContextWindow
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
//
|
||||
// NOTE: the effective effort is not yet forwarded to the provider request — the
|
||||
// zeroruntime.CompletionRequest / provider wire schemas carry no effort field.
|
||||
// Full provider-request propagation is deferred (see slice-3 report).
|
||||
// forwardedReasoningEffort returns the effort to send on the provider request.
|
||||
// It mirrors reasoningEffortNotice: a known model that does not support reasoning
|
||||
// yields "" (matching the "ignoring" advisory, so the request never carries an
|
||||
@@ -1087,10 +1044,6 @@ func forwardedReasoningEffort(registry modelregistry.Registry, modelID string, r
|
||||
return string(effective)
|
||||
}
|
||||
|
||||
// reasoningEffortNotice resolves the requested --reasoning-effort against the
|
||||
// selected model's supported efforts via EffectiveReasoningEffort and returns a
|
||||
// short advisory when the requested value is unsupported (and was coerced to the
|
||||
// model default).
|
||||
func reasoningEffortNotice(registry modelregistry.Registry, modelID string, requested string) string {
|
||||
trimmed := strings.TrimSpace(modelID)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -98,7 +98,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
defer stopSignals()
|
||||
result, err := agent.Run(runCtx, run.prompt, run.provider, agent.Options{
|
||||
MaxTurns: run.resolved.MaxTurns,
|
||||
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
|
||||
ContextWindow: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
|
||||
SessionID: draftSession.SessionID,
|
||||
SessionTitle: run.sessionTitle,
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
|
||||
@@ -7,27 +7,6 @@ import (
|
||||
|
||||
const DefaultModelID = "gpt-4.1"
|
||||
|
||||
// FallbackContextWindow is the assumed context window (max input tokens) for a
|
||||
// model that isn't in the curated registry and wasn't resolved from live provider
|
||||
// discovery — e.g. proxy/custom models like the GPT-5 Codex, xAI, or Ollama-cloud
|
||||
// variants. A positive value here is what enables agent-loop compaction (both the
|
||||
// proactive ~80% trigger and the reactive post-overflow recovery) for those models;
|
||||
// without it compaction stays disabled and long sessions overflow. It is only a
|
||||
// backstop: an exact window from the registry or live discovery always wins.
|
||||
const FallbackContextWindow = 200_000
|
||||
|
||||
// AgentContextWindow turns a resolved context window into the value used to enable
|
||||
// agent-loop compaction: the resolved window when known, else FallbackContextWindow
|
||||
// so compaction is enabled even for uncatalogued models. Kept separate from the
|
||||
// raw resolver so display gauges still show nothing (not a guessed denominator)
|
||||
// when the real window is unknown.
|
||||
func AgentContextWindow(resolved int) int {
|
||||
if resolved <= 0 {
|
||||
return FallbackContextWindow
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
const sourceLastVerified = "2026-06-04"
|
||||
|
||||
const (
|
||||
@@ -193,12 +172,13 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
|
||||
}
|
||||
|
||||
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
|
||||
if model, ok := registry.Get(pattern); ok {
|
||||
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
|
||||
if model, ok := registry.Get(pattern); ok && len(model.ReasoningEfforts) > 0 {
|
||||
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
|
||||
}
|
||||
// Unknown model not in the curated catalog — e.g. a GPT-5 / Codex / o-series
|
||||
// variant served via the ChatGPT proxy or a custom OpenAI-compatible endpoint.
|
||||
// Infer from the name so /effort still shows controls for it.
|
||||
// Fallback for reasoning-model families that aren't enumerated in the curated
|
||||
// catalog — e.g. the GPT-5 / Codex / o-series variants served via the ChatGPT
|
||||
// proxy or custom OpenAI-compatible endpoints. Without this, /effort shows no
|
||||
// controls for those models even though they support reasoning_effort.
|
||||
return reasoningEffortsForModelName(pattern)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
ReasoningEffortMedium ReasoningEffort = "medium"
|
||||
ReasoningEffortHigh ReasoningEffort = "high"
|
||||
ReasoningEffortXHigh ReasoningEffort = "xhigh"
|
||||
// ReasoningEffortMax is the top tier some providers expose above xhigh (e.g.
|
||||
// newer Anthropic models). Defined so a capability catalog that lists "max"
|
||||
// has a constant to map to; no curated model lists it yet.
|
||||
ReasoningEffortMax ReasoningEffort = "max"
|
||||
)
|
||||
|
||||
type ModelCapability string
|
||||
@@ -406,7 +410,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
|
||||
|
||||
func ValidReasoningEffort(effort ReasoningEffort) bool {
|
||||
switch effort {
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
|
||||
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -47,13 +47,10 @@ func (registry Registry) ResolveWithFallback(input string) (ModelEntry, string,
|
||||
|
||||
// EffectiveReasoningEffort returns the effort to use for a model: the requested
|
||||
// value if the model supports it, otherwise the model's default (or first
|
||||
// supported, or none). It resolves the supported set through
|
||||
// effectiveReasoningEfforts so it sees the same name-based fallback the /effort
|
||||
// picker uses — the two must never disagree about which tiers a model supports.
|
||||
// supported, or none).
|
||||
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
|
||||
efforts := effectiveReasoningEfforts(model)
|
||||
if requested != "" {
|
||||
for _, effort := range efforts {
|
||||
for _, effort := range model.ReasoningEfforts {
|
||||
if effort == requested {
|
||||
return requested
|
||||
}
|
||||
@@ -62,23 +59,8 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
|
||||
if model.DefaultReasoningEffort != "" {
|
||||
return model.DefaultReasoningEffort
|
||||
}
|
||||
if len(efforts) > 0 {
|
||||
return efforts[0]
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts[0]
|
||||
}
|
||||
return ReasoningEffortNone
|
||||
}
|
||||
|
||||
// effectiveReasoningEfforts returns a model's supported reasoning efforts, falling
|
||||
// back to name-based inference (reasoningEffortsForModelName) when the catalog
|
||||
// entry enumerates none. Both the /effort picker (Registry.ReasoningEfforts) and
|
||||
// the run-time resolver (EffectiveReasoningEffort) read efforts through this
|
||||
// single helper, so the picker can never advertise a tier the resolver drops.
|
||||
func effectiveReasoningEfforts(model ModelEntry) []ReasoningEffort {
|
||||
if len(model.ReasoningEfforts) > 0 {
|
||||
return model.ReasoningEfforts
|
||||
}
|
||||
if efforts := reasoningEffortsForModelName(model.ID); len(efforts) > 0 {
|
||||
return efforts
|
||||
}
|
||||
return reasoningEffortsForModelName(model.APIModel)
|
||||
}
|
||||
@@ -79,43 +79,3 @@ func TestEffectiveReasoningEffort(t *testing.T) {
|
||||
t.Errorf("empty effort should use default low, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveReasoningEffortUsesNameFallback pins that the run-time resolver
|
||||
// honors the same name-based fallback the /effort picker uses. A model that the
|
||||
// catalog enumerates with no efforts but whose name is a known reasoning family
|
||||
// (e.g. a GPT-5 variant served via a proxy) must have its requested effort
|
||||
// honored — not silently coerced to "none" while the picker advertises controls.
|
||||
func TestEffectiveReasoningEffortUsesNameFallback(t *testing.T) {
|
||||
// Registered model, no explicit efforts, GPT-5 api model -> fallback infers
|
||||
// {minimal, low, medium, high}.
|
||||
gpt5 := ModelEntry{ID: "gpt-5-proxy", APIModel: "gpt-5", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortHigh); got != ReasoningEffortHigh {
|
||||
t.Errorf("supported (via name fallback) effort = %q; want high", got)
|
||||
}
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortMinimal); got != ReasoningEffortMinimal {
|
||||
t.Errorf("minimal (via name fallback) = %q; want minimal", got)
|
||||
}
|
||||
// xhigh is outside the inferred set and there is no declared default, so it
|
||||
// coerces to the first inferred tier rather than to "none".
|
||||
if got := EffectiveReasoningEffort(gpt5, ReasoningEffortXHigh); got != ReasoningEffortMinimal {
|
||||
t.Errorf("unsupported effort on a fallback model = %q; want minimal (first inferred)", got)
|
||||
}
|
||||
|
||||
// Non-reasoning model: name matches nothing, stays "none".
|
||||
gpt4o := ModelEntry{ID: "gpt-4o", APIModel: "gpt-4o", Provider: ProviderOpenAI}
|
||||
if got := EffectiveReasoningEffort(gpt4o, ReasoningEffortHigh); got != ReasoningEffortNone {
|
||||
t.Errorf("non-reasoning model = %q; want none", got)
|
||||
}
|
||||
|
||||
// The picker and the resolver must agree on the supported set for the same id.
|
||||
reg := resolveTestRegistry(t)
|
||||
picker := reg.ReasoningEfforts("gpt-5") // unknown -> name fallback
|
||||
if len(picker) == 0 {
|
||||
t.Fatal("picker should advertise efforts for a gpt-5 name")
|
||||
}
|
||||
for _, tier := range picker {
|
||||
if got := EffectiveReasoningEffort(gpt5, tier); got != tier {
|
||||
t.Errorf("picker advertises %q but resolver returns %q", tier, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,21 +71,13 @@ const (
|
||||
|
||||
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
|
||||
type responsesRequest struct {
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
Input []inputItem `json:"input"`
|
||||
Stream bool `json:"stream"`
|
||||
Store bool `json:"store"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// responsesReasoning carries the reasoning controls for the Responses API. The
|
||||
// chat-completions `reasoning_effort` field is nested under `reasoning.effort`
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Instructions string `json:"instructions"`
|
||||
Input []inputItem `json:"input"`
|
||||
Stream bool `json:"stream"`
|
||||
Store bool `json:"store"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
Tools []responsesTool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -250,14 +242,6 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
Parameters: tool.Parameters,
|
||||
})
|
||||
}
|
||||
// Codex / o-series reasoning models take the effort tier nested under
|
||||
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
|
||||
// moved here). Reuse the chat normalizer so only API-accepted values are sent
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -454,67 +454,6 @@ func TestCodexProviderSendsResponsesRequestShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
// A reasoning effort must reach the Responses backend nested under
|
||||
// `reasoning.effort` (where the chat-completions `reasoning_effort` moved).
|
||||
// Without this the user's chosen effort is silently dropped for Codex models.
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
|
||||
reasoning, ok := rec.body["reasoning"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("body.reasoning = %#v, want an object carrying the effort", rec.body["reasoning"])
|
||||
}
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
// No effort (and unsupported values) must omit the `reasoning` field entirely
|
||||
// rather than send an empty object, which the backend would reject.
|
||||
var rec codexRequest
|
||||
srv := newCodexTestServer(t, &rec)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
||||
ReasoningEffort: "", // auto
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
drainCodexEvents(t, stream)
|
||||
|
||||
if _, ok := rec.body["reasoning"]; ok {
|
||||
t.Fatalf("body.reasoning must be omitted when no effort is requested, got %#v", rec.body["reasoning"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderRetriesHeadersAfter401(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var rec1, rec2 codexRequest
|
||||
|
||||
@@ -385,14 +385,6 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -453,12 +445,11 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,6 +1000,57 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1025,6 +1076,38 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1082,8 +1165,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,66 +1246,3 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,8 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package reasoning models how each provider's models expose reasoning-effort
|
||||
// control. Providers disagree on the concept — OpenAI uses a discrete effort
|
||||
// enum, Anthropic and Gemini 2.5 use a thinking-token budget, some models only
|
||||
// toggle thinking on/off — so a single flat effort string cannot describe them.
|
||||
//
|
||||
// Capability is the typed, per-model description of that control, sourced from a
|
||||
// community capability catalog (models.dev). It is the data the rest of Zero
|
||||
// consults to decide which reasoning tiers a model actually supports, replacing
|
||||
// model-name guessing. This package depends only on the standard library, so the
|
||||
// model registry and provider adapters can import it without a cycle.
|
||||
//
|
||||
// Source of truth: this catalog is intended to become the authoritative reasoning
|
||||
// -capability source. When it is wired into the live path, modelregistry's
|
||||
// name-pattern inference (reasoningEffortsForModelName) is demoted to a fallback
|
||||
// for models the catalog does not cover; the catalog wins where it has an entry.
|
||||
// This commit is additive groundwork — nothing consumes it yet — so there is no
|
||||
// behavioral divergence until that wiring lands.
|
||||
package reasoning
|
||||
|
||||
import "strings"
|
||||
|
||||
// ControlKind enumerates how a model exposes reasoning control. The values
|
||||
// mirror the models.dev reasoning_options[].type field.
|
||||
type ControlKind string
|
||||
|
||||
const (
|
||||
// ControlEffort is a discrete effort enum (OpenAI reasoning_effort, Gemini 3
|
||||
// thinkingLevel, newer Claude output_config.effort).
|
||||
ControlEffort ControlKind = "effort"
|
||||
// ControlBudget is a thinking-token budget (Gemini 2.5 thinkingBudget, legacy
|
||||
// Claude thinking.budget_tokens).
|
||||
ControlBudget ControlKind = "budget_tokens"
|
||||
// ControlToggle is an on/off thinking switch with no levels.
|
||||
ControlToggle ControlKind = "toggle"
|
||||
)
|
||||
|
||||
// Control is one reasoning control a model accepts. For an effort control,
|
||||
// Values lists the accepted tiers ordered weakest to strongest. For a budget
|
||||
// control, Min/Max bound the thinking-token budget; each is nil when the
|
||||
// provider does not bound that side, kept distinct from an explicit 0 (Gemini
|
||||
// uses min: 0 to mean "thinking can be disabled", which a plain int would lose).
|
||||
type Control struct {
|
||||
Kind ControlKind `json:"type"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Min *int `json:"min,omitempty"`
|
||||
Max *int `json:"max,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the control so the embedded catalog cannot be
|
||||
// mutated through a returned Control's slice or budget pointers.
|
||||
func (ctrl Control) clone() Control {
|
||||
out := Control{Kind: ctrl.Kind}
|
||||
if ctrl.Values != nil {
|
||||
out.Values = append([]string(nil), ctrl.Values...)
|
||||
}
|
||||
if ctrl.Min != nil {
|
||||
min := *ctrl.Min
|
||||
out.Min = &min
|
||||
}
|
||||
if ctrl.Max != nil {
|
||||
max := *ctrl.Max
|
||||
out.Max = &max
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Capability is the reasoning capability of a single model: whether it reasons
|
||||
// at all, and through which controls. A model may carry more than one control
|
||||
// (e.g. a newer Claude model exposes both an effort enum and a token budget); a
|
||||
// reasoning model with no controls reasons but exposes no knob (always-on).
|
||||
type Capability struct {
|
||||
Reasoning bool `json:"reasoning"`
|
||||
Controls []Control `json:"reasoning_options,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the capability, cloning each control, so a caller
|
||||
// cannot mutate the shared catalog through the returned value's slices.
|
||||
func (c Capability) clone() Capability {
|
||||
out := Capability{Reasoning: c.Reasoning}
|
||||
if c.Controls != nil {
|
||||
out.Controls = make([]Control, len(c.Controls))
|
||||
for i, ctrl := range c.Controls {
|
||||
out.Controls[i] = ctrl.clone()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Supported reports whether the model performs any reasoning.
|
||||
func (c Capability) Supported() bool { return c.Reasoning }
|
||||
|
||||
// EffortControl returns the model's effort control and whether it has one. The
|
||||
// returned Control is a deep copy, so a caller cannot mutate the shared catalog
|
||||
// through its Values slice.
|
||||
func (c Capability) EffortControl() (Control, bool) {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == ControlEffort {
|
||||
return ctrl.clone(), true
|
||||
}
|
||||
}
|
||||
return Control{}, false
|
||||
}
|
||||
|
||||
// EffortValues returns the ordered effort tiers the model accepts, or nil when
|
||||
// it has no effort control (a budget- or toggle-only model, or a non-reasoning
|
||||
// model).
|
||||
func (c Capability) EffortValues() []string {
|
||||
if ctrl, ok := c.EffortControl(); ok {
|
||||
return ctrl.Values
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportsEffort reports whether tier is one of the model's accepted effort
|
||||
// values (case-insensitive).
|
||||
func (c Capability) SupportsEffort(tier string) bool {
|
||||
tier = strings.ToLower(strings.TrimSpace(tier))
|
||||
for _, v := range c.EffortValues() {
|
||||
if strings.ToLower(v) == tier {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BudgetControl returns the model's token-budget control and whether it has one.
|
||||
// The returned Control is a deep copy: its Min/Max are independent pointers, so a
|
||||
// caller cannot mutate the shared catalog through them. Min/Max are nil when that
|
||||
// bound is unspecified (a real 0, e.g. Gemini's min: 0, is a non-nil pointer to 0).
|
||||
func (c Capability) BudgetControl() (Control, bool) {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == ControlBudget {
|
||||
return ctrl.clone(), true
|
||||
}
|
||||
}
|
||||
return Control{}, false
|
||||
}
|
||||
|
||||
// HasControl reports whether the model exposes a control of the given kind.
|
||||
func (c Capability) HasControl(kind ControlKind) bool {
|
||||
for _, ctrl := range c.Controls {
|
||||
if ctrl.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// modelsdev_snapshot.json is a trimmed snapshot of https://models.dev/api.json:
|
||||
// for a set of first-party providers, each model's api id mapped to its
|
||||
// reasoning capability (the `reasoning` flag and `reasoning_options`). Routers
|
||||
// (OpenRouter/Azure/etc.) are intentionally excluded — they carry generic or
|
||||
// stale option lists, so a lookup resolves to the first-party provider instead.
|
||||
//
|
||||
// Provenance is recorded in the snapshot's `_source`/`_fetched` header fields.
|
||||
// To regenerate: fetch api.json, then for each provider in
|
||||
// {anthropic, openai, google, xai, groq, deepseek} keep `{reasoning,
|
||||
// reasoning_options}` per api id, and bump `_fetched`. Runtime auto-refresh is a
|
||||
// separate change.
|
||||
//
|
||||
//go:embed modelsdev_snapshot.json
|
||||
var snapshotBytes []byte
|
||||
|
||||
// snapshotDoc is the on-disk shape of the embedded snapshot.
|
||||
type snapshotDoc struct {
|
||||
Providers map[string]map[string]Capability `json:"providers"`
|
||||
}
|
||||
|
||||
// Catalog is a reasoning-capability lookup keyed by provider slug and api model
|
||||
// id. It is read-only after construction.
|
||||
type Catalog struct {
|
||||
byProvider map[string]map[string]Capability
|
||||
}
|
||||
|
||||
// ParseCatalog builds a Catalog from a models.dev-style snapshot document.
|
||||
func ParseCatalog(data []byte) (Catalog, error) {
|
||||
var doc snapshotDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return Catalog{}, fmt.Errorf("reasoning: parse catalog: %w", err)
|
||||
}
|
||||
if len(doc.Providers) == 0 {
|
||||
return Catalog{}, fmt.Errorf("reasoning: catalog has no providers")
|
||||
}
|
||||
return Catalog{byProvider: doc.Providers}, nil
|
||||
}
|
||||
|
||||
// embedded is the catalog parsed from the bundled snapshot. A malformed embed is
|
||||
// a build/release error, so it panics at init rather than failing silently.
|
||||
var embedded = mustParseEmbedded()
|
||||
|
||||
func mustParseEmbedded() Catalog {
|
||||
c, err := ParseCatalog(snapshotBytes)
|
||||
if err != nil {
|
||||
panic("reasoning: invalid embedded snapshot: " + err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Embedded returns the catalog parsed from the bundled models.dev snapshot.
|
||||
func Embedded() Catalog { return embedded }
|
||||
|
||||
// Lookup returns the reasoning capability for a model identified by its Zero
|
||||
// provider kind and the API model id — the provider's wire name (e.g.
|
||||
// "claude-opus-4-1-20250805"), NOT the friendly registry id ("claude-opus-4.1").
|
||||
// The match is exact: the api model id is only trimmed, not case-folded, and no
|
||||
// router prefixes ("openai/…") or suffixes (":cloud") are stripped. ok is false
|
||||
// when the provider or model is not in the snapshot, in which case the caller
|
||||
// falls back to its next capability source.
|
||||
func (c Catalog) Lookup(provider, apiModel string) (Capability, bool) {
|
||||
apiModel = strings.TrimSpace(apiModel)
|
||||
if apiModel == "" {
|
||||
return Capability{}, false
|
||||
}
|
||||
for _, slug := range providerSlugs(provider) {
|
||||
models, ok := c.byProvider[slug]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if entry, ok := models[apiModel]; ok {
|
||||
// Deep copy so callers cannot mutate the shared catalog through the
|
||||
// returned Controls / Values slices or budget pointers.
|
||||
return entry.clone(), true
|
||||
}
|
||||
}
|
||||
return Capability{}, false
|
||||
}
|
||||
|
||||
// providerSlugs maps a Zero provider kind to the models.dev provider slug to
|
||||
// look up. Gemini and Google both resolve to "google" (AI Studio), the
|
||||
// first-party source for Gemini models.
|
||||
func providerSlugs(provider string) []string {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "gemini":
|
||||
return []string{"google"}
|
||||
default:
|
||||
return []string{strings.ToLower(strings.TrimSpace(provider))}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package reasoning
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmbeddedCatalogLoads(t *testing.T) {
|
||||
c := Embedded()
|
||||
if len(c.byProvider) == 0 {
|
||||
t.Fatal("embedded catalog is empty")
|
||||
}
|
||||
if _, ok := c.Lookup("openai", "gpt-5"); !ok {
|
||||
t.Fatal("embedded catalog missing a well-known model (gpt-5)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCatalogRejectsEmpty(t *testing.T) {
|
||||
if _, err := ParseCatalog([]byte(`{}`)); err == nil {
|
||||
t.Fatal("expected an error for a catalog with no providers")
|
||||
}
|
||||
if _, err := ParseCatalog([]byte(`not json`)); err == nil {
|
||||
t.Fatal("expected an error for malformed json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthOpenAI(t *testing.T) {
|
||||
c := Embedded()
|
||||
cases := []struct {
|
||||
api string
|
||||
reason bool
|
||||
kind ControlKind
|
||||
values []string
|
||||
}{
|
||||
{"gpt-5", true, ControlEffort, []string{"minimal", "low", "medium", "high"}},
|
||||
{"gpt-5-codex", true, ControlEffort, []string{"low", "medium", "high"}},
|
||||
{"gpt-5-pro", true, ControlEffort, []string{"high"}},
|
||||
{"gpt-5.1", true, ControlEffort, []string{"none", "low", "medium", "high"}},
|
||||
{"gpt-5.1-codex-max", true, ControlEffort, []string{"low", "medium", "high", "xhigh"}},
|
||||
{"o3", true, ControlEffort, []string{"low", "medium", "high"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
entry, ok := c.Lookup("openai", tc.api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", tc.api)
|
||||
continue
|
||||
}
|
||||
if entry.Supported() != tc.reason {
|
||||
t.Errorf("%s: Supported=%v want %v", tc.api, entry.Supported(), tc.reason)
|
||||
}
|
||||
if !equalStrings(entry.EffortValues(), tc.values) {
|
||||
t.Errorf("%s: efforts=%v want %v", tc.api, entry.EffortValues(), tc.values)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-reasoning models report reasoning:false and expose no effort.
|
||||
for _, api := range []string{"gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4-turbo"} {
|
||||
entry, ok := c.Lookup("openai", api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", api)
|
||||
continue
|
||||
}
|
||||
if entry.Supported() {
|
||||
t.Errorf("%s: Supported=true, want false (non-reasoning)", api)
|
||||
}
|
||||
if entry.EffortValues() != nil {
|
||||
t.Errorf("%s: efforts=%v, want nil", api, entry.EffortValues())
|
||||
}
|
||||
}
|
||||
|
||||
// Version splits: minimal vs none are not interchangeable.
|
||||
gpt5, _ := c.Lookup("openai", "gpt-5")
|
||||
if !gpt5.SupportsEffort("minimal") || gpt5.SupportsEffort("none") {
|
||||
t.Error("gpt-5 should support minimal, not none")
|
||||
}
|
||||
gpt51, _ := c.Lookup("openai", "gpt-5.1")
|
||||
if gpt51.SupportsEffort("minimal") || !gpt51.SupportsEffort("none") {
|
||||
t.Error("gpt-5.1 should support none, not minimal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthAnthropic(t *testing.T) {
|
||||
c := Embedded()
|
||||
|
||||
// Legacy / budget-only models: a thinking-token budget, no effort enum. The
|
||||
// budget carries an explicit min (1024) and no max — Max stays nil.
|
||||
for _, api := range []string{
|
||||
"claude-opus-4-1-20250805", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001",
|
||||
} {
|
||||
entry, ok := c.Lookup("anthropic", api)
|
||||
if !ok {
|
||||
t.Errorf("%s: not found", api)
|
||||
continue
|
||||
}
|
||||
ctrl, ok := entry.BudgetControl()
|
||||
if !ok {
|
||||
t.Errorf("%s: expected a budget control", api)
|
||||
continue
|
||||
}
|
||||
if ctrl.Min == nil || *ctrl.Min != 1024 {
|
||||
t.Errorf("%s: budget Min=%v, want non-nil 1024", api, ctrl.Min)
|
||||
}
|
||||
if ctrl.Max != nil {
|
||||
t.Errorf("%s: budget Max=%d, want nil (unbounded)", api, *ctrl.Max)
|
||||
}
|
||||
if _, ok := entry.EffortControl(); ok {
|
||||
t.Errorf("%s: did not expect an effort control", api)
|
||||
}
|
||||
}
|
||||
|
||||
// Newer models: native effort enum with xhigh and the budget control removed.
|
||||
opus48, ok := c.Lookup("anthropic", "claude-opus-4-8")
|
||||
if !ok {
|
||||
t.Fatal("claude-opus-4-8 not found")
|
||||
}
|
||||
if !equalStrings(opus48.EffortValues(), []string{"low", "medium", "high", "xhigh", "max"}) {
|
||||
t.Errorf("opus-4-8 efforts=%v", opus48.EffortValues())
|
||||
}
|
||||
if _, ok := opus48.BudgetControl(); ok {
|
||||
t.Error("opus-4-8 should not carry a budget control (removed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthGemini(t *testing.T) {
|
||||
c := Embedded()
|
||||
|
||||
pro, ok := c.Lookup("gemini", "gemini-2.5-pro") // gemini kind -> google slug
|
||||
if !ok {
|
||||
t.Fatal("gemini-2.5-pro not found via the gemini provider kind")
|
||||
}
|
||||
ctrl, ok := pro.BudgetControl()
|
||||
if !ok || ctrl.Min == nil || *ctrl.Min != 128 || ctrl.Max == nil || *ctrl.Max != 32768 {
|
||||
t.Errorf("gemini-2.5-pro budget=%+v ok=%v, want min=128 max=32768", ctrl, ok)
|
||||
}
|
||||
if _, ok := pro.EffortControl(); ok {
|
||||
t.Error("gemini-2.5-pro should be budget-only, no effort control")
|
||||
}
|
||||
|
||||
// gemini-2.5-flash can disable thinking: a toggle plus a budget whose explicit
|
||||
// min: 0 must survive (a plain int + omitempty would drop it).
|
||||
flash, _ := c.Lookup("google", "gemini-2.5-flash")
|
||||
if !flash.HasControl(ControlToggle) {
|
||||
t.Error("gemini-2.5-flash should expose a toggle (thinking can be disabled)")
|
||||
}
|
||||
if fctrl, ok := flash.BudgetControl(); !ok || fctrl.Min == nil || *fctrl.Min != 0 {
|
||||
t.Errorf("gemini-2.5-flash budget Min=%v, want a non-nil pointer to 0", fctrl.Min)
|
||||
}
|
||||
|
||||
// Gemini 3.x switched to an effort enum.
|
||||
if g3, ok := c.Lookup("google", "gemini-3-pro-preview"); ok {
|
||||
if _, ok := g3.EffortControl(); !ok {
|
||||
t.Error("gemini-3-pro-preview should expose an effort control")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoversZeroShippedReasoningModels pins that every reasoning model Zero
|
||||
// currently ships resolves in the catalog (by its api model id), so the
|
||||
// models.dev fallback actually covers Zero's catalog rather than just well-known
|
||||
// ids. The api ids mirror internal/modelregistry's curated entries.
|
||||
func TestCoversZeroShippedReasoningModels(t *testing.T) {
|
||||
c := Embedded()
|
||||
shipped := []struct {
|
||||
provider, api string
|
||||
wantKind ControlKind
|
||||
}{
|
||||
{"anthropic", "claude-opus-4-1-20250805", ControlBudget},
|
||||
{"anthropic", "claude-sonnet-4-5-20250929", ControlBudget},
|
||||
{"anthropic", "claude-haiku-4-5-20251001", ControlBudget},
|
||||
{"google", "gemini-2.5-pro", ControlBudget},
|
||||
{"google", "gemini-2.5-flash", ControlToggle},
|
||||
{"google", "gemini-2.5-flash-lite", ControlToggle},
|
||||
}
|
||||
for _, m := range shipped {
|
||||
entry, ok := c.Lookup(m.provider, m.api)
|
||||
if !ok {
|
||||
t.Errorf("%s/%s: not covered by the snapshot", m.provider, m.api)
|
||||
continue
|
||||
}
|
||||
if !entry.Supported() {
|
||||
t.Errorf("%s/%s: Supported=false, want a reasoning model", m.provider, m.api)
|
||||
}
|
||||
if !entry.HasControl(m.wantKind) {
|
||||
t.Errorf("%s/%s: missing expected control %q (controls=%+v)", m.provider, m.api, m.wantKind, entry.Controls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupReturnsDeepCopy pins that a caller cannot corrupt the shared
|
||||
// embedded catalog by mutating a returned Capability's slices.
|
||||
func TestLookupReturnsDeepCopy(t *testing.T) {
|
||||
c := Embedded()
|
||||
first, ok := c.Lookup("openai", "gpt-5")
|
||||
if !ok || len(first.Controls) == 0 || len(first.Controls[0].Values) == 0 {
|
||||
t.Fatal("setup: gpt-5 should have an effort control with values")
|
||||
}
|
||||
first.Controls[0].Values[0] = "MUTATED"
|
||||
first.Controls[0].Kind = "MUTATED"
|
||||
|
||||
second, _ := c.Lookup("openai", "gpt-5")
|
||||
if second.Controls[0].Values[0] == "MUTATED" || second.Controls[0].Kind == "MUTATED" {
|
||||
t.Error("Lookup leaked a shared reference; a caller's mutation reached the catalog")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessorsReturnDeepCopies pins that EffortControl/BudgetControl hand back
|
||||
// independent copies, so a caller holding a Capability that shares state with the
|
||||
// catalog cannot corrupt it by mutating an accessor result's slice or pointers.
|
||||
func TestAccessorsReturnDeepCopies(t *testing.T) {
|
||||
min := 100
|
||||
c := Capability{Reasoning: true, Controls: []Control{
|
||||
{Kind: ControlEffort, Values: []string{"low", "high"}},
|
||||
{Kind: ControlBudget, Min: &min},
|
||||
}}
|
||||
|
||||
eff, _ := c.EffortControl()
|
||||
eff.Values[0] = "MUTATED"
|
||||
if c.Controls[0].Values[0] != "low" {
|
||||
t.Error("EffortControl leaked the Values slice")
|
||||
}
|
||||
|
||||
bud, _ := c.BudgetControl()
|
||||
*bud.Min = 999
|
||||
if *c.Controls[1].Min != 100 {
|
||||
t.Error("BudgetControl leaked the Min pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupMissFallsThrough(t *testing.T) {
|
||||
c := Embedded()
|
||||
if _, ok := c.Lookup("openai", "totally-made-up-model"); ok {
|
||||
t.Error("unknown model should miss")
|
||||
}
|
||||
if _, ok := c.Lookup("no-such-provider", "gpt-5"); ok {
|
||||
t.Error("unknown provider should miss")
|
||||
}
|
||||
if _, ok := c.Lookup("openai", " "); ok {
|
||||
t.Error("blank api model should miss")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -46,11 +44,6 @@ func renderAssistantMarkdownText(text string, proseMeasure int, tableMeasure int
|
||||
if len(raw) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
if allowHighlight && looksLikeBareCodeBlock(raw) {
|
||||
if highlighted, ok := highlightCodeAuto(raw, "", tableMeasure); ok {
|
||||
return trimMarkdownDisplayBlankEdges(highlighted)
|
||||
}
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
// blankBefore inserts one separator blank line before a block (heading or
|
||||
@@ -89,8 +82,11 @@ func renderAssistantMarkdownText(text string, proseMeasure int, tableMeasure int
|
||||
code = append(code, strings.ReplaceAll(raw[index], "\t", " "))
|
||||
index++
|
||||
}
|
||||
// Highlighting tokenises the whole block, so it is confined to
|
||||
// committed/cached rows (allowHighlight); the live streaming block
|
||||
// renders plain so the per-frame render loop never re-lexes.
|
||||
if allowHighlight {
|
||||
if highlighted, ok := highlightCodeAuto(code, lang, tableMeasure); ok {
|
||||
if highlighted, ok := highlightCode(code, lang, tableMeasure); ok {
|
||||
lines = append(lines, highlighted...)
|
||||
continue
|
||||
}
|
||||
@@ -111,13 +107,6 @@ func renderAssistantMarkdownText(text string, proseMeasure int, tableMeasure int
|
||||
continue
|
||||
}
|
||||
|
||||
if isMarkdownHorizontalRule(trimmed) {
|
||||
blankBefore()
|
||||
lines = append(lines, renderMarkdownHorizontalRule(proseMeasure))
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
if heading := markdownHeadingText(trimmed); heading != "" {
|
||||
blankBefore()
|
||||
// Headings are distinguished by weight + underline, not a bright colour —
|
||||
@@ -137,27 +126,6 @@ func renderAssistantMarkdownText(text string, proseMeasure int, tableMeasure int
|
||||
continue
|
||||
}
|
||||
|
||||
if allowHighlight && looksLikeBareCodeLine(line) {
|
||||
code := []string{}
|
||||
probe := index
|
||||
for probe < len(raw) {
|
||||
next := raw[probe]
|
||||
nextTrimmed := strings.TrimSpace(next)
|
||||
if nextTrimmed == "" || markdownStartsBlock(raw, probe) || !looksLikeBareCodeLine(next) {
|
||||
break
|
||||
}
|
||||
code = append(code, strings.ReplaceAll(next, "\t", " "))
|
||||
probe++
|
||||
}
|
||||
if looksLikeBareCodeBlock(code) {
|
||||
if highlighted, ok := highlightCodeAuto(code, "", tableMeasure); ok {
|
||||
lines = append(lines, highlighted...)
|
||||
index = probe
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blankBefore()
|
||||
paragraph := []string{strings.TrimSpace(line)}
|
||||
index++
|
||||
@@ -176,58 +144,6 @@ func renderAssistantMarkdownText(text string, proseMeasure int, tableMeasure int
|
||||
return trimMarkdownDisplayBlankEdges(lines)
|
||||
}
|
||||
|
||||
func renderStreamingAssistantMarkdownText(text string, proseMeasure int, tableMeasure int) []string {
|
||||
stablePrefix := streamingMarkdownStablePrefix(text)
|
||||
if defaultRenderCache == nil {
|
||||
return renderAssistantMarkdownText(stablePrefix, proseMeasure, tableMeasure, true)
|
||||
}
|
||||
key := streamingMarkdownRenderCacheKey(stablePrefix, proseMeasure, tableMeasure)
|
||||
rendered := defaultRenderCache.render(key, true, func() string {
|
||||
return strings.Join(renderAssistantMarkdownText(stablePrefix, proseMeasure, tableMeasure, true), "\n")
|
||||
})
|
||||
return viewLines(rendered)
|
||||
}
|
||||
|
||||
func streamingMarkdownRenderCacheKey(text string, proseMeasure int, tableMeasure int) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return fmt.Sprintf("stream-md-v1:%d:%d:%x", proseMeasure, tableMeasure, sum)
|
||||
}
|
||||
|
||||
func streamingMarkdownStablePrefix(text string) string {
|
||||
text = strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\r", "\n")
|
||||
offset := 0
|
||||
openFenceStart := -1
|
||||
openFence := ""
|
||||
for offset <= len(text) {
|
||||
lineStart := offset
|
||||
next := strings.IndexByte(text[offset:], '\n')
|
||||
line := ""
|
||||
if next < 0 {
|
||||
line = text[offset:]
|
||||
offset = len(text) + 1
|
||||
} else {
|
||||
line = text[offset : offset+next]
|
||||
offset += next + 1
|
||||
}
|
||||
if fence, ok := markdownFenceMarker(strings.TrimSpace(line)); ok {
|
||||
if openFence == "" {
|
||||
openFence = fence
|
||||
openFenceStart = lineStart
|
||||
} else if fence == openFence {
|
||||
openFence = ""
|
||||
openFenceStart = -1
|
||||
}
|
||||
}
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if openFenceStart >= 0 {
|
||||
return strings.TrimRight(text[:openFenceStart], "\n")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasure int) []string {
|
||||
lines := renderAssistantMarkdownText(text, proseMeasure, tableMeasure, false)
|
||||
for index := range lines {
|
||||
@@ -242,10 +158,6 @@ func stripMarkdownRenderControls(text string) string {
|
||||
}
|
||||
|
||||
func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
if hasExternalANSIStyle(line) {
|
||||
return line
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
style := markdownDisplayNormal
|
||||
var run strings.Builder
|
||||
@@ -310,26 +222,6 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func hasExternalANSIStyle(line string) bool {
|
||||
for index := 0; index < len(line); {
|
||||
if line[index] != '\x1b' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
end := ansiSequenceEnd(line, index)
|
||||
if end <= index {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
seq := line[index:end]
|
||||
if seq != markdownBoldStart && seq != markdownBoldEnd {
|
||||
return true
|
||||
}
|
||||
index = end
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
|
||||
switch r {
|
||||
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
|
||||
@@ -353,166 +245,12 @@ func markdownStartsBlock(lines []string, index int) bool {
|
||||
if _, ok := markdownFenceMarker(trimmed); ok {
|
||||
return true
|
||||
}
|
||||
if markdownHeadingText(trimmed) != "" || isMarkdownHorizontalRule(trimmed) || isMarkdownListLine(trimmed) || strings.HasPrefix(trimmed, ">") {
|
||||
if markdownHeadingText(trimmed) != "" || isMarkdownListLine(trimmed) || strings.HasPrefix(trimmed, ">") {
|
||||
return true
|
||||
}
|
||||
return index+1 < len(lines) && isMarkdownTableHeader(lines[index], lines[index+1])
|
||||
}
|
||||
|
||||
func isMarkdownHorizontalRule(trimmed string) bool {
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
marker := rune(0)
|
||||
count := 0
|
||||
for _, r := range trimmed {
|
||||
if r == ' ' || r == '\t' {
|
||||
continue
|
||||
}
|
||||
if r != '-' && r != '_' && r != '*' {
|
||||
return false
|
||||
}
|
||||
if marker == 0 {
|
||||
marker = r
|
||||
} else if marker != r {
|
||||
return false
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count >= 3
|
||||
}
|
||||
|
||||
func renderMarkdownHorizontalRule(measure int) string {
|
||||
width := clampInt(measure, 16, 96)
|
||||
return strings.Repeat("─", width)
|
||||
}
|
||||
|
||||
func looksLikeBareCodeLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "from ") && strings.Contains(trimmed, " import "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "import ") && !strings.Contains(trimmed, " from "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "def ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "class ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "if ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "elif ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case trimmed == "else:" || trimmed == "try:" || trimmed == "finally:":
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "for ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "while ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "with ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "except") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "return "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "print("):
|
||||
return true
|
||||
case isSimpleFunctionCall(trimmed):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "package "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "func ") && strings.Contains(trimmed, "{"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "const "), strings.HasPrefix(trimmed, "let "), strings.HasPrefix(trimmed, "var "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "function ") && strings.Contains(trimmed, "{"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "<!DOCTYPE "), strings.HasPrefix(trimmed, "<html"), strings.HasPrefix(trimmed, "<div"), strings.HasPrefix(trimmed, "<span"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func looksLikeBareCodeBlock(lines []string) bool {
|
||||
nonBlank := 0
|
||||
codeLike := 0
|
||||
strongSignals := 0
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
nonBlank++
|
||||
if isIndentedCodeContinuation(line) {
|
||||
codeLike++
|
||||
strongSignals++
|
||||
continue
|
||||
}
|
||||
if looksLikeBareCodeLine(line) {
|
||||
codeLike++
|
||||
if looksLikeStrongBareCodeLine(line) {
|
||||
strongSignals++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nonBlank >= 2 && codeLike == nonBlank && strongSignals > 0
|
||||
}
|
||||
|
||||
func looksLikeStrongBareCodeLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "from ") && strings.Contains(trimmed, " import "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "import ") && !strings.Contains(trimmed, " from "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "def ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "class ") && strings.HasSuffix(trimmed, ":"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "print("):
|
||||
return true
|
||||
case isSimpleFunctionCall(trimmed):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "package "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "func ") && strings.Contains(trimmed, "{"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "const "), strings.HasPrefix(trimmed, "let "), strings.HasPrefix(trimmed, "var "):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "function ") && strings.Contains(trimmed, "{"):
|
||||
return true
|
||||
case strings.HasPrefix(trimmed, "<!DOCTYPE "), strings.HasPrefix(trimmed, "<html"), strings.HasPrefix(trimmed, "<div"), strings.HasPrefix(trimmed, "<span"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isIndentedCodeContinuation(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return trimmed != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t"))
|
||||
}
|
||||
|
||||
func isSimpleFunctionCall(trimmed string) bool {
|
||||
open := strings.Index(trimmed, "(")
|
||||
return open > 0 &&
|
||||
strings.HasSuffix(trimmed, ")") &&
|
||||
isIdentifierText(trimmed[:open])
|
||||
}
|
||||
|
||||
func isIdentifierText(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for index, r := range text {
|
||||
if r == '_' || unicode.IsLetter(r) || (index > 0 && unicode.IsDigit(r)) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func markdownFenceMarker(trimmed string) (string, bool) {
|
||||
for _, marker := range []string{"```", "~~~"} {
|
||||
if strings.HasPrefix(trimmed, marker) {
|
||||
|
||||
@@ -48,8 +48,8 @@ func TestSuggestionsSurfaceMatchingCommands(t *testing.T) {
|
||||
t.Fatal("expected suggestions active after typing /mo")
|
||||
}
|
||||
names := suggestionNames(m)
|
||||
if !contains(names, "/model") {
|
||||
t.Fatalf("expected /model in suggestions, got %v", names)
|
||||
if !contains(names, "/model") || !contains(names, "/mode") {
|
||||
t.Fatalf("expected /model and /mode in suggestions, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestSuggestionsInactiveWithoutSlashOrToken(t *testing.T) {
|
||||
|
||||
func TestTabCyclesSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/s") // ambiguous prefix with several matches
|
||||
m = typeRunes(t, m, "/mo")
|
||||
start := m.suggestionIdx
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyTab))
|
||||
@@ -101,7 +101,7 @@ func TestTabCyclesSuggestions(t *testing.T) {
|
||||
|
||||
func TestUpDownMoveSuggestions(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/s") // ambiguous prefix with several matches
|
||||
m = typeRunes(t, m, "/mo")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyDown))
|
||||
m = updated.(model)
|
||||
@@ -223,13 +223,13 @@ func TestCommandSuggestionFooterReflectsInsertAction(t *testing.T) {
|
||||
|
||||
func TestTabCompletesAfterSelection(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = typeRunes(t, m, "/s") // ambiguous prefix (stop, search, spec, …)
|
||||
m = typeRunes(t, m, "/mo")
|
||||
|
||||
// With multiple matches, Tab cycles the highlighted suggestion rather than
|
||||
// completing the input, so the typed prefix stays put.
|
||||
// Move to /mode, then Tab again -> per spec Tab cycles, so we use Down then
|
||||
// Enter to lock the selection; verify Tab keeps cycling not completing.
|
||||
updated, _ := m.Update(testKey(tea.KeyTab))
|
||||
m = updated.(model)
|
||||
if m.input.Value() != "/s" {
|
||||
if m.input.Value() != "/mo" {
|
||||
t.Fatalf("Tab should cycle, not yet complete; input=%q", m.input.Value())
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func TestSuggestionOverlayRenders(t *testing.T) {
|
||||
if !strings.Contains(plain, "model") || !strings.Contains(plain, "mode") {
|
||||
t.Fatal("view should render the suggestion overlay")
|
||||
}
|
||||
if strings.Contains(plain, "/model") {
|
||||
if strings.Contains(plain, "/model") || strings.Contains(plain, "/mode") {
|
||||
t.Fatalf("suggestion overlay should display command names without slash prefixes, got %q", plain)
|
||||
}
|
||||
for _, want := range []string{"╭── Commands", "╰", "search > mo", "↑/↓ move", "Enter run", "Esc close"} {
|
||||
|
||||
@@ -427,7 +427,7 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
if guarded, text, requested := m.requestCompactionBeforeModelSwitch(modelSwitchCompactionRequest{
|
||||
TargetModel: target.modelID,
|
||||
TargetProvider: string(metadata.ProviderKind),
|
||||
TargetContextWindow: modelregistry.AgentContextWindow(m.modelContextWindow(target.modelID)),
|
||||
TargetContextWindow: modelContextWindow(target.modelID),
|
||||
}, "Model"); requested {
|
||||
return guarded, text
|
||||
}
|
||||
@@ -613,13 +613,109 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
|
||||
return modelSwitchTarget{}, false
|
||||
}
|
||||
|
||||
// handleModeCommand applies a preset that bundles model, reasoning effort, and
|
||||
// turn budget. "/mode" with no argument lists the presets; "/mode <name>"
|
||||
// switches the active model (rebuilding the provider, like /model), the reasoning
|
||||
// effort (like /effort), and the agent-loop turn budget for this TUI session. It
|
||||
// mirrors the state mutations in handleModelCommand/handleEffortCommand so a mode
|
||||
// switch is equivalent to running those commands in sequence.
|
||||
func (m model) handleModeCommand(args string) (model, string) {
|
||||
args = strings.TrimSpace(args)
|
||||
switch strings.ToLower(args) {
|
||||
case "":
|
||||
return m, m.modeListText()
|
||||
case "list", "ls":
|
||||
return m, m.modeListText()
|
||||
}
|
||||
|
||||
mode, ok := modelregistry.LookupMode(args)
|
||||
if !ok {
|
||||
return m, "Mode\nunknown mode " + strconv.Quote(args) + "\navailable: " + strings.Join(modelregistry.ModeNames(), ", ")
|
||||
}
|
||||
if m.pending {
|
||||
return m, "Mode\nCannot switch modes while a run is active."
|
||||
}
|
||||
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return m, "Mode\nFailed to load model catalog: " + err.Error()
|
||||
}
|
||||
entry, notice, ok := registry.ResolveWithFallback(mode.Model)
|
||||
if !ok {
|
||||
return m, "Mode\nmode " + strconv.Quote(mode.Name) + " references unknown model " + strconv.Quote(mode.Model)
|
||||
}
|
||||
if !config.HasProviderProfile(m.providerProfile) {
|
||||
return m, "Mode\nNo provider profile is available for TUI mode switching."
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Mode\nProvider rebuild is not available for this TUI session."
|
||||
}
|
||||
|
||||
nextProfile := m.providerProfile
|
||||
nextProfile.Model = entry.ID
|
||||
metadata, err := providers.ResolveRuntimeMetadata(nextProfile, providers.Options{})
|
||||
if err != nil {
|
||||
return m, "Mode\n" + err.Error()
|
||||
}
|
||||
if guarded, text, requested := m.requestCompactionBeforeModelSwitch(modelSwitchCompactionRequest{
|
||||
TargetModel: entry.ID,
|
||||
TargetProvider: string(metadata.ProviderKind),
|
||||
TargetContextWindow: modelContextWindow(entry.ID),
|
||||
}, "Mode"); requested {
|
||||
return guarded, text
|
||||
}
|
||||
nextProvider, err := m.newProvider(nextProfile)
|
||||
if err != nil {
|
||||
return m, "Mode\n" + err.Error()
|
||||
}
|
||||
|
||||
m.providerProfile = nextProfile
|
||||
m.provider = nextProvider
|
||||
m.providerName = displayValue(nextProfile.Name, string(metadata.ProviderKind))
|
||||
m.modelName = entry.ID
|
||||
|
||||
// Apply the mode's reasoning effort when the resolved model supports it;
|
||||
// otherwise fall back to auto (the model's effective default) so we never
|
||||
// store an unsupported preference.
|
||||
effortLine := "effort: auto"
|
||||
if mode.Effort != "" && reasoningEffortAllowed(entry.ReasoningEfforts, mode.Effort) {
|
||||
m.reasoningEffort = mode.Effort
|
||||
effortLine = "effort: " + string(mode.Effort)
|
||||
} else {
|
||||
m.reasoningEffort = ""
|
||||
if mode.Effort != "" {
|
||||
effortLine = "effort: auto (mode effort unsupported by model)"
|
||||
}
|
||||
}
|
||||
|
||||
turnsLine := fmt.Sprintf("max turns: %d (unchanged)", m.agentOptions.MaxTurns)
|
||||
if mode.MaxTurns > 0 {
|
||||
m.agentOptions.MaxTurns = mode.MaxTurns
|
||||
turnsLine = fmt.Sprintf("max turns: %d", mode.MaxTurns)
|
||||
}
|
||||
|
||||
lines := []string{"Mode"}
|
||||
if notice != "" {
|
||||
lines = append(lines, notice)
|
||||
}
|
||||
lines = append(lines,
|
||||
"Switched to mode "+mode.Name+" for this TUI session.",
|
||||
mode.Description,
|
||||
"model: "+entry.ID,
|
||||
"provider: "+displayValue(nextProfile.Name, string(metadata.ProviderKind)),
|
||||
effortLine,
|
||||
turnsLine,
|
||||
)
|
||||
return m, strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
|
||||
if modelSwitchCompactionGuard == nil {
|
||||
return m, "", false
|
||||
}
|
||||
request.CurrentModel = m.modelName
|
||||
request.CurrentProvider = m.providerName
|
||||
request.CurrentContextWindow = m.modelContextWindow(m.modelName)
|
||||
request.CurrentContextWindow = modelContextWindow(m.modelName)
|
||||
request.EstimatedTokens = estimateTranscriptTokens(m.transcript)
|
||||
request.SessionEventCount = len(m.sessionEvents)
|
||||
request.CompactRequests = m.compactRequests
|
||||
@@ -647,6 +743,29 @@ func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionR
|
||||
return m, strings.Join(lines, "\n"), true
|
||||
}
|
||||
|
||||
func (m model) modeListText() string {
|
||||
lines := make([]string, 0, len(modelregistry.Modes()))
|
||||
for _, mode := range modelregistry.Modes() {
|
||||
detail := fmt.Sprintf("model=%s", mode.Model)
|
||||
if mode.Effort != "" {
|
||||
detail += " effort=" + string(mode.Effort)
|
||||
}
|
||||
if mode.MaxTurns > 0 {
|
||||
detail += fmt.Sprintf(" turns=%d", mode.MaxTurns)
|
||||
}
|
||||
lines = append(lines, commandBullet(fmt.Sprintf("%s - %s (%s)", mode.Name, mode.Description, detail)))
|
||||
}
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Mode",
|
||||
Status: commandStatusOK,
|
||||
Sections: []commandSection{{
|
||||
Title: "Available",
|
||||
Lines: lines,
|
||||
}},
|
||||
Hints: []string{"use /mode <name> to switch model, effort, and turns"},
|
||||
})
|
||||
}
|
||||
|
||||
func apiKeyState(set bool) string {
|
||||
if set {
|
||||
return "set"
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
commandSandboxSetup
|
||||
commandProvider
|
||||
commandModel
|
||||
commandMode
|
||||
commandContext
|
||||
commandConfig
|
||||
commandDebug
|
||||
@@ -83,6 +84,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or switch the active model.",
|
||||
kind: commandModel,
|
||||
},
|
||||
{
|
||||
name: "/mode",
|
||||
usage: "/mode [name]",
|
||||
group: commandGroupModel,
|
||||
description: "List agent modes or switch model, effort, and turns at once.",
|
||||
kind: commandMode,
|
||||
},
|
||||
{
|
||||
name: "/plan",
|
||||
usage: "/plan",
|
||||
|
||||
+35
-51
@@ -286,7 +286,7 @@ type model struct {
|
||||
exitConfirmSeq int
|
||||
|
||||
// picker, when non-nil, is an open interactive selector overlay (/model,
|
||||
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
|
||||
// /effort, /mode with no argument). It captures ↑/↓/Enter/Esc and applies
|
||||
// the chosen value through the existing command handlers.
|
||||
picker *commandPicker
|
||||
providerWizard *providerWizardState
|
||||
@@ -727,16 +727,6 @@ func (m model) Init() tea.Cmd {
|
||||
if m.themeMode == themeAuto {
|
||||
cmds = append(cmds, tea.RequestBackgroundColor)
|
||||
}
|
||||
// Warm model discovery for the active provider in the background so the
|
||||
// context-usage gauge (used / total tokens + % fill) knows the active model's
|
||||
// window from launch — including proxy/custom models not in the curated
|
||||
// registry. Async: never blocks startup; if discovery is unavailable the gauge
|
||||
// just shows the used-token count until the window is otherwise learned.
|
||||
if descriptor, ok := m.activeProviderDescriptor(); ok {
|
||||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
if m.prService != nil && m.runtimeMessageSink != nil {
|
||||
service := m.prService
|
||||
sink := m.runtimeMessageSink
|
||||
@@ -2271,12 +2261,6 @@ func overlayViewportLines(lines []string, overlay string, width int) []string {
|
||||
if overlayWidth <= 0 {
|
||||
return lines
|
||||
}
|
||||
// Scrim: dim the whole transcript backdrop so a floating overlay (slash-command
|
||||
// palette, picker, wizard) clearly stands out instead of blending into the live
|
||||
// chat behind it. Header and composer are rendered separately and stay bright.
|
||||
for index := range lines {
|
||||
lines[index] = scrimViewportLine(lines[index], width)
|
||||
}
|
||||
start := maxInt(0, (len(lines)-len(overlayLines))/2)
|
||||
for offset, line := range overlayLines {
|
||||
target := start + offset
|
||||
@@ -2288,17 +2272,6 @@ func overlayViewportLines(lines []string, overlay string, width int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// scrimViewportLine dims one backdrop line: it strips the line's own colors and
|
||||
// re-renders the text faint, so the dimmed transcript recedes behind the overlay.
|
||||
// Blank lines are left untouched.
|
||||
func scrimViewportLine(line string, width int) string {
|
||||
plain := ansi.Strip(line)
|
||||
if strings.TrimSpace(plain) == "" {
|
||||
return line
|
||||
}
|
||||
return zeroTheme.faint.Render(plain)
|
||||
}
|
||||
|
||||
func normalizeOverlayBlock(lines []string, width int) (int, []string, int) {
|
||||
left := -1
|
||||
for _, line := range lines {
|
||||
@@ -2484,13 +2457,17 @@ func (m model) interimBlock(width int) string {
|
||||
}
|
||||
return strings.Join(blocks, "\n")
|
||||
}
|
||||
// Live streaming block: prose streams normally, but an open fenced code block
|
||||
// is buffered until its closing fence arrives so the code appears as one
|
||||
// highlighted block instead of recoloring token-by-token.
|
||||
lines := renderStreamingAssistantMarkdownText(text, assistantMeasure(width), width)
|
||||
// Live streaming block: render plain (no chroma) so the per-frame render loop
|
||||
// never re-tokenises the growing code block. Highlighting happens once the row
|
||||
// commits (renderAssistantRow, cached).
|
||||
lines := renderAssistantMarkdownText(text, assistantMeasure(width), width, false)
|
||||
for index, line := range lines {
|
||||
// styleStreamingLine fades plain prose but leaves already-highlighted
|
||||
// markdown/code lines alone, so live colors match the committed row.
|
||||
// styleStreamingLine applies the fade palette when fadeActive is
|
||||
// true and lineAges is populated; otherwise it falls through to
|
||||
// the same base-ink render styleAssistantMarkdownLine would have
|
||||
// applied. This means test fixtures that pre-populate
|
||||
// m.streamingText without going through the agentTextMsg
|
||||
// branch keep rendering identically to before.
|
||||
lines[index] = m.styleStreamingLine(line, index, len(lines))
|
||||
}
|
||||
lines = m.appendStreamingCursor(lines, width)
|
||||
@@ -3119,17 +3096,6 @@ func isToolCardKind(kind rowKind) bool {
|
||||
return kind == rowToolCall || kind == rowToolResult
|
||||
}
|
||||
|
||||
func needsSeparatorBeforeToolCard(previous rowKind, current rowKind) bool {
|
||||
if !isToolCardKind(current) {
|
||||
return false
|
||||
}
|
||||
return isToolCardKind(previous) || previous == rowAssistant || previous == rowUser
|
||||
}
|
||||
|
||||
func shouldRuleBeforeTurn(previous rowKind, current rowKind) bool {
|
||||
return current == rowAssistant && isToolCardKind(previous)
|
||||
}
|
||||
|
||||
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.pendingPermission == nil {
|
||||
return m, nil
|
||||
@@ -3184,7 +3150,8 @@ func permissionDecisionReason(decision permissionDecision) string {
|
||||
|
||||
// choosePicker applies the highlighted picker item through the same handler the
|
||||
// typed command would have used, appends the resulting status text, and closes
|
||||
// the picker. Behavior is identical to running "/model <id>" or "/effort <v>".
|
||||
// the picker. Behavior is identical to running "/model <id>", "/effort <v>",
|
||||
// or "/mode <name>".
|
||||
func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if m.modelPickerIsLoading() {
|
||||
return m, nil
|
||||
@@ -3215,6 +3182,10 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
text := ""
|
||||
m, text = m.handleEffortCommand(item.Value)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
case pickerMode:
|
||||
text := ""
|
||||
m, text = m.handleModeCommand(item.Value)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
case pickerSession:
|
||||
// item.Value is the chosen session id; handleResumeCommand hydrates it and
|
||||
// rebuilds the transcript (returning "" on success, an error note on failure).
|
||||
@@ -3356,6 +3327,21 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleModelCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandMode:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||||
return m, nil
|
||||
}
|
||||
if picker := m.newModePicker(); picker != nil {
|
||||
m.picker = picker
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleModeCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandContext:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
|
||||
return m, nil
|
||||
@@ -3792,11 +3778,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
if m.captureRunImages != nil {
|
||||
m.captureRunImages(images)
|
||||
}
|
||||
// Enable agent-loop compaction sized to the active model's context window.
|
||||
// AgentContextWindow applies a positive fallback for unknown/custom models so
|
||||
// compaction (proactive + reactive) is enabled for every model, not just
|
||||
// catalogued ones.
|
||||
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
|
||||
// Enable agent-loop compaction sized to the active model's context
|
||||
// window. An unknown/custom model resolves to 0, leaving compaction off.
|
||||
options.ContextWindow = modelContextWindow(m.modelName)
|
||||
|
||||
// Post-edit self-correction is on by default in the TUI but kept FAST: it
|
||||
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
)
|
||||
|
||||
func (m model) modelListText() string {
|
||||
@@ -55,49 +54,20 @@ func (m model) modelListText() string {
|
||||
// modelContextWindow resolves the active model's context window (max input
|
||||
// tokens) from the model registry to size agent-loop compaction. An unknown or
|
||||
// custom model resolves to 0, leaving compaction DISABLED as a safe default.
|
||||
// modelContextWindow resolves a model's exact context window (max input tokens) for
|
||||
// the context gauges: the curated registry value, else a value learned from live
|
||||
// provider discovery (so proxy/custom models like GPT-5 Codex, xAI, or Ollama-cloud
|
||||
// get an accurate window once /model has discovered them), else 0 (unknown). The
|
||||
// agent-run compaction path wraps this in modelregistry.AgentContextWindow to apply
|
||||
// a positive fallback so compaction is enabled even for unknown models.
|
||||
func (m model) modelContextWindow(modelName string) int {
|
||||
func modelContextWindow(modelName string) int {
|
||||
trimmed := strings.TrimSpace(modelName)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
if registry, err := modelregistry.DefaultRegistry(); err == nil {
|
||||
if entry, ok := registry.Resolve(trimmed); ok && entry.ContextLimits.ContextWindow > 0 {
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Live-discovered window, preferring the ACTIVE provider's models so a model ID
|
||||
// shared across providers resolves to the provider actually in use; only then
|
||||
// fall back to other providers that surfaced the same ID.
|
||||
if descriptor, ok := m.activeProviderDescriptor(); ok {
|
||||
if window := discoveredContextWindow(m.modelPickerLiveByProvider[descriptor.ID], trimmed); window > 0 {
|
||||
return window
|
||||
}
|
||||
entry, ok := registry.Resolve(trimmed)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
for _, models := range m.modelPickerLiveByProvider {
|
||||
if window := discoveredContextWindow(models, trimmed); window > 0 {
|
||||
return window
|
||||
}
|
||||
}
|
||||
// Unknown: report 0 so display gauges show no denominator. Compaction enablement
|
||||
// applies its own positive fallback via modelregistry.AgentContextWindow.
|
||||
return 0
|
||||
}
|
||||
|
||||
// discoveredContextWindow returns the context window of the model whose ID matches
|
||||
// name in a provider's live-discovered model list, or 0 when absent.
|
||||
func discoveredContextWindow(models []providermodeldiscovery.Model, name string) int {
|
||||
for _, dm := range models {
|
||||
if strings.EqualFold(strings.TrimSpace(dm.ID), name) && dm.ContextWindow > 0 {
|
||||
return dm.ContextWindow
|
||||
}
|
||||
}
|
||||
return 0
|
||||
return entry.ContextLimits.ContextWindow
|
||||
}
|
||||
|
||||
func activeModelID(registry modelregistry.Registry, modelName string) string {
|
||||
|
||||
+37
-197
@@ -13,7 +13,6 @@ import (
|
||||
"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"
|
||||
@@ -1298,10 +1297,7 @@ func TestAgentResponsePreservesToolResultMetadata(t *testing.T) {
|
||||
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, "@@")
|
||||
assertContains(t, next.renderRow(row, 80, buildRowContext(next.transcript)), "@@ -1 +1 @@")
|
||||
}
|
||||
|
||||
func TestAgentResponsePreservesPermissionMetadata(t *testing.T) {
|
||||
@@ -1553,7 +1549,7 @@ func TestAgentEventRenderingMappingCoversRuntimeContract(t *testing.T) {
|
||||
tool: "read_file",
|
||||
detail: "README.md",
|
||||
},
|
||||
wants: []string{"Read", "README.md"},
|
||||
wants: []string{"read_file", "README.md"},
|
||||
},
|
||||
zeroruntime.AgentEventToolResult: {
|
||||
row: transcriptRow{
|
||||
@@ -1569,7 +1565,7 @@ func TestAgentEventRenderingMappingCoversRuntimeContract(t *testing.T) {
|
||||
"+new",
|
||||
}, "\n"),
|
||||
},
|
||||
wants: []string{"Patched", "file.txt", "old", "new"},
|
||||
wants: []string{"apply_patch", "@@ -1 +1 @@"},
|
||||
},
|
||||
zeroruntime.AgentEventPlanUpdate: {
|
||||
row: transcriptRow{kind: rowSystem, text: "Plan updated\n- inspect: completed"},
|
||||
@@ -1930,9 +1926,11 @@ func TestSelectionHighlightUsesGutterShiftedCoordinate(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// bug: assistant/user/reasoning rows used to self-paint the selection in the
|
||||
// UNSHIFTED coordinate AND finalizeTranscriptBodyRow re-painted it in the
|
||||
// gutter-shifted coordinate, so a single selection lit up in two places (the
|
||||
// real one plus a copy shifted right by the reading gutter). 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
|
||||
@@ -1941,6 +1939,9 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
|
||||
kind: rowAssistant, text: "alpha beta gamma delta epsilon zeta", final: true,
|
||||
})
|
||||
width := m.chatColumnWidth()
|
||||
if transcriptGutter(width) <= 0 {
|
||||
t.Fatalf("need a positive reading gutter at width %d", width)
|
||||
}
|
||||
|
||||
findRow := func(mm model) (transcriptBodyItem, bool) {
|
||||
for _, it := range mm.transcriptBodyItems(width, "") {
|
||||
@@ -1966,8 +1967,8 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
|
||||
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.
|
||||
// Select the first 5 columns of that line (in the gutter-shifted coordinate the
|
||||
// mouse hands to anchor/cursor).
|
||||
m.transcriptSelection = transcriptSelectionState{
|
||||
active: true,
|
||||
anchor: transcriptSelectionPoint{bodyY: line.bodyY, x: line.textStart},
|
||||
@@ -2015,127 +2016,20 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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(), "")
|
||||
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(), "")
|
||||
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(), "")
|
||||
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 = "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(), "")
|
||||
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)
|
||||
// Wide terminal: a scaled side margin on each side, and the content fills the
|
||||
// rest (column minus both margins) instead of stopping at a fixed cap.
|
||||
if g := transcriptGutter(160); g != 8 {
|
||||
t.Fatalf("wide gutter = %d, want 8", g)
|
||||
}
|
||||
if cw := transcriptContentWidth(160); cw != 160 {
|
||||
t.Fatalf("wide contentWidth = %d, want full 160", cw)
|
||||
if cw := transcriptContentWidth(160); cw != 160-2*8 {
|
||||
t.Fatalf("wide contentWidth = %d, want %d (column minus both margins)", cw, 160-2*8)
|
||||
}
|
||||
// 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)
|
||||
// Very wide terminal: the margin is clamped so it never grows unbounded.
|
||||
if g := transcriptGutter(400); g != 12 {
|
||||
t.Fatalf("very wide gutter = %d, want clamp ceiling 12", g)
|
||||
}
|
||||
// Tiny terminal: full width so prose never collapses.
|
||||
// Tiny terminal: no margins, full width so prose never collapses.
|
||||
if g := transcriptGutter(40); g != 0 {
|
||||
t.Fatalf("tiny gutter = %d, want 0", g)
|
||||
}
|
||||
@@ -2144,7 +2038,7 @@ func TestTranscriptReadingColumnHelpers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
|
||||
func TestTranscriptBodyRowsUseReadingColumnAndAlignSelection(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
|
||||
m.width, m.height = 160, 30
|
||||
m.altScreen = true
|
||||
@@ -2154,8 +2048,8 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
gutter := transcriptGutter(width)
|
||||
if gutter != 0 {
|
||||
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
|
||||
if gutter <= 0 {
|
||||
t.Fatalf("expected a gutter at width %d", width)
|
||||
}
|
||||
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
@@ -2170,21 +2064,24 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
|
||||
}
|
||||
rendered := row.render(0)
|
||||
|
||||
maxLine := transcriptContentWidth(width) + gutter // content plus any left indent
|
||||
sawContent := false
|
||||
maxLine := transcriptContentWidth(width) + gutter // content plus the left indent
|
||||
wroteIndented := 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)
|
||||
t.Fatalf("body line width %d exceeds reading column %d: %q", w, maxLine, line)
|
||||
}
|
||||
if strings.TrimSpace(line) != "" {
|
||||
sawContent = true
|
||||
if !strings.HasPrefix(line, strings.Repeat(" ", gutter)) {
|
||||
t.Fatalf("non-blank body line is not gutter-indented: %q", line)
|
||||
}
|
||||
wroteIndented = true
|
||||
}
|
||||
}
|
||||
if !sawContent {
|
||||
t.Fatal("expected at least one content line")
|
||||
if !wroteIndented {
|
||||
t.Fatal("expected at least one indented content line")
|
||||
}
|
||||
// Selection alignment: text-carrying selectable lines should never start before
|
||||
// the rendered text coordinate.
|
||||
// Selection alignment: text-carrying selectable lines start at/after the gutter
|
||||
// so click-to-select and the highlight track the indented glyphs.
|
||||
for _, sl := range rendered.selectable {
|
||||
if sl.text != "" && sl.textStart < gutter {
|
||||
t.Fatalf("selectable textStart %d < gutter %d — selection would misalign", sl.textStart, gutter)
|
||||
@@ -2419,60 +2316,3 @@ func TestModelNotifierFocusAndCompletion(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -22,6 +22,7 @@ type pickerKind int
|
||||
const (
|
||||
pickerModel pickerKind = iota
|
||||
pickerEffort
|
||||
pickerMode
|
||||
pickerSession
|
||||
)
|
||||
|
||||
@@ -44,8 +45,8 @@ type pickerItem struct {
|
||||
Favorite bool
|
||||
}
|
||||
|
||||
// commandPicker is a generic single-select overlay reused by /model and /effort
|
||||
// (invoked with no argument). It owns only list state; the chosen
|
||||
// commandPicker is a generic single-select overlay reused by /model, /effort,
|
||||
// and /mode (invoked with no argument). It owns only list state; the chosen
|
||||
// value is applied through the existing command handlers.
|
||||
type commandPicker struct {
|
||||
kind pickerKind
|
||||
@@ -726,3 +727,21 @@ func (m model) newEffortPicker() *commandPicker {
|
||||
}
|
||||
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
|
||||
}
|
||||
|
||||
// newModePicker lists the agent modes, preselecting none (modes don't carry a
|
||||
// single "active" identity).
|
||||
func (m model) newModePicker() *commandPicker {
|
||||
modes := modelregistry.Modes()
|
||||
if len(modes) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]pickerItem, 0, len(modes))
|
||||
for _, mode := range modes {
|
||||
label := mode.Name
|
||||
if mode.Description != "" {
|
||||
label += " — " + mode.Description
|
||||
}
|
||||
items = append(items, pickerItem{Label: label, Value: mode.Name})
|
||||
}
|
||||
return &commandPicker{kind: pickerMode, title: "select mode", items: items, selected: 0}
|
||||
}
|
||||
@@ -659,6 +659,7 @@ func TestPickersRefuseToOpenWhileRunPending(t *testing.T) {
|
||||
command string
|
||||
}{
|
||||
{name: "model", command: "/model"},
|
||||
{name: "mode", command: "/mode"},
|
||||
{name: "effort", command: "/effort"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -840,39 +841,3 @@ func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) {
|
||||
t.Fatalf("auto selection should clear reasoning effort, got %q", m.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelContextWindowResolution(t *testing.T) {
|
||||
m := model{}
|
||||
// Registered model → the exact registry window (compare to the registry value so
|
||||
// this doesn't break on a benign catalog update).
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatalf("load default registry: %v", err)
|
||||
}
|
||||
entry, ok := registry.Resolve("gpt-4.1")
|
||||
if !ok || entry.ContextLimits.ContextWindow <= 0 {
|
||||
t.Fatalf("gpt-4.1 should resolve a positive window in the registry")
|
||||
}
|
||||
if got := m.modelContextWindow("gpt-4.1"); got != entry.ContextLimits.ContextWindow {
|
||||
t.Fatalf("registered model window = %d, want %d", got, entry.ContextLimits.ContextWindow)
|
||||
}
|
||||
// Unknown model → 0 (display shows no denominator); compaction applies its own
|
||||
// fallback via AgentContextWindow.
|
||||
if got := m.modelContextWindow("totally-unknown-proxy-model"); got != 0 {
|
||||
t.Fatalf("unknown model should resolve 0 for display, got %d", got)
|
||||
}
|
||||
if got := modelregistry.AgentContextWindow(m.modelContextWindow("totally-unknown-proxy-model")); got != modelregistry.FallbackContextWindow {
|
||||
t.Fatalf("compaction window for unknown model should be %d, got %d", modelregistry.FallbackContextWindow, got)
|
||||
}
|
||||
// Live-discovered window wins for an unregistered model.
|
||||
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
|
||||
"xai": {{ID: "grok-4", ContextWindow: 256000}},
|
||||
}
|
||||
if got := m.modelContextWindow("grok-4"); got != 256000 {
|
||||
t.Fatalf("discovered window should win for unregistered model, got %d", got)
|
||||
}
|
||||
// Empty name → 0 (no window).
|
||||
if got := m.modelContextWindow(""); got != 0 {
|
||||
t.Fatalf("empty model name should yield 0, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,6 @@ func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, op
|
||||
appendRenderCacheField(&b, strconv.Itoa(width))
|
||||
appendRenderCacheField(&b, strconv.FormatBool(flush))
|
||||
appendRenderCacheField(&b, strconv.Itoa(opts.bodyCap))
|
||||
appendRenderCacheField(&b, strconv.FormatBool(opts.expanded))
|
||||
appendRenderCacheField(&b, opts.cwd)
|
||||
appendRenderCacheField(&b, strconv.Itoa(int(row.kind)))
|
||||
appendRenderCacheField(&b, row.id)
|
||||
|
||||
+185
-635
File diff suppressed because it is too large.
Load diff
@@ -47,8 +47,15 @@ func TestStyleAssistantMarkdownLinePassesAnsiVerbatim(t *testing.T) {
|
||||
in := "\x1b[31mtoken\x1b[0m" // an already-colored code token
|
||||
out := styleAssistantMarkdownLine(in, lipgloss.NewStyle().Bold(true))
|
||||
|
||||
if out != in {
|
||||
t.Fatalf("already-styled input must pass through verbatim:\nin: %q\nout: %q", in, out)
|
||||
// With the fix the input escape leads the output verbatim. Without it, the
|
||||
// whole line is re-wrapped so the output would START with base's own "\x1b[1m"
|
||||
// and the input escape would be nested inside it.
|
||||
if !strings.HasPrefix(out, "\x1b[31m") {
|
||||
t.Fatalf("already-styled input was re-wrapped instead of passed verbatim: %q", out)
|
||||
}
|
||||
// The input's reset must also survive, not be re-encoded as runes.
|
||||
if !strings.Contains(out, "\x1b[0m") {
|
||||
t.Fatalf("input reset escape not preserved: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,40 +326,6 @@ func TestMarkdownInlineKeepsCodingLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHorizontalRulesRenderAsDividers(t *testing.T) {
|
||||
lines := renderAssistantMarkdownText(strings.Join([]string{
|
||||
"Before",
|
||||
"---",
|
||||
"After",
|
||||
"***",
|
||||
"Done",
|
||||
}, "\n"), 40, 40, true)
|
||||
got := plainRender(t, strings.Join(lines, "\n"))
|
||||
if strings.Contains(got, "\n---\n") || strings.Contains(got, "\n***\n") {
|
||||
t.Fatalf("horizontal rules should not leak raw markdown markers:\n%s", got)
|
||||
}
|
||||
if strings.Count(got, strings.Repeat("─", 40)) != 2 {
|
||||
t.Fatalf("horizontal rules should render as divider lines, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownHorizontalRulesDoNotEatListsOrDiffHeaders(t *testing.T) {
|
||||
lines := renderAssistantMarkdownText(strings.Join([]string{
|
||||
"- item",
|
||||
"--- a/file.go",
|
||||
"+++ b/file.go",
|
||||
}, "\n"), 60, 60, false)
|
||||
got := plainRender(t, strings.Join(lines, "\n"))
|
||||
for _, want := range []string{"- item", "--- a/file.go", "+++ b/file.go"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("markdown rule parser should leave %q alone, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, strings.Repeat("─", 16)) {
|
||||
t.Fatalf("list/diff-looking text should not render as a divider:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownTableHeaderRendersBold(t *testing.T) {
|
||||
lines := renderAssistantMarkdownText(strings.Join([]string{
|
||||
"| Feature | System A | System B |",
|
||||
@@ -761,38 +734,44 @@ func TestDoneLineOnlyOnLongTurns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterimAssistantRowRendersAsPlainProse(t *testing.T) {
|
||||
func TestInterimAssistantRowRendersAsBulletedProse(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
row := transcriptRow{kind: rowAssistant, text: "No provider configured."}
|
||||
got := plainRender(t, m.renderRow(row, 96, buildRowContext(nil)))
|
||||
if strings.Contains(got, "│") || strings.Contains(got, "●") {
|
||||
t.Fatalf("non-final assistant row = %q, must not carry activity chrome", got)
|
||||
if strings.Contains(got, "│") {
|
||||
t.Fatalf("non-final assistant row = %q, must not carry a card rail", got)
|
||||
}
|
||||
// Narration carries a leading "●" bullet so each story beat stands out.
|
||||
if !strings.Contains(got, "●") {
|
||||
t.Fatalf("non-final assistant row should carry the narration bullet, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "No provider configured.") {
|
||||
t.Fatalf("non-final assistant row should carry the body text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNarrationSelectionGeometry locks narration column math: interim prose and
|
||||
// final answers both start at textStart 0, no line overflows the width, and copy
|
||||
// metadata stays free of display-only chrome.
|
||||
func TestNarrationSelectionGeometry(t *testing.T) {
|
||||
// TestNarrationBulletSelectionGeometry locks the bullet's column math: the
|
||||
// narration block wraps 2 cols narrower, every selectable meta starts at the
|
||||
// gutter (so copy/selection stays aligned and copied text is gutter-free), no
|
||||
// line overflows the width, and the final answer keeps no bullet / textStart 0.
|
||||
func TestNarrationBulletSelectionGeometry(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
gutter := lipgloss.Width(narrationPrefix)
|
||||
narr := transcriptRow{kind: rowAssistant, text: "Now the stylesheet. " + strings.Repeat("word ", 60)}
|
||||
rendered, metas := m.renderSelectableAssistantRow(0, narr, 90, 0)
|
||||
|
||||
if strings.Contains(plainRender(t, rendered), "●") {
|
||||
t.Fatalf("narration display should not carry the old bullet:\n%s", rendered)
|
||||
if !strings.Contains(plainRender(t, rendered), "●") {
|
||||
t.Fatalf("narration display should carry the bullet:\n%s", rendered)
|
||||
}
|
||||
if len(metas) < 2 {
|
||||
t.Fatalf("expected the long narration to wrap to multiple lines, got %d", len(metas))
|
||||
}
|
||||
for i, meta := range metas {
|
||||
if meta.textStart != 0 {
|
||||
t.Errorf("meta %d textStart = %d, want 0", i, meta.textStart)
|
||||
if meta.textStart != gutter {
|
||||
t.Errorf("meta %d textStart = %d, want %d (gutter)", i, meta.textStart, gutter)
|
||||
}
|
||||
if strings.Contains(meta.text, "●") {
|
||||
t.Errorf("meta %d copy text should be chrome-free, got %q", i, meta.text)
|
||||
t.Errorf("meta %d copy text should be gutter-free, got %q", i, meta.text)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(rendered, "\n") {
|
||||
@@ -841,11 +820,13 @@ func TestRunningToolCardShowsHeadAndSpinnerSlot(t *testing.T) {
|
||||
m.pending = true
|
||||
row := transcriptRow{kind: rowToolCall, id: "call_1", tool: "grep", detail: "internal/cli"}
|
||||
got := plainRender(t, m.renderRow(row, 80, buildRowContext(nil)))
|
||||
if !strings.Contains(got, "Searching") || !strings.Contains(got, "internal/cli") {
|
||||
t.Fatalf("running card = %q, want action label and target in head", got)
|
||||
if !strings.Contains(got, "grep") || !strings.Contains(got, "internal/cli") {
|
||||
t.Fatalf("running card = %q, want tool name and target in head", got)
|
||||
}
|
||||
if strings.HasPrefix(got, "│ ") || strings.Contains(got, "\n│ ") {
|
||||
t.Fatalf("running card = %q, must not carry the old left rail", got)
|
||||
// Tool cards render as a left-rule card (status-tinted "│ " rail, no box),
|
||||
// matching specialist cards and the reference TUIs.
|
||||
if !strings.HasPrefix(got, "│ ") {
|
||||
t.Fatalf("running card = %q, want a left-rule card", got)
|
||||
}
|
||||
if strings.ContainsAny(got, "╭╮╰╯") {
|
||||
t.Fatalf("running card = %q, must not carry box-border corners", got)
|
||||
@@ -869,32 +850,20 @@ func TestResolvedToolCallCollapsesIntoResultCard(t *testing.T) {
|
||||
func TestDiffCardBodyRendersCountsNumbersAndCap(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
diff := strings.Join([]string{
|
||||
"--- internal/cli/root.go",
|
||||
"+++ internal/cli/root.go",
|
||||
"--- /dev/null",
|
||||
"+++ b/internal/cli/root.go",
|
||||
"@@ -0,0 +1,3 @@",
|
||||
"+package cli",
|
||||
"+",
|
||||
"+++var Version = \"dev\"",
|
||||
"+var Version = \"dev\"",
|
||||
}, "\n")
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "write_file", status: tools.StatusOK, detail: diff}
|
||||
styled := m.renderRow(row, 80, buildRowContext(nil))
|
||||
for _, want := range []string{zeroTheme.diffAdd.Render("+3"), zeroTheme.diffDel.Render("-0")} {
|
||||
if !strings.Contains(styled, want) {
|
||||
t.Fatalf("diff card count tag should color additions/deletions, missing styled %q in:\n%s", want, styled)
|
||||
}
|
||||
}
|
||||
got := plainRender(t, styled)
|
||||
for _, want := range []string{"Added", "internal/cli/root.go", "(+3 -0)", "package cli", "++var Version = \"dev\"", " 1"} {
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "edit_file", status: tools.StatusOK, detail: diff}
|
||||
got := plainRender(t, m.renderRow(row, 80, buildRowContext(nil)))
|
||||
for _, want := range []string{"internal/cli/root.go", "NEW FILE", "+3", "package cli", " 1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("diff card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "write_file") || strings.Contains(got, "NEW FILE") {
|
||||
t.Fatalf("diff card = %q, must not expose raw tool name or duplicate new-file tag", got)
|
||||
}
|
||||
if strings.Contains(got, "@@") {
|
||||
t.Fatalf("diff card should hide raw hunk metadata, got %q", got)
|
||||
}
|
||||
|
||||
// The 16-line cap keeps long diffs bounded.
|
||||
long := []string{"+++ b/big.go", "@@ -0,0 +1,40 @@"}
|
||||
@@ -908,58 +877,18 @@ func TestDiffCardBodyRendersCountsNumbersAndCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditedDiffCardHidesHunkHeader(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
diff := strings.Join([]string{
|
||||
"--- a/time_test.py",
|
||||
"+++ b/time_test.py",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" from datetime import datetime",
|
||||
"",
|
||||
"-print(datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"))",
|
||||
"+print(f\"Current system time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\")",
|
||||
}, "\n")
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "edit_file", status: tools.StatusOK, detail: diff}
|
||||
got := plainRender(t, m.renderRow(row, 96, buildRowContext(nil)))
|
||||
for _, want := range []string{"Edited", "time_test.py", "(+1 -1)", "from datetime import datetime", " 3 −", " 3 +"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("edited diff card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "@@") {
|
||||
t.Fatalf("edited diff card should hide raw hunk metadata, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCardBodyShowsExploredSummary(t *testing.T) {
|
||||
func TestReadCardBodyShowsGutterAndRange(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
// Mirrors the real read_file output shape: "<right-aligned N> | <text>".
|
||||
detail := "File: internal/agent/loop.go\n\n 12 | func Run() {\n 13 | }\n"
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "read_file", status: tools.StatusOK, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "read_file", detail: "internal/agent/loop.go"}})
|
||||
got := plainRender(t, m.renderRow(row, 80, rc))
|
||||
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go"} {
|
||||
for _, want := range []string{"read_file", "internal/agent/loop.go", "L12–L13", "func Run() {"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("read card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "▸ details") {
|
||||
t.Fatalf("read card = %q, missing expand affordance", got)
|
||||
}
|
||||
if strings.Contains(got, "L12") || strings.Contains(got, "func Run()") || strings.Contains(got, " 12 ") {
|
||||
t.Fatalf("read card = %q, must not dump read_file body into the transcript", got)
|
||||
}
|
||||
if strings.Contains(got, "read_file") {
|
||||
t.Fatalf("read card = %q, must not expose raw tool name", got)
|
||||
}
|
||||
|
||||
row.expanded = true
|
||||
expanded := plainRender(t, m.renderRow(row, 80, rc))
|
||||
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go", "func Run()", "13 |"} {
|
||||
if !strings.Contains(expanded, want) {
|
||||
t.Fatalf("expanded read card = %q, missing %q", expanded, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCardBodyShowsCommandOutputAndExit(t *testing.T) {
|
||||
@@ -968,46 +897,14 @@ func TestBashCardBodyShowsCommandOutputAndExit(t *testing.T) {
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "bash", status: tools.StatusError, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "bash", detail: "go build ./..."}})
|
||||
got := plainRender(t, m.renderRow(row, 80, rc))
|
||||
for _, want := range []string{"Ran", "go build ./...", "ok build", "warning: slow", "exit 1"} {
|
||||
for _, want := range []string{"❯ go build ./...", "ok build", "warning: slow", "exit 1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("bash card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "❯") || strings.Contains(got, "stdout:") || strings.Contains(got, "exit_code:") {
|
||||
if strings.Contains(got, "stdout:") || strings.Contains(got, "exit_code:") {
|
||||
t.Fatalf("bash card = %q, must restyle section markers", got)
|
||||
}
|
||||
if strings.Contains(got, "├ ok build") || strings.Contains(got, "└ warning: slow") {
|
||||
t.Fatalf("multi-line command output should render as a simple output block, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "│ ok build") || !strings.Contains(got, "│ warning: slow") {
|
||||
t.Fatalf("multi-line command output should stay grouped under the command, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashCardBodyKeepsSingleLineOutputCompact(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
detail := "stdout:\nJS syntax: OK\nexit_code: 0"
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "bash", status: tools.StatusOK, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "bash", detail: `node -e "console.log('ok')"`}})
|
||||
got := plainRender(t, m.renderRow(row, 80, rc))
|
||||
if !strings.Contains(got, "└ JS syntax: OK") {
|
||||
t.Fatalf("single-line command output should keep compact child marker, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCardHeadCollapsesMultilineCommand(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
command := "node -e \"\nconst fs = require('fs')\nconsole.log('ok')\n\""
|
||||
detail := "stdout:\nJS syntax: OK\nexit_code: 0"
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "bash", status: tools.StatusOK, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "bash", detail: command}})
|
||||
got := plainRender(t, m.renderRow(row, 120, rc))
|
||||
if strings.Contains(got, "\nconst fs") || strings.Contains(got, "\n\"") {
|
||||
t.Fatalf("multi-line command should not break the card header, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "Ran node -e") || !strings.Contains(got, "const fs = require") {
|
||||
t.Fatalf("multi-line command summary should stay visible on one line, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandCardBodyShowsSessionAndExit(t *testing.T) {
|
||||
@@ -1016,24 +913,18 @@ func TestExecCommandCardBodyShowsSessionAndExit(t *testing.T) {
|
||||
running := transcriptRow{kind: rowToolResult, id: "call_1", tool: "exec_command", status: tools.StatusOK, detail: runningDetail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "exec_command", detail: "python3 -m http.server 8000"}})
|
||||
got := plainRender(t, m.renderRow(running, 90, rc))
|
||||
for _, want := range []string{"Ran", "python3 -m http.server 8000", "Serving HTTP", "session 1000"} {
|
||||
for _, want := range []string{"❯ python3 -m http.server 8000", "Serving HTTP", "session 1000"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("exec_command card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "❯") || strings.Contains(got, "Use write_stdin") || strings.Contains(got, "session_id:") {
|
||||
if strings.Contains(got, "Use write_stdin") || strings.Contains(got, "session_id:") {
|
||||
t.Fatalf("exec_command card = %q, must restyle session markers", got)
|
||||
}
|
||||
|
||||
exited := transcriptRow{kind: rowToolResult, id: "call_2", tool: "write_stdin", status: tools.StatusOK, detail: "output:\ndone\nexit_code: 0"}
|
||||
got = plainRender(t, m.renderRow(exited, 90, buildRowContext(nil)))
|
||||
if !strings.Contains(got, "done") {
|
||||
t.Fatalf("write_stdin card = %q, missing output", got)
|
||||
}
|
||||
if strings.Contains(got, "exit 0") {
|
||||
t.Fatalf("write_stdin card = %q, must not show successful exit", got)
|
||||
}
|
||||
for _, want := range []string{"done"} {
|
||||
for _, want := range []string{"done", "exit 0"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("write_stdin card = %q, missing %q", got, want)
|
||||
}
|
||||
@@ -1046,61 +937,6 @@ func TestExecCommandCardBodyShowsSessionAndExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalControlCardsUseFriendlyCompactLabels(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
|
||||
open := transcriptRow{kind: rowToolResult, id: "call_1", tool: "browser_open", status: tools.StatusOK, detail: "✓ ZERO - terminal agent\nhttp://localhost:8080/"}
|
||||
openRC := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "browser_open", detail: "http://localhost:8080"}})
|
||||
got := plainRender(t, m.renderRow(open, 100, openRC))
|
||||
for _, want := range []string{"Opened", "http://localhost:8080", "ZERO - terminal agent"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("browser_open card missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "browser_open") || strings.Contains(got, "http://localhost:8080/\n") {
|
||||
t.Fatalf("browser_open card should not expose raw tool names or repeat the URL body:\n%s", got)
|
||||
}
|
||||
|
||||
snapshotDetail := strings.Repeat("button \"Item\"\n", 31)
|
||||
snapshot := transcriptRow{kind: rowToolResult, id: "call_2", tool: "browser_snapshot", status: tools.StatusOK, detail: snapshotDetail}
|
||||
got = plainRender(t, m.renderRow(snapshot, 100, buildRowContext(nil)))
|
||||
for _, want := range []string{"Captured", "snapshot"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("browser_snapshot card missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "browser_snapshot") || strings.Contains(got, "click to expand") || strings.Contains(got, "button \"Item\"") {
|
||||
t.Fatalf("browser_snapshot card should stay compact and friendly:\n%s", got)
|
||||
}
|
||||
|
||||
artifact := transcriptRow{kind: rowToolResult, id: "call_3", tool: "capture_artifact", status: tools.StatusOK, detail: "Artifact captured: /tmp/zero-artifacts/page.png\n\nhelper wrote screenshot"}
|
||||
got = plainRender(t, m.renderRow(artifact, 100, buildRowContext(nil)))
|
||||
for _, want := range []string{"Captured", "page.png"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("capture_artifact card missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "capture_artifact") || strings.Contains(got, "Artifact captured:") || strings.Contains(got, "helper wrote screenshot") {
|
||||
t.Fatalf("capture_artifact card should summarize the artifact instead of dumping helper output:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionRowsUseFriendlyLocalControlLabels(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
row := transcriptRow{kind: rowPermission, id: "call_1", permission: &agent.PermissionEvent{
|
||||
ToolCallID: "call_1",
|
||||
ToolName: "browser_open",
|
||||
Action: agent.PermissionActionPrompt,
|
||||
}}
|
||||
got := plainRender(t, m.renderRow(row, 100, buildRowContext(nil)))
|
||||
if !strings.Contains(got, "Open browser") {
|
||||
t.Fatalf("permission row should use local-control display name:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "browser_open") {
|
||||
t.Fatalf("permission row should not expose raw local-control tool name:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallSummaryDescribesExecSessions(t *testing.T) {
|
||||
cases := []struct {
|
||||
event streamjson.Event
|
||||
@@ -1110,10 +946,6 @@ func TestToolCallSummaryDescribesExecSessions(t *testing.T) {
|
||||
event: streamjson.Event{Name: "exec_command", Args: map[string]any{"cmd": "python3 -m http.server 8000"}},
|
||||
want: "python3 -m http.server 8000",
|
||||
},
|
||||
{
|
||||
event: streamjson.Event{Name: "exec_command", Args: map[string]any{"cmd": "node -e \"\nconsole.log('ok')\n\""}},
|
||||
want: `node -e " console.log('ok') "`,
|
||||
},
|
||||
{
|
||||
event: streamjson.Event{Name: "write_stdin", Args: map[string]any{"session_id": 1000}},
|
||||
want: "poll session 1000",
|
||||
@@ -1134,20 +966,16 @@ func TestToolCallSummaryDescribesExecSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrepCardBodyShowsExploredSummary(t *testing.T) {
|
||||
func TestGrepCardBodyShowsLocationsAndMatchCount(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
detail := "internal/cli/root.go:41: fs := flag.NewFlagSet\ninternal/cli/app.go:12: flag.Parse()"
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "grep", status: tools.StatusOK, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "grep", detail: "internal/cli", arg: "flag"}})
|
||||
got := plainRender(t, m.renderRow(row, 90, rc))
|
||||
for _, want := range []string{"Explored", "└ Search", "flag"} {
|
||||
got := plainRender(t, m.renderRow(row, 90, buildRowContext(nil)))
|
||||
for _, want := range []string{"internal/cli/root.go:41", "2 matches"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("grep card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "internal/cli/root.go:41") || strings.Contains(got, "2 matches") {
|
||||
t.Fatalf("grep card = %q, must not expose raw search matches", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolBodyRendererRegistryCoversCoreCards(t *testing.T) {
|
||||
@@ -1498,7 +1326,7 @@ func TestToolCardLinesAllSameWidth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultCardRendersInlineWithoutRail(t *testing.T) {
|
||||
func TestToolResultCardRendersAsLeftRule(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
@@ -1509,18 +1337,19 @@ func TestToolResultCardRendersInlineWithoutRail(t *testing.T) {
|
||||
}
|
||||
card := plainRender(t, m.renderRow(row, 80, buildRowContext(nil)))
|
||||
lines := strings.Split(card, "\n")
|
||||
// No line carries the old left rail or box borders.
|
||||
// Every line carries the left rail; none carries a box corner or a right
|
||||
// border — the unified left-rule card shape (matches specialist cards).
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(line, "│ ") {
|
||||
t.Fatalf("line %d = %q, must not carry left-rule prefix", i, line)
|
||||
if !strings.HasPrefix(line, "│ ") {
|
||||
t.Fatalf("line %d = %q, want left-rule prefix", i, line)
|
||||
}
|
||||
if strings.ContainsAny(line, "╭╮╰╯") || strings.HasSuffix(line, "│") {
|
||||
t.Fatalf("line %d = %q, must not carry box borders", i, line)
|
||||
}
|
||||
}
|
||||
// The status glyph still sits on the head line.
|
||||
if !strings.Contains(lines[0], "•") {
|
||||
t.Fatalf("head line = %q, want the status glyph on the head line", lines[0])
|
||||
// The status glyph sits on the head line (first line), right-aligned.
|
||||
if !strings.Contains(lines[0], "✓") {
|
||||
t.Fatalf("head line = %q, want the status glyph on the rule line", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,16 +1421,8 @@ func TestGrepCardHeadShowsTargetAndPatternColumns(t *testing.T) {
|
||||
}
|
||||
rc := buildRowContext(rows)
|
||||
got := plainRender(t, m.renderRow(rows[1], 110, rc))
|
||||
for _, want := range []string{"Explored", "└ Search", `flag\.|RegisterFlag`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("grep card = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "internal/cli/root.go:41") {
|
||||
t.Fatalf("grep card = %q, must not dump search matches", got)
|
||||
}
|
||||
if exploreTargetLooksLikePath("grep", `flag\.|RegisterFlag`) {
|
||||
t.Fatal("grep regex arguments must not be treated as paths")
|
||||
if !strings.Contains(got, "internal/cli") || !strings.Contains(got, `flag\.|RegisterFlag`) {
|
||||
t.Fatalf("grep card head = %q, want separate target and pattern columns", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1643,9 +1464,6 @@ func TestPermissionPromptCollapsesAfterDecision(t *testing.T) {
|
||||
if !rc.skip(prompt) {
|
||||
t.Fatal("a decided prompt row must collapse away")
|
||||
}
|
||||
if rc.skip(allowed) {
|
||||
t.Fatal("manual allow rows should remain as audit lines")
|
||||
}
|
||||
if got := plainRender(t, m.renderRow(allowed, 80, rc)); !strings.Contains(got, "allowed once · bash") {
|
||||
t.Fatalf("manual allow = %q, want allowed once · bash", got)
|
||||
}
|
||||
@@ -1657,9 +1475,6 @@ func TestPermissionPromptCollapsesAfterDecision(t *testing.T) {
|
||||
{kind: rowPermission, id: "call_session", permission: &agent.PermissionEvent{ToolCallID: "call_session", ToolName: "bash", Action: agent.PermissionActionPrompt}},
|
||||
session,
|
||||
})
|
||||
if rcSession.skip(session) {
|
||||
t.Fatal("session allow rows should remain as audit lines")
|
||||
}
|
||||
if got := plainRender(t, m.renderRow(session, 80, rcSession)); !strings.Contains(got, "allowed for session · bash") {
|
||||
t.Fatalf("session allow = %q, want allowed for session · bash", got)
|
||||
}
|
||||
@@ -1671,9 +1486,6 @@ func TestPermissionPromptCollapsesAfterDecision(t *testing.T) {
|
||||
ToolCallID: "call_2", ToolName: "bash", Action: agent.PermissionActionPrompt,
|
||||
}}
|
||||
rcTwo := buildRowContext([]transcriptRow{promptTwo, always})
|
||||
if rcTwo.skip(always) {
|
||||
t.Fatal("always allow rows should remain as audit lines")
|
||||
}
|
||||
if got := plainRender(t, m.renderRow(always, 80, rcTwo)); !strings.Contains(got, "always · bash") {
|
||||
t.Fatalf("always allow = %q, want always · bash", got)
|
||||
}
|
||||
@@ -1692,7 +1504,7 @@ func TestUnpromptedAllowRowsCollapseIntoAutoTag(t *testing.T) {
|
||||
}}
|
||||
rc := buildRowContext([]transcriptRow{allow})
|
||||
if !rc.skip(allow) {
|
||||
t.Fatal("an unprompted (auto) allow row must collapse")
|
||||
t.Fatal("an unprompted (auto) allow row must collapse — the tool card carries [auto]")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,8 +1611,7 @@ func TestPermissionCollapseIsRunScoped(t *testing.T) {
|
||||
t.Fatal("run 2's unprompted allow should fold into its tool card's [auto] tag")
|
||||
}
|
||||
|
||||
// Same-run prompt+decision collapses the prompt but keeps the manual decision
|
||||
// as an audit line.
|
||||
// Same-run prompt+decision still collapses as before.
|
||||
sameRunPrompt := transcriptRow{kind: rowPermission, id: "gemini_tool_1", runID: 3, permission: &agent.PermissionEvent{
|
||||
ToolCallID: "gemini_tool_1", ToolName: "bash", Action: agent.PermissionActionPrompt,
|
||||
}}
|
||||
|
||||
@@ -5,15 +5,16 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The status glyph must LEAD the tool-card head row (dot/spinner first), not be
|
||||
// right-aligned to the far edge — so state reads at a glance.
|
||||
// The status glyph must LEAD the tool-card head row (✓/✗/spinner in the first
|
||||
// cell after the rail), not be right-aligned to the far edge — so state reads at
|
||||
// a glance. Guards the "easier-to-scan results" change.
|
||||
func TestToolCardGlyphLeadsHeadRow(t *testing.T) {
|
||||
head := zeroTheme.toolName.Render("bash")
|
||||
glyph := zeroTheme.green.Render("•")
|
||||
glyph := zeroTheme.green.Render("✓")
|
||||
out := toolCard(head, glyph, nil, "", zeroTheme.line, 60)
|
||||
first := strings.SplitN(out, "\n", 2)[0]
|
||||
|
||||
glyphIdx := strings.Index(first, "•")
|
||||
glyphIdx := strings.Index(first, "✓")
|
||||
nameIdx := strings.Index(first, "bash")
|
||||
if glyphIdx < 0 {
|
||||
t.Fatalf("head row missing status glyph: %q", first)
|
||||
|
||||
@@ -519,7 +519,7 @@ func (m model) compactRequest() CompactRequest {
|
||||
return CompactRequest{
|
||||
SessionID: strings.TrimSpace(m.activeSession.SessionID),
|
||||
ModelName: strings.TrimSpace(m.modelName),
|
||||
ContextWindow: m.modelContextWindow(m.modelName),
|
||||
ContextWindow: modelContextWindow(m.modelName),
|
||||
SessionEventCount: len(m.sessionEvents),
|
||||
EstimatedTokens: estimateTranscriptTokens(m.transcript),
|
||||
VisibleTranscriptRows: len(m.transcript),
|
||||
|
||||
@@ -263,8 +263,8 @@ func TestCompactCommandCallsInjectedCompactorAndReportsResult(t *testing.T) {
|
||||
if request.ModelName != "gpt-4.1" {
|
||||
t.Fatalf("expected request model gpt-4.1, got %q", request.ModelName)
|
||||
}
|
||||
if request.ContextWindow != next.modelContextWindow("gpt-4.1") {
|
||||
t.Fatalf("expected request context window %d, got %d", next.modelContextWindow("gpt-4.1"), request.ContextWindow)
|
||||
if request.ContextWindow != modelContextWindow("gpt-4.1") {
|
||||
t.Fatalf("expected request context window %d, got %d", modelContextWindow("gpt-4.1"), request.ContextWindow)
|
||||
}
|
||||
if request.EstimatedTokens <= 0 || request.VisibleTranscriptRows != len(m.transcript) {
|
||||
t.Fatalf("expected request estimate and transcript count, got %#v", request)
|
||||
@@ -951,3 +951,106 @@ func anthropicTestProfile(modelID string) config.ProviderProfile {
|
||||
Model: modelID,
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeCommandListsPresets(t *testing.T) {
|
||||
// "/mode list" prints the preset list (no picker for an explicit subcommand).
|
||||
m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"})
|
||||
m.input.SetValue("/mode list")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /mode list to be handled without starting an agent run")
|
||||
}
|
||||
for _, want := range []string{"Mode", "smart", "deep", "fast", "large", "precise", "model=claude-opus-4.1", "turns=50"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected mode list transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeCommandNoArgOpensPicker(t *testing.T) {
|
||||
// A bare "/mode" opens the interactive picker instead of printing status.
|
||||
m := newModel(context.Background(), Options{ModelName: "claude-sonnet-4.5"})
|
||||
m.input.SetValue("/mode")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /mode picker open without starting an agent run")
|
||||
}
|
||||
if next.picker == nil || next.picker.kind != pickerMode {
|
||||
t.Fatalf("expected an open mode picker, got %#v", next.picker)
|
||||
}
|
||||
if len(next.picker.items) == 0 {
|
||||
t.Fatal("expected mode picker to list presets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeCommandSwitchesModelEffortAndTurns(t *testing.T) {
|
||||
nextProvider := &fakeProvider{}
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "anthropic",
|
||||
ModelName: "claude-sonnet-4.5",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: anthropicTestProfile("claude-sonnet-4.5"),
|
||||
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
if profile.Model != "claude-opus-4.1" {
|
||||
t.Fatalf("expected provider rebuild for claude-opus-4.1, got %#v", profile)
|
||||
}
|
||||
return nextProvider, nil
|
||||
},
|
||||
AgentOptions: agent.Options{MaxTurns: 12},
|
||||
})
|
||||
m.input.SetValue("/mode deep")
|
||||
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected /mode deep to be handled without starting an agent run")
|
||||
}
|
||||
if next.modelName != "claude-opus-4.1" {
|
||||
t.Fatalf("expected model claude-opus-4.1, got %q", next.modelName)
|
||||
}
|
||||
if next.reasoningEffort != modelregistry.ReasoningEffortHigh {
|
||||
t.Fatalf("expected effort high, got %q", next.reasoningEffort)
|
||||
}
|
||||
if next.agentOptions.MaxTurns != 50 {
|
||||
t.Fatalf("expected max turns 50, got %d", next.agentOptions.MaxTurns)
|
||||
}
|
||||
if next.provider != nextProvider {
|
||||
t.Fatal("expected provider to be rebuilt for the mode model")
|
||||
}
|
||||
for _, want := range []string{"mode deep", "model: claude-opus-4.1", "effort: high", "max turns: 50"} {
|
||||
if !transcriptContains(next.transcript, want) {
|
||||
t.Fatalf("expected mode switch transcript to contain %q, got %#v", want, next.transcript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeCommandUnknownReportsError(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{
|
||||
ProviderName: "anthropic",
|
||||
ModelName: "claude-sonnet-4.5",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: anthropicTestProfile("claude-sonnet-4.5"),
|
||||
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
t.Fatal("provider should not be rebuilt for an unknown mode")
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
m.input.SetValue("/mode turbo")
|
||||
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
|
||||
if next.modelName != "claude-sonnet-4.5" {
|
||||
t.Fatalf("expected active model to stay claude-sonnet-4.5, got %q", next.modelName)
|
||||
}
|
||||
if !transcriptContains(next.transcript, "unknown mode") {
|
||||
t.Fatalf("expected unknown mode error, got %#v", next.transcript)
|
||||
}
|
||||
}
|
||||
+21
-7
@@ -113,16 +113,30 @@ func (m model) chatColumnWidth() int {
|
||||
return chatWidth(m.width)
|
||||
}
|
||||
|
||||
// transcriptGutter is the left indent applied to transcript body rows. Keep this
|
||||
// at zero so content starts at the chat edge and tool/code blocks can use the
|
||||
// full available width.
|
||||
// transcriptGutterMinColumn is the column width below which the body uses the
|
||||
// full width (no side margins). Narrow columns (e.g. the chat column in the
|
||||
// two-column layout) have no space to waste, so they stay flush; the breathing
|
||||
// room is only for wide terminals where text would otherwise leave a big void.
|
||||
const transcriptGutterMinColumn = 98
|
||||
|
||||
// transcriptGutter is the side margin applied to transcript body rows so the
|
||||
// reading column floats off both edges instead of running flush. It scales gently
|
||||
// with the column (a wider terminal earns a little more breathing room), is
|
||||
// applied on the left as an indent, and is mirrored on the right by
|
||||
// transcriptContentWidth. Zero on terminals too narrow to spare it.
|
||||
func transcriptGutter(columnWidth int) int {
|
||||
return 0
|
||||
if columnWidth <= transcriptGutterMinColumn {
|
||||
return 0
|
||||
}
|
||||
return clamp(columnWidth/20, 4, 12)
|
||||
}
|
||||
|
||||
// transcriptContentWidth is the wrap width for transcript body rows.
|
||||
// transcriptContentWidth is the wrap width for transcript body rows: the column
|
||||
// minus a margin on each side, so the chat fills most of a wide terminal with a
|
||||
// little breathing room rather than stopping at a fixed reading cap. Degrades to
|
||||
// the full column on tiny terminals so prose never collapses to the wrap floor.
|
||||
func transcriptContentWidth(columnWidth int) int {
|
||||
cw := columnWidth - transcriptGutter(columnWidth)
|
||||
cw := columnWidth - 2*transcriptGutter(columnWidth)
|
||||
if cw < 24 {
|
||||
return columnWidth
|
||||
}
|
||||
@@ -823,7 +837,7 @@ func (m model) sidebarTokenText() string {
|
||||
if used <= 0 {
|
||||
return ""
|
||||
}
|
||||
if window := m.modelContextWindow(m.modelName); window > 0 {
|
||||
if window := modelContextWindow(m.modelName); window > 0 {
|
||||
return fmt.Sprintf("%s / %s tokens", humanCount(used), humanCount(window))
|
||||
}
|
||||
return humanCount(used) + " tokens"
|
||||
|
||||
@@ -406,10 +406,10 @@ func toolCallSummary(event streamjson.Event) string {
|
||||
}
|
||||
case "bash", "exec_command":
|
||||
if cmd, ok := args["command"].(string); ok {
|
||||
return truncateRunes(singleLineToolHeadText(cmd), 40)
|
||||
return truncateRunes(cmd, 40)
|
||||
}
|
||||
if cmd, ok := args["cmd"].(string); ok {
|
||||
return truncateRunes(singleLineToolHeadText(cmd), 40)
|
||||
return truncateRunes(cmd, 40)
|
||||
}
|
||||
case "write_stdin":
|
||||
sessionID := toolCallIntArg(args, "session_id")
|
||||
|
||||
@@ -17,7 +17,6 @@ type streamingDecoder struct {
|
||||
|
||||
rawLen int // total raw arg bytes seen (for the "receiving N KB" indicator)
|
||||
head []byte // raw args before the content value; dropped once content starts
|
||||
pathScan []byte // bounded post-content args used only to recover a late path
|
||||
path string
|
||||
pathDone bool
|
||||
|
||||
@@ -33,7 +32,7 @@ type streamingDecoder struct {
|
||||
|
||||
func newStreamingDecoder() *streamingDecoder {
|
||||
return &streamingDecoder{
|
||||
pathKeys: []string{"path", "file", "file_path", "filepath", "filename"},
|
||||
pathKeys: []string{"path", "file_path", "filename"},
|
||||
contentKeys: []string{"content", "new_string", "new_str", "new_text", "new", "replacement", "patch", "input"},
|
||||
tailCap: streamingTailLines,
|
||||
}
|
||||
@@ -43,54 +42,27 @@ func newStreamingDecoder() *streamingDecoder {
|
||||
func (d *streamingDecoder) feed(fragment string) {
|
||||
d.rawLen += len(fragment)
|
||||
if d.closed {
|
||||
d.scanPathFragment(fragment)
|
||||
return
|
||||
}
|
||||
if d.inContent {
|
||||
if remainder := d.consume(fragment); remainder != "" {
|
||||
d.scanPathFragment(remainder)
|
||||
}
|
||||
d.consume(fragment)
|
||||
return
|
||||
}
|
||||
d.head = append(d.head, fragment...)
|
||||
if !d.pathDone {
|
||||
// Best effort: the path completes quickly (it precedes the content), and a
|
||||
// partial value just updates until it's whole.
|
||||
d.setPathFrom(string(d.head))
|
||||
d.path = streamingFilePath(string(d.head))
|
||||
}
|
||||
start := d.contentValueStart()
|
||||
if start < 0 {
|
||||
return // content value hasn't begun yet
|
||||
}
|
||||
d.pathDone = true
|
||||
d.inContent = true
|
||||
rest := string(d.head[start:])
|
||||
d.head = nil
|
||||
if remainder := d.consume(rest); remainder != "" {
|
||||
d.scanPathFragment(remainder)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *streamingDecoder) setPathFrom(args string) {
|
||||
if d.pathDone {
|
||||
return
|
||||
}
|
||||
if v := streamingFilePath(args); v != "" {
|
||||
d.path = v
|
||||
d.pathDone = true
|
||||
}
|
||||
}
|
||||
|
||||
const maxStreamingPathScanBytes = 8192
|
||||
|
||||
func (d *streamingDecoder) scanPathFragment(fragment string) {
|
||||
if d.pathDone || fragment == "" {
|
||||
return
|
||||
}
|
||||
d.pathScan = append(d.pathScan, fragment...)
|
||||
if len(d.pathScan) > maxStreamingPathScanBytes {
|
||||
d.pathScan = d.pathScan[len(d.pathScan)-maxStreamingPathScanBytes:]
|
||||
}
|
||||
d.setPathFrom(string(d.pathScan))
|
||||
d.consume(rest)
|
||||
}
|
||||
|
||||
// contentValueStart returns the index in head just past the opening quote of the
|
||||
@@ -106,9 +78,9 @@ func (d *streamingDecoder) contentValueStart() int {
|
||||
return best
|
||||
}
|
||||
|
||||
// consume decodes content bytes, splitting on decoded newlines into tail lines,
|
||||
// stopping at the value's closing quote, and returning any bytes after it.
|
||||
func (d *streamingDecoder) consume(b string) string {
|
||||
// consume decodes content bytes, splitting on decoded newlines into tail lines and
|
||||
// stopping at the value's closing quote.
|
||||
func (d *streamingDecoder) consume(b string) {
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if d.uSkip > 0 {
|
||||
@@ -136,12 +108,11 @@ func (d *streamingDecoder) consume(b string) string {
|
||||
d.pendingEsc = true
|
||||
case '"':
|
||||
d.closed = true // closing quote of the content value
|
||||
return b[i+1:]
|
||||
return
|
||||
default:
|
||||
d.cur = append(d.cur, c)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *streamingDecoder) newline() {
|
||||
|
||||
@@ -31,27 +31,6 @@ func TestStreamingDecoderPathTailAndCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingDecoderPathAliases(t *testing.T) {
|
||||
for _, key := range []string{"file", "file_path", "filepath", "filename"} {
|
||||
d := newStreamingDecoder()
|
||||
d.feed(`{"` + key + `":"time_test.py","content":"print(1)"}`)
|
||||
if d.path != "time_test.py" {
|
||||
t.Errorf("%s path = %q, want time_test.py", key, d.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingDecoderFindsPathAfterContent(t *testing.T) {
|
||||
d := newStreamingDecoder()
|
||||
feedChunks(d, `{"content":"from datetime import datetime\n`, `print(datetime.now())",`, `"path":"time_test.py"}`)
|
||||
if d.path != "time_test.py" {
|
||||
t.Fatalf("late path = %q, want time_test.py", d.path)
|
||||
}
|
||||
if got := d.lineTotal(); got != 2 {
|
||||
t.Fatalf("lineTotal = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingDecoderSplitEscape(t *testing.T) {
|
||||
d := newStreamingDecoder()
|
||||
// The \n and \t escapes are split across feed boundaries (backslash, then n/t).
|
||||
|
||||
@@ -262,14 +262,12 @@ func (m *model) resetStreamingFade() {
|
||||
m.lastStreamActivity = time.Time{}
|
||||
}
|
||||
|
||||
// styleStreamingLine applies the fade palette to one visual line of prose in the
|
||||
// streaming block. Already-styled lines (markdown/code/table highlighting) are
|
||||
// returned unchanged so live colors match committed colors instead of snapping at
|
||||
// turn end.
|
||||
// styleStreamingLine applies the fade palette to one visual line of the
|
||||
// streaming block. When fadeActive is false (or lineAges is nil because a
|
||||
// test fixture pre-populated streamingText), the line is styled with the
|
||||
// base ink color — the same look the streaming block had before the fade
|
||||
// feature shipped.
|
||||
func (m model) styleStreamingLine(line string, visualIndex, visualCount int) string {
|
||||
if strings.Contains(line, "\x1b") {
|
||||
return line
|
||||
}
|
||||
if !m.fadeActive || m.lineAges == nil {
|
||||
return zeroTheme.ink.Render(line)
|
||||
}
|
||||
|
||||
@@ -362,13 +362,3 @@ func TestStyleStreamingLineFallsBackWhenLineAgesNil(t *testing.T) {
|
||||
t.Errorf("styleStreamingLine with nil lineAges = %q, want %q (base render)", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleStreamingLinePreservesHighlightedLines(t *testing.T) {
|
||||
m := model{}
|
||||
m.fadeActive = true
|
||||
m.lineAges = []time.Time{time.Now()}
|
||||
line := zeroTheme.accent.Render("func") + " main()"
|
||||
if out := m.styleStreamingLine(line, 0, 1); out != line {
|
||||
t.Errorf("highlighted streaming line should be preserved, got %q want %q", out, line)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// streamingTailLines is how many trailing lines of a file's in-progress content
|
||||
@@ -94,7 +96,7 @@ func skipJSONSpace(s string, i int) int {
|
||||
// streamingFilePath pulls the file path out of a streaming tool-call args buffer,
|
||||
// trying the argument names the file tools use. Used to seed the decoder's path.
|
||||
func streamingFilePath(args string) string {
|
||||
for _, key := range []string{"path", "file", "file_path", "filepath", "filename"} {
|
||||
for _, key := range []string{"path", "file_path", "filename"} {
|
||||
if v, ok := decodeStreamingJSONString(args, key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
@@ -102,29 +104,64 @@ func streamingFilePath(args string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// streamingToolCallView renders the in-progress file-writing tool call: its path
|
||||
// and live line count. The code body is intentionally buffered until the result
|
||||
// card lands, so partial code does not recolor or shift while generating.
|
||||
// streamingToolCallView renders the in-progress file-writing tool call — its path,
|
||||
// a live line count, and a tail of the streaming content — so a long write/edit
|
||||
// shows the code flowing in instead of a frozen spinner. It reads the decoder's
|
||||
// incrementally-maintained state (O(1) per render); returns "" when no
|
||||
// file-writing call is mid-stream.
|
||||
func (m model) streamingToolCallView(width int) string {
|
||||
if m.streamCallID == "" || !isFileWritingTool(m.streamCallName) || m.streamCallDecoder == nil {
|
||||
return ""
|
||||
}
|
||||
d := m.streamCallDecoder
|
||||
|
||||
headTag := ""
|
||||
head := zeroTheme.accent.Render("✎ ") + zeroTheme.toolName.Render(m.streamCallName)
|
||||
if d.path != "" {
|
||||
head += " " + zeroTheme.toolTarget.Render(d.path)
|
||||
}
|
||||
switch {
|
||||
case d.hasContent():
|
||||
if m.streamCallName == "write_file" {
|
||||
headTag = diffCountTag(d.lineTotal(), 0)
|
||||
} else {
|
||||
headTag = fmt.Sprintf("%d lines", d.lineTotal())
|
||||
}
|
||||
head += zeroTheme.faint.Render(fmt.Sprintf(" · %d lines", d.lineTotal()))
|
||||
case d.rawLen > 0:
|
||||
// Args are streaming but the content field hasn't arrived yet — show a live
|
||||
// byte count so it reads as progressing, never frozen.
|
||||
headTag = fmt.Sprintf("receiving %.1f KB", float64(d.rawLen)/1024)
|
||||
head += zeroTheme.faint.Render(fmt.Sprintf(" · receiving %.1f KB", float64(d.rawLen)/1024))
|
||||
}
|
||||
lines := []string{head}
|
||||
if d.hasContent() {
|
||||
bodyWidth := maxInt(8, width-4)
|
||||
// write_file/edit_file content is brand-new, so every line is an addition;
|
||||
// apply_patch carries a real ± diff. styleStreamingCodeLine colors added
|
||||
// lines green, removed red, and everything else bright (not the old dim gray).
|
||||
newContent := m.streamCallName == "write_file" || m.streamCallName == "edit_file"
|
||||
for _, line := range d.tailLines() {
|
||||
line = strings.ReplaceAll(line, "\t", " ")
|
||||
// Truncate by display WIDTH (ansi.Truncate), not rune count, so wide/CJK
|
||||
// glyphs can't overrun bodyWidth and break the tail layout.
|
||||
lines = append(lines, " "+styleStreamingCodeLine(ansi.Truncate(line, bodyWidth, ""), newContent))
|
||||
}
|
||||
}
|
||||
head := toolCardHead(m.streamCallName, d.path, "", headTag, "", "", true, zeroTheme.ink, false, width, cardRenderOptions{cwd: m.cwd})
|
||||
lines := []string{zeroTheme.accent.Render(m.spinnerGlyph()) + " " + head}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// styleStreamingCodeLine colors one line of the live code preview: explicit diff
|
||||
// additions (+) render green and removals (-) red; for a brand-new write_file/edit
|
||||
// every line is an addition (green); anything else renders in bright ink. This
|
||||
// replaces the old dim faintest gray the streamed code was nearly invisible in.
|
||||
func styleStreamingCodeLine(line string, newContent bool) string {
|
||||
// A brand-new write_file/edit_file is NOT a diff: every line is added content,
|
||||
// so color it all green and never treat a leading "-" (e.g. CSS "-webkit-…") as
|
||||
// a removal. Only apply_patch (newContent=false) carries real ± diff markers.
|
||||
if newContent {
|
||||
return zeroTheme.green.Render(line)
|
||||
}
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"):
|
||||
return zeroTheme.green.Render(line)
|
||||
case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"):
|
||||
return zeroTheme.red.Render(line)
|
||||
default:
|
||||
return zeroTheme.ink.Render(line)
|
||||
}
|
||||
}
|
||||
@@ -62,38 +62,13 @@ func TestStreamingToolCallView(t *testing.T) {
|
||||
if viewModel("bash", `{"command":"ls"}`).streamingToolCallView(80) != "" {
|
||||
t.Error("non-file tool should render nothing")
|
||||
}
|
||||
// Active write_file → shows path and live count, but buffers the code body
|
||||
// until the final tool result lands.
|
||||
// Active write_file → shows path, line count, and a content tail.
|
||||
out := viewModel("write_file", `{"path":"a/b.go","content":"package main\n\nfunc main() {}\n"}`).streamingToolCallView(80)
|
||||
for _, want := range []string{zeroTheme.diffAdd.Render("+3"), zeroTheme.diffDel.Render("-0")} {
|
||||
for _, want := range []string{"write_file", "a/b.go", "lines", "func main()"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("live count tag should color additions/deletions, missing styled %q in:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
plain := plainRender(t, out)
|
||||
for _, want := range []string{"Adding", "a/b.go", "(+3 -0)"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("view missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(plain, "package main") || strings.Contains(plain, "func main()") {
|
||||
t.Errorf("live write preview should buffer code body until completion:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "write_file") {
|
||||
t.Errorf("live preview must not expose raw tool name:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingToolCallViewShowsLatePath(t *testing.T) {
|
||||
dec := newStreamingDecoder()
|
||||
feedChunks(dec, `{"content":"from datetime import datetime\n`, `print(datetime.now())",`, `"path":"time_test.py"}`)
|
||||
m := model{streamCallID: "1", streamCallName: "write_file", streamCallDecoder: dec}
|
||||
out := plainRender(t, m.streamingToolCallView(80))
|
||||
for _, want := range []string{"Adding", "time_test.py", "(+2 -0)"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("live write header = %q, missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingViewShowsProgressBeforeContent(t *testing.T) {
|
||||
@@ -107,3 +82,19 @@ func TestStreamingViewShowsProgressBeforeContent(t *testing.T) {
|
||||
t.Errorf("should show a receiving byte count before content: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleStreamingNewContentNeverRed(t *testing.T) {
|
||||
// L8: a brand-new file line beginning with "-" (e.g. CSS "-webkit-…") must NOT
|
||||
// be colored as a diff removal. With newContent=true it renders green; the
|
||||
// red/green diff branches apply only to apply_patch (newContent=false).
|
||||
green := zeroTheme.green.Render("-webkit-box-shadow: none;")
|
||||
got := styleStreamingCodeLine("-webkit-box-shadow: none;", true)
|
||||
if got != green {
|
||||
t.Errorf("new-file '-' line should render green, got %q want %q", got, green)
|
||||
}
|
||||
// And for a real patch (newContent=false) a '-' line IS a red removal.
|
||||
red := zeroTheme.red.Render("-old line")
|
||||
if got := styleStreamingCodeLine("-old line", false); got != red {
|
||||
t.Errorf("patch '-' line should render red, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -39,25 +37,6 @@ func cachedLexer(lang string) chroma.Lexer {
|
||||
return lexer
|
||||
}
|
||||
|
||||
func cachedLexerForPath(path string) chroma.Lexer {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
key := "path:" + strings.ToLower(filepath.Base(path))
|
||||
lexerCacheMu.RLock()
|
||||
lexer, ok := lexerCache[key]
|
||||
lexerCacheMu.RUnlock()
|
||||
if ok {
|
||||
return lexer
|
||||
}
|
||||
lexer = lexers.Match(path)
|
||||
lexerCacheMu.Lock()
|
||||
lexerCache[key] = lexer
|
||||
lexerCacheMu.Unlock()
|
||||
return lexer
|
||||
}
|
||||
|
||||
// tokenStyle maps a chroma token type onto Zero's existing, contrast-audited
|
||||
// palette rather than a chroma color scheme — so highlighted code stays on-brand
|
||||
// and degrades through the same lipgloss profile path as the rest of the UI
|
||||
@@ -87,73 +66,10 @@ func tokenStyle(tt chroma.TokenType) lipgloss.Style {
|
||||
// unknown or missing language is never worse than today. Wrapping is done at the
|
||||
// token level so colors never split an ANSI escape.
|
||||
func highlightCode(code []string, lang string, measure int) ([]string, bool) {
|
||||
return highlightCodeWithLexer(cachedLexer(lang), code, measure, nil)
|
||||
}
|
||||
|
||||
func highlightCodeAuto(code []string, lang string, measure int) ([]string, bool) {
|
||||
if strings.TrimSpace(lang) == "" {
|
||||
lang = inferCodeLanguage(code)
|
||||
}
|
||||
return highlightCode(code, lang, measure)
|
||||
}
|
||||
|
||||
func highlightCodeForPath(code []string, path string, measure int, bg color.Color) ([]string, bool) {
|
||||
return highlightCodeWithLexer(cachedLexerForPath(path), code, measure, bg)
|
||||
}
|
||||
|
||||
func inferCodeLanguage(code []string) string {
|
||||
for _, line := range code {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "from ") && strings.Contains(trimmed, " import "):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "import ") && !strings.Contains(trimmed, " from "):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "def ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "class ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "if ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "elif ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case trimmed == "else:" || trimmed == "try:" || trimmed == "finally:":
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "for ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "while ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "with ") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "except") && strings.HasSuffix(trimmed, ":"):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "return "):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "print("):
|
||||
return "python"
|
||||
case strings.HasPrefix(trimmed, "package "):
|
||||
return "go"
|
||||
case strings.HasPrefix(trimmed, "func ") && strings.Contains(trimmed, "{"):
|
||||
return "go"
|
||||
case strings.HasPrefix(trimmed, "const "), strings.HasPrefix(trimmed, "let "), strings.HasPrefix(trimmed, "var "):
|
||||
return "javascript"
|
||||
case strings.HasPrefix(trimmed, "function ") && strings.Contains(trimmed, "{"):
|
||||
return "javascript"
|
||||
case strings.HasPrefix(trimmed, "<!DOCTYPE "), strings.HasPrefix(trimmed, "<html"), strings.HasPrefix(trimmed, "<div"), strings.HasPrefix(trimmed, "<span"):
|
||||
return "html"
|
||||
}
|
||||
break
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg color.Color) ([]string, bool) {
|
||||
if measure < 4 {
|
||||
return nil, false
|
||||
}
|
||||
lexer := cachedLexer(lang)
|
||||
if lexer == nil {
|
||||
return nil, false
|
||||
}
|
||||
@@ -171,9 +87,6 @@ func highlightCodeWithLexer(lexer chroma.Lexer, code []string, measure int, bg c
|
||||
curWidth = 0
|
||||
}
|
||||
emit := func(style lipgloss.Style, s string) {
|
||||
if bg != nil {
|
||||
style = style.Background(bg)
|
||||
}
|
||||
var chunk strings.Builder
|
||||
flushChunk := func() {
|
||||
if chunk.Len() > 0 {
|
||||
|
||||
@@ -5,109 +5,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStreamingCodeRendersHighlighted(t *testing.T) {
|
||||
m := model{
|
||||
streamingText: "```go\nfunc main() {}\n```",
|
||||
pending: true,
|
||||
}
|
||||
out := m.interimBlock(80)
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("live code should be highlighted before commit, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(plainRender(t, out), "func main() {}") {
|
||||
t.Fatalf("live code should keep the plain content, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalBarePythonCodeRendersHighlighted(t *testing.T) {
|
||||
row := transcriptRow{
|
||||
kind: rowAssistant,
|
||||
final: true,
|
||||
text: strings.Join([]string{
|
||||
"from datetime import datetime",
|
||||
"",
|
||||
"def print_current_time():",
|
||||
" print(datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"))",
|
||||
"",
|
||||
"if __name__ == \"__main__\":",
|
||||
" print_current_time()",
|
||||
}, "\n"),
|
||||
}
|
||||
out := renderAssistantRow(row, 90)
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("final bare Python code should be syntax-highlighted, got:\n%s", out)
|
||||
}
|
||||
plain := plainRender(t, out)
|
||||
if !strings.Contains(plain, "from datetime import datetime") ||
|
||||
!strings.Contains(plain, "def print_current_time():") ||
|
||||
!strings.Contains(plain, `if __name__ == "__main__":`) ||
|
||||
!strings.Contains(plain, "print_current_time()") {
|
||||
t.Fatalf("final bare Python code should keep content, got:\n%s", out)
|
||||
}
|
||||
for _, wantStyled := range []string{"from", "def", "if"} {
|
||||
if !strings.Contains(out, zeroTheme.accent.Render(wantStyled)) {
|
||||
t.Fatalf("final bare Python code should color keyword %q, got:\n%s", wantStyled, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareFencedCodeInfersLanguage(t *testing.T) {
|
||||
out := strings.Join(renderAssistantMarkdownText("```\nfrom datetime import datetime\nprint(datetime.now())\n```", 90, 90, true), "\n")
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("bare fenced Python code should infer language and highlight, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainProseDoesNotTriggerBareCodeHighlight(t *testing.T) {
|
||||
out := strings.Join(renderAssistantMarkdownText("from here we continue with the explanation", 90, 90, true), "\n")
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("plain prose should not be treated as bare code, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareCodeHighlightRequiresBlockSignal(t *testing.T) {
|
||||
out := strings.Join(renderAssistantMarkdownText("for these reasons:\nreturn later with a decision", 90, 90, true), "\n")
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Fatalf("ordinary prose should not be highlighted as bare code, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingMarkdownStablePrefixUsesRenderCache(t *testing.T) {
|
||||
defaultRenderCache.clear()
|
||||
text := "Here is the script:\n```go\nfunc main() {}\n```\nDone."
|
||||
_ = renderStreamingAssistantMarkdownText(text, 90, 90)
|
||||
before := defaultRenderCache.stats()
|
||||
_ = renderStreamingAssistantMarkdownText(text, 90, 90)
|
||||
after := defaultRenderCache.stats()
|
||||
if after.Hits <= before.Hits {
|
||||
t.Fatalf("streaming stable markdown should reuse highlighted cache, before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingBuffersOpenFencedCodeBlock(t *testing.T) {
|
||||
open := model{
|
||||
streamingText: "Here is the script:\n```python\nfrom datetime import datetime\nprint(datetime.now())",
|
||||
pending: true,
|
||||
}
|
||||
openOut := plainRender(t, open.interimBlock(90))
|
||||
if !strings.Contains(openOut, "Here is the script:") {
|
||||
t.Fatalf("streaming prose before code should remain visible, got:\n%s", openOut)
|
||||
}
|
||||
if strings.Contains(openOut, "datetime") || strings.Contains(openOut, "print(") {
|
||||
t.Fatalf("open fenced code should be buffered until the closing fence, got:\n%s", openOut)
|
||||
}
|
||||
|
||||
closed := model{
|
||||
streamingText: open.streamingText + "\n```",
|
||||
pending: true,
|
||||
}
|
||||
closedOut := closed.interimBlock(90)
|
||||
closedPlain := plainRender(t, closedOut)
|
||||
if !strings.Contains(closedPlain, "from datetime import datetime") || !strings.Contains(closedPlain, "print(datetime.now())") {
|
||||
t.Fatalf("closed fenced code should appear as one block, got:\n%s", closedOut)
|
||||
}
|
||||
if !strings.Contains(closedOut, "\x1b[") {
|
||||
t.Fatalf("closed streaming code should be highlighted, got:\n%s", closedOut)
|
||||
// The live streaming render path (allowHighlight=false) must render code
|
||||
// verbatim/plain — never tokenised — so the per-frame loop can't re-lex a
|
||||
// growing block. Profile-independent: the plain path applies no styling, so the
|
||||
// code appears as a contiguous substring (a highlighted block would not).
|
||||
func TestStreamingCodeRendersPlain(t *testing.T) {
|
||||
md := "```go\nfunc main() {}\n```"
|
||||
out := strings.Join(renderAssistantMarkdownText(md, 80, 80, false), "\n")
|
||||
if !strings.Contains(out, "func main() {}") {
|
||||
t.Fatalf("interim render must keep code verbatim (no chroma), got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ type tuiTheme struct {
|
||||
|
||||
// Tool cards.
|
||||
toolName lipgloss.Style // head-row tool name, ink bold
|
||||
toolTarget lipgloss.Style // head-row target path, ink
|
||||
toolTarget lipgloss.Style // head-row target path, muted
|
||||
toolArg lipgloss.Style // one-line arg hint, faintest
|
||||
cardRun lipgloss.Style // card border while the call runs (accent-mixed)
|
||||
cardErr lipgloss.Style // card border after an error (red-mixed)
|
||||
@@ -144,9 +144,9 @@ var darkPalette = palette{
|
||||
blue: "#7db4ff",
|
||||
gitAdd: "#7db87a",
|
||||
gitDel: "#b87a7a",
|
||||
addBg: "#18352c",
|
||||
addBg: "#15201d",
|
||||
delBg: "#241819",
|
||||
addBgWord: "#2e654d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
|
||||
addBgWord: "#26503d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
|
||||
delBgWord: "#502d30", // changed span within a deleted line — brighter red (sep 1.44 vs delBg, delInk 7.7:1)
|
||||
permBg: "#1c1915",
|
||||
selBg: "#32401b", // selected row bg — brightened from #1d2114 so the highlighted row separates from the panel (sep 1.18→1.73) while ink label contrast stays ~9.4:1
|
||||
@@ -217,7 +217,7 @@ func buildTheme(p palette) tuiTheme {
|
||||
userPrompt: fg(p.accent).Bold(true),
|
||||
sayText: fg(p.muted),
|
||||
toolName: fg(p.ink).Bold(true),
|
||||
toolTarget: fg(p.ink),
|
||||
toolTarget: fg(p.muted),
|
||||
toolArg: fg(p.faintest),
|
||||
cardRun: fg(p.cardRun),
|
||||
cardErr: fg(p.cardErr),
|
||||
|
||||
@@ -120,29 +120,18 @@ func TestRedundantConfirmationCollapsesSuccessBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplorationOutputSummarizesBody(t *testing.T) {
|
||||
func TestRealOutputKeepsBody(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
rows := []transcriptRow{{
|
||||
kind: rowToolCall,
|
||||
id: "c2",
|
||||
tool: "grep",
|
||||
detail: "internal/cli",
|
||||
arg: "flag.NewFlagSet",
|
||||
}, {
|
||||
row := transcriptRow{
|
||||
kind: rowToolResult,
|
||||
id: "c2",
|
||||
tool: "grep",
|
||||
status: tools.StatusOK,
|
||||
detail: "internal/cli/root.go:41: fs := flag.NewFlagSet",
|
||||
}}
|
||||
card := plainRender(t, m.renderRow(rows[1], 80, buildRowContext(rows)))
|
||||
for _, want := range []string{"Explored", "Search", "flag.NewFlagSet"} {
|
||||
if !strings.Contains(card, want) {
|
||||
t.Fatalf("exploration card missing %q:\n%s", want, card)
|
||||
}
|
||||
}
|
||||
if strings.Contains(card, "internal/cli/root.go:41") {
|
||||
t.Fatalf("exploration card must not dump raw grep output, got:\n%s", card)
|
||||
card := plainRender(t, m.renderRow(row, 80, buildRowContext(nil)))
|
||||
if !strings.Contains(card, "flag.NewFlagSet") {
|
||||
t.Fatalf("real grep output must NOT be suppressed, got:\n%s", card)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +151,9 @@ func TestErrorConfirmationKeepsBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsecutiveExplorationResultsGroup(t *testing.T) {
|
||||
// --- Fix 5: blank line between consecutive tool cards -------------------------
|
||||
|
||||
func TestConsecutiveToolCardsAreBlankSeparated(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
m.headerPrinted = true
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "go"})
|
||||
@@ -177,11 +168,16 @@ func TestConsecutiveExplorationResultsGroup(t *testing.T) {
|
||||
|
||||
body, _ := m.transcriptBody(96, "")
|
||||
got := plainRender(t, body)
|
||||
if strings.Count(got, "Explored") != 1 {
|
||||
t.Fatalf("adjacent exploration results should group into one card, got:\n%s", got)
|
||||
// Both cards must be present, and a blank line must sit between the end of the
|
||||
// first card and the start of the second (the "wall" fix).
|
||||
firstIdx := strings.Index(got, "internal/a.go")
|
||||
secondIdx := strings.Index(got, "internal/b.go")
|
||||
if firstIdx < 0 || secondIdx < 0 || secondIdx <= firstIdx {
|
||||
t.Fatalf("both tool cards should render in order, got:\n%s", got)
|
||||
}
|
||||
if strings.Count(got, "Search") != 2 || !strings.Contains(got, "├ Search") || !strings.Contains(got, "└ Search") {
|
||||
t.Fatalf("grouped exploration card should show both searches, got:\n%s", got)
|
||||
between := got[firstIdx:secondIdx]
|
||||
if !strings.Contains(between, "\n\n") {
|
||||
t.Fatalf("expected a blank line between consecutive tool cards, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,7 @@ func TestToolDisplayNameCleansMCPNames(t *testing.T) {
|
||||
"mcp_exa_web_search_exa": "web search",
|
||||
"mcp_exa_web_fetch_exa": "web fetch",
|
||||
"mcp_foo_bar": "bar",
|
||||
"write_file": "Create",
|
||||
"edit_file": "Edit",
|
||||
"read_file": "Read",
|
||||
"bash": "Run",
|
||||
"browser_open": "Open browser",
|
||||
"browser_snapshot": "Browser snapshot",
|
||||
"capture_artifact": "Capture",
|
||||
"edit_file": "edit_file", // built-in, unchanged
|
||||
"web_search": "web_search", // built-in, unchanged
|
||||
}
|
||||
for in, want := range cases {
|
||||
|
||||
@@ -8,11 +8,9 @@ import (
|
||||
type toolBodyRequest struct {
|
||||
name string
|
||||
hint string
|
||||
arg string
|
||||
detail string
|
||||
width int
|
||||
opts cardRenderOptions
|
||||
failed bool
|
||||
}
|
||||
|
||||
type toolBodyRenderer interface {
|
||||
@@ -42,13 +40,9 @@ func newDefaultToolBodyRegistry() *toolBodyRegistry {
|
||||
// write_file's card-only Display.Preview is a synthesized all-additions diff
|
||||
// (the new file's head), so render it through the same diff path.
|
||||
registry.register("write_file", diffOrFallback)
|
||||
exploreRenderer := diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return exploreCardBody(req.name, req.hint, req.arg, req.detail, req.width, req.opts, req.failed)
|
||||
})}
|
||||
registry.register("read_file", exploreRenderer)
|
||||
registry.register("read_minified_file", exploreRenderer)
|
||||
registry.register("list_directory", exploreRenderer)
|
||||
registry.register("glob", exploreRenderer)
|
||||
registry.register("read_file", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return readCardBody(req.detail, req.width, req.opts)
|
||||
})})
|
||||
registry.register("bash", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return bashCardBody(req.hint, req.detail, req.width, req.opts)
|
||||
})})
|
||||
@@ -58,18 +52,9 @@ func newDefaultToolBodyRegistry() *toolBodyRegistry {
|
||||
registry.register("write_stdin", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return execCommandCardBody("", req.detail, req.width, req.opts)
|
||||
})})
|
||||
registry.register("grep", exploreRenderer)
|
||||
localControlRenderer := diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return localControlCardBody(req.name, req.hint, req.detail, req.width, req.opts, req.failed)
|
||||
})}
|
||||
for _, name := range []string{
|
||||
"browser_install", "browser_launch", "browser_connect", "browser_open", "browser_snapshot",
|
||||
"browser_click", "browser_type", "browser_press", "browser_action",
|
||||
"desktop_windows", "desktop_snapshot", "desktop_action",
|
||||
"terminal_session", "capture_artifact",
|
||||
} {
|
||||
registry.register(name, localControlRenderer)
|
||||
}
|
||||
registry.register("grep", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
|
||||
return grepCardBody(req.detail, req.width, req.opts)
|
||||
})})
|
||||
// update_plan's full plan is already shown live by the sticky plan panel
|
||||
// (renderPlanPanel); collapse its transcript card to a one-line summary so the
|
||||
// plan isn't re-dumped into the transcript on every call.
|
||||
|
||||
@@ -12,7 +12,6 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hint string
|
||||
arg string
|
||||
detail string
|
||||
want []string
|
||||
}{
|
||||
@@ -25,7 +24,7 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
"-old",
|
||||
"+new",
|
||||
}, "\n"),
|
||||
want: []string{"(+1 -1)", "-1", "+1", "new"},
|
||||
want: []string{"app.go", "-1", "+1", "new"},
|
||||
},
|
||||
{
|
||||
name: "apply_patch",
|
||||
@@ -36,25 +35,23 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
"-old",
|
||||
"+new",
|
||||
}, "\n"),
|
||||
want: []string{"(+1 -1)", "-1", "+1", "new"},
|
||||
want: []string{"app.go", "-1", "+1", "new"},
|
||||
},
|
||||
{
|
||||
name: "read_file",
|
||||
hint: "README.md",
|
||||
detail: "File: README.md\n\n 7 | # Zero",
|
||||
want: []string{"Read", "README.md"},
|
||||
want: []string{"# Zero", "L7"},
|
||||
},
|
||||
{
|
||||
name: "bash",
|
||||
hint: "go test ./internal/tui",
|
||||
detail: "stdout:\nok\nexit_code: 0",
|
||||
want: []string{"ok"},
|
||||
want: []string{"go test ./internal/tui", "ok", "exit 0"},
|
||||
},
|
||||
{
|
||||
name: "grep",
|
||||
arg: "func render",
|
||||
detail: "internal/tui/rendering.go:41: func render()",
|
||||
want: []string{"Search", "func render"},
|
||||
want: []string{"internal/tui/rendering.go:41", "1 matches"},
|
||||
},
|
||||
{
|
||||
name: "unknown_tool",
|
||||
@@ -68,7 +65,6 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
body := registry.render(toolBodyRequest{
|
||||
name: tt.name,
|
||||
hint: tt.hint,
|
||||
arg: tt.arg,
|
||||
detail: normalizeToolCardDetail(tt.detail),
|
||||
width: 96,
|
||||
opts: opts,
|
||||
@@ -79,12 +75,6 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
t.Fatalf("%s body = %q, missing %q", tt.name, got, want)
|
||||
}
|
||||
}
|
||||
if tt.name == "read_file" && strings.Contains(got, "# Zero") {
|
||||
t.Fatalf("read_file body = %q, must not expose read contents", got)
|
||||
}
|
||||
if tt.name == "grep" && strings.Contains(got, "internal/tui/rendering.go:41") {
|
||||
t.Fatalf("grep body = %q, must not expose raw search matches", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -117,7 +107,7 @@ func TestToolBodyRegistryReplacementIsScopedToOneTool(t *testing.T) {
|
||||
if strings.Contains(got, "replacement grep body") {
|
||||
t.Fatalf("bash body = %q, must not use grep replacement", got)
|
||||
}
|
||||
if !strings.Contains(got, "ok") || strings.Contains(got, "exit 0") {
|
||||
if !strings.Contains(got, "go test ./internal/tui") || !strings.Contains(got, "exit 0") {
|
||||
t.Fatalf("bash body = %q, want original bash renderer", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type transcriptSelectionPoint struct {
|
||||
@@ -61,7 +59,6 @@ const (
|
||||
transcriptBodyItemTitle transcriptBodyItemKind = iota
|
||||
transcriptBodyItemEmpty
|
||||
transcriptBodyItemSeparator
|
||||
transcriptBodyItemRule
|
||||
transcriptBodyItemRow
|
||||
transcriptBodyItemPendingPrompt
|
||||
transcriptBodyItemPendingInterim
|
||||
@@ -121,11 +118,10 @@ func (l transcriptBodyLayout) visibleLines(window transcriptViewportWindow) []st
|
||||
return append([]string(nil), l.lines[start:end]...)
|
||||
}
|
||||
|
||||
// padTranscriptBodyLines left-indents transcript body rows when a non-zero
|
||||
// gutter is configured. It is horizontal only — it never changes the line count,
|
||||
// so the width-keyed height cache stays valid. Two-column mode right-pads the
|
||||
// chat block to the column width in joinColumns; single-column leaves any
|
||||
// remaining cells blank.
|
||||
// padTranscriptBodyLines left-indents transcript body rows by gutter cells (the
|
||||
// reading-column margin). Horizontal only — it never changes the line count, so
|
||||
// the width-keyed height cache stays valid. Two-column mode right-pads the chat
|
||||
// block to the column width in joinColumns; single-column leaves the right blank.
|
||||
func padTranscriptBodyLines(lines []string, gutter int) []string {
|
||||
if gutter <= 0 {
|
||||
return lines
|
||||
@@ -165,9 +161,9 @@ func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcrip
|
||||
|
||||
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
|
||||
items := []transcriptBodyItem{}
|
||||
// Transcript ROWS render at the full chat width; row/status glyphs provide
|
||||
// structure without adding another body margin. Block items (title bar, empty
|
||||
// state, prompts) keep the full column width below.
|
||||
// Transcript ROWS render in a reading column: wrapped to contentWidth and
|
||||
// indented by gutter, so wide terminals don't run text edge-to-edge. Block
|
||||
// items (title bar, empty state, prompts) keep the full column width below.
|
||||
contentWidth := transcriptContentWidth(width)
|
||||
gutter := transcriptGutter(width)
|
||||
|
||||
@@ -177,8 +173,6 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
items = append(items, transcriptBlockBodyItem(transcriptBodyItemTitle, -1, m.titleBar(width)))
|
||||
}
|
||||
|
||||
previousKind := rowWelcome
|
||||
havePreviousKind := false
|
||||
if m.transcriptEmpty() && !m.pending {
|
||||
if emptyOverlay != "" {
|
||||
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyStateWithOverlay(width, emptyOverlay)))
|
||||
@@ -188,7 +182,7 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
} else {
|
||||
rc := buildRowContext(m.transcript)
|
||||
shownAny := false
|
||||
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
previousKind, havePreviousKind := previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
||||
specialistSummaryEmitted := false
|
||||
for index := m.flushed; index < len(m.transcript); index++ {
|
||||
row := m.transcript[index]
|
||||
@@ -197,54 +191,10 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
if row.kind == rowWelcome || rc.skip(row) {
|
||||
continue
|
||||
}
|
||||
if isSuccessfulExploreResult(row) {
|
||||
groupRows, groupIndices, nextIndex := collectExploreResultGroup(m.transcript, index, rc)
|
||||
if len(groupRows) > 0 {
|
||||
if (shownAny || m.flushedAny) && startsTurn(row.kind) {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
if shownAny && havePreviousKind && needsSeparatorBeforeToolCard(previousKind, row.kind) {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
firstRowIndex := groupIndices[0]
|
||||
groupRowsCopy := append([]transcriptRow(nil), groupRows...)
|
||||
groupIndicesCopy := append([]int(nil), groupIndices...)
|
||||
block := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc)
|
||||
items = append(items, transcriptBodyItem{
|
||||
kind: transcriptBodyItemRow,
|
||||
rowIndex: firstRowIndex,
|
||||
heightCacheKey: transcriptBlockBodyHeightCacheKey(transcriptBodyItemRow, block),
|
||||
heightCacheStable: true,
|
||||
render: func(startBodyY int) transcriptBodyRenderedItem {
|
||||
rendered := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc)
|
||||
selectable := make([]transcriptSelectableLine, 0, len(groupIndicesCopy)+1)
|
||||
for offset, line := range viewLines(rendered) {
|
||||
rowIndex := firstRowIndex
|
||||
if offset > 0 && offset-1 < len(groupIndicesCopy) {
|
||||
rowIndex = groupIndicesCopy[offset-1]
|
||||
}
|
||||
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+offset, line, false); ok {
|
||||
selectable = append(selectable, meta)
|
||||
}
|
||||
}
|
||||
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
||||
},
|
||||
})
|
||||
shownAny = true
|
||||
previousKind = row.kind
|
||||
havePreviousKind = true
|
||||
index = nextIndex - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Blank-line separation before turns, including between flushed
|
||||
// history and the first live row.
|
||||
if (shownAny || m.flushedAny) && startsTurn(row.kind) {
|
||||
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
|
||||
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
||||
} else {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
if (shownAny || (m.flushedAny && havePreviousKind)) && previousKind == rowUser && row.kind == rowReasoning {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
@@ -254,7 +204,7 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
// would otherwise stack with no gap (the dense "wall" look). One blank
|
||||
// line between them matches the reference agents. Turn-starters are
|
||||
// separated above, so this only fires tool-card -> tool-card.
|
||||
if shownAny && havePreviousKind && needsSeparatorBeforeToolCard(previousKind, row.kind) {
|
||||
if shownAny && havePreviousKind && isToolCardKind(previousKind) && isToolCardKind(row.kind) {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
// A "Thought for X" reasoning header opens the next think→act group, so
|
||||
@@ -313,12 +263,7 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
|
||||
}
|
||||
|
||||
if m.pending {
|
||||
pendingShowsAssistantText := m.pendingPermission == nil && m.pendingAskUser == nil && strings.TrimSpace(m.streamingText) != ""
|
||||
if pendingShowsAssistantText && havePreviousKind && shouldRuleBeforeTurn(previousKind, rowAssistant) {
|
||||
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
||||
} else {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
switch {
|
||||
case m.pendingPermission != nil:
|
||||
perm := m.pendingPermission
|
||||
@@ -391,61 +336,6 @@ func transcriptBlankBodyItem() transcriptBodyItem {
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptRuleBodyItem(width int, gutter int) transcriptBodyItem {
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
return transcriptBodyItem{
|
||||
kind: transcriptBodyItemRule,
|
||||
rowIndex: -1,
|
||||
heightCacheKey: "transcript-body-height:v1:rule:" + strconv.Itoa(width) + ":" + strconv.Itoa(gutter),
|
||||
heightCacheStable: true,
|
||||
render: func(int) transcriptBodyRenderedItem {
|
||||
rule := zeroTheme.line.Render(strings.Repeat("─", width))
|
||||
return transcriptBodyRenderedItem{lines: padTranscriptBodyLines([]string{rule, ""}, gutter)}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func isSuccessfulExploreResult(row transcriptRow) bool {
|
||||
return row.kind == rowToolResult && !row.expanded && row.status != tools.StatusError && isExploreTool(toolRowName(row))
|
||||
}
|
||||
|
||||
func collectExploreResultGroup(rows []transcriptRow, start int, rc rowContext) ([]transcriptRow, []int, int) {
|
||||
groupRows := []transcriptRow{}
|
||||
groupIndices := []int{}
|
||||
for index := start; index < len(rows); index++ {
|
||||
row := rows[index]
|
||||
if row.kind == rowWelcome || rc.skip(row) {
|
||||
continue
|
||||
}
|
||||
if !isSuccessfulExploreResult(row) {
|
||||
return groupRows, groupIndices, index
|
||||
}
|
||||
groupRows = append(groupRows, row)
|
||||
groupIndices = append(groupIndices, index)
|
||||
}
|
||||
return groupRows, groupIndices, len(rows)
|
||||
}
|
||||
|
||||
func (m model) renderExploreResultGroup(rows []transcriptRow, width int, rc rowContext) string {
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
||||
body := make([]string, 0, len(rows))
|
||||
for index, row := range rows {
|
||||
marker := "├"
|
||||
if index == len(rows)-1 {
|
||||
marker = "└"
|
||||
}
|
||||
key := rcKey(row.runID, row.id)
|
||||
body = append(body, exploreCardLine(toolRowName(row), rc.hints[key], rc.args[key], row.detail, width, opts, marker))
|
||||
}
|
||||
head := zeroTheme.green.Bold(true).Render("Explored")
|
||||
return toolCard(head, zeroTheme.green.Render("•"), body, zeroTheme.faint.Render("▸ details"), zeroTheme.line, width)
|
||||
}
|
||||
|
||||
// transcriptBodyItemsFromRows builds body items from an arbitrary set of
|
||||
// transcript rows (used by the subchat drill-in view to render a child
|
||||
// session's events). It mirrors the main loop's logic but operates on the
|
||||
@@ -467,11 +357,7 @@ func (m model) transcriptBodyItemsFromRows(rows []transcriptRow, width int) []tr
|
||||
continue
|
||||
}
|
||||
if shownAny && startsTurn(row.kind) {
|
||||
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
|
||||
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
||||
} else {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
}
|
||||
if shownAny && havePreviousKind && previousKind == rowUser && row.kind == rowReasoning {
|
||||
items = append(items, transcriptBlankBodyItem())
|
||||
@@ -684,13 +570,6 @@ func selectableLineFromRenderedLine(rowIndex int, bodyY int, rendered string, bo
|
||||
text = strings.TrimSuffix(text, " │")
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{" ├ ", " └ ", " │ "} {
|
||||
if strings.HasPrefix(text, prefix) {
|
||||
textStart += lipgloss.Width(prefix)
|
||||
text = strings.TrimPrefix(text, prefix)
|
||||
break
|
||||
}
|
||||
}
|
||||
text = strings.TrimRight(text, " ")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return transcriptSelectableLine{}, false
|
||||
@@ -827,8 +706,16 @@ func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width in
|
||||
|
||||
func (m model) renderSelectableAssistantRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
|
||||
tableMeasure := width
|
||||
// Interim narration carries a 2-col "● " gutter (see renderAssistantRow), so it
|
||||
// wraps 2 cols narrower and its selectable columns start at textStart=gutter;
|
||||
// the final answer keeps the full width and textStart=0.
|
||||
gutter := lipgloss.Width(narrationPrefix)
|
||||
measure := assistantMeasure(width)
|
||||
textStart := 0
|
||||
if !row.final {
|
||||
measure = maxInt(16, assistantMeasure(width)-gutter)
|
||||
textStart = gutter
|
||||
}
|
||||
// Committed/selectable row: highlight (the result is cached per row).
|
||||
wrapped := renderAssistantMarkdownText(row.text, measure, tableMeasure, true)
|
||||
selectable := make([]transcriptSelectableLine, 0, len(wrapped))
|
||||
|
||||
@@ -67,7 +67,7 @@ func (m model) titleBar(width int) string {
|
||||
}
|
||||
model := m.titleModelSegment()
|
||||
ctx := ""
|
||||
if window := m.modelContextWindow(m.modelName); window > 0 {
|
||||
if window := modelContextWindow(m.modelName); window > 0 {
|
||||
ctx = zeroTheme.faint.Render(" · " + formatContextWindow(window))
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ func (m model) contextFillPercent() (pct, used, window int, style lipgloss.Style
|
||||
}
|
||||
summary := m.usageTracker.Summary()
|
||||
used = m.latestUsageTokens(summary)
|
||||
window = m.modelContextWindow(m.modelName)
|
||||
window = modelContextWindow(m.modelName)
|
||||
if used <= 0 || window <= 0 {
|
||||
return 0, 0, 0, lipgloss.Style{}, false
|
||||
}
|
||||
@@ -934,7 +934,7 @@ func firstArgValue(raw string, keys []string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := args[key]; ok {
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
return singleLineToolHeadText(text)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestWidthTierSegments(t *testing.T) {
|
||||
}
|
||||
|
||||
rows := []transcriptRow{
|
||||
{kind: rowToolCall, id: "c", tool: "custom_tool", detail: "internal/cli", arg: "RegisterFlag"},
|
||||
{kind: rowToolResult, id: "c", tool: "custom_tool", status: tools.StatusOK, detail: "ok"},
|
||||
{kind: rowToolCall, id: "c", tool: "grep", detail: "internal/cli", arg: "RegisterFlag"},
|
||||
{kind: rowToolResult, id: "c", tool: "grep", status: tools.StatusOK, detail: "internal/cli/root.go:41: x"},
|
||||
}
|
||||
rc := buildRowContext(rows)
|
||||
card := plainRender(t, m.renderRow(rows[1], tc.width, rc))
|
||||
|
||||
@@ -59,8 +59,8 @@ func TestDiffBodyWordHighlightRenders(t *testing.T) {
|
||||
d := "--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-timeout = 90\n+timeout = 300"
|
||||
body := diffCardBody(d, 70, cardRenderOptions{bodyCap: 20})
|
||||
joined := strings.Join(body.lines, "\n")
|
||||
// addBgWord #2e654d -> "46;101;77"; delBgWord #502d30 -> "80;45;48"
|
||||
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
|
||||
// addBgWord #26503d -> "38;80;61"; delBgWord #502d30 -> "80;45;48"
|
||||
if !strings.Contains(joined, "38;80;61") || !strings.Contains(joined, "80;45;48") {
|
||||
t.Errorf("expected changed spans on the brighter word bg, got:\n%s", joined)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user