Compare commits

..
Author SHA1 Message Date
Gnanam 022a6d4f54 fix(openai): always send message content so strict OpenAI-compatible servers accept it
Some OpenAI-compatible endpoints (e.g. glm-* on Ollama-cloud) reject a request whose
message 'content' is absent or null with HTTP 400 'invalid message content type:
<nil>'. The chat-completions mapper relied on 'content,omitempty', so a message with
empty text (a tool result, an assistant turn that only makes tool calls, or a
sub-agent that failed with no output) serialized with no content field at all.

- Drop omitempty on chatMessage.Content and always set it (to "" when there is no
  text), so contentless messages serialize as "content":"" instead of being
  dropped. Standard OpenAI/xAI/groq already tolerated the omission; this makes the
  strict servers work too.
- Skip a degenerate assistant turn that has neither text nor tool calls, mirroring
  the Anthropic/Gemini mappers (which already drop empty turns).

Fixes the failure seen on glm-5.2:cloud after a sub-agent failed with empty output.
2026-06-30 11:12:38 +05:30
KRATOS 7a09a42a17 feat(tui): remove the /mode command (superseded by /model + /effort) (#337)
The /mode presets bundled a hardcoded Anthropic/Google model + reasoning effort +
turn budget, so on other providers (Ollama-cloud, xAI, GPT-5, custom) /mode would
switch to a model the user isn't authenticated to. With /model (any provider's
models) and /effort (reasoning effort) covering the same ground flexibly, /mode is
redundant in the TUI.

Removes the /mode slash command, its handler, the mode picker, and the dispatch +
stale comments; updates affected tests. The CLI `--mode` flag and internal/
modelregistry mode presets are kept (still used by headless exec).
2026-06-28 13:41:57 +05:30
Anandan c0a0389b09 Polish TUI transcript rendering (#334)
* Polish transcript rendering

Tighten transcript spacing, collapse noisy permission/tool rows, and render tool activity with cleaner action-oriented labels.

Improve code and diff rendering with markdown highlighting, cleaner command output blocks, and grouped exploration summaries.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: git diff --check

* Fix grep exploration rendering on Windows

Keep search query text out of path normalization so regex escapes render consistently across platforms.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: git diff --check

* Address transcript rendering review

Cache highlighted streaming markdown so closed code blocks are not re-lexed on every live render.

Tighten bare-code detection to avoid highlighting ordinary prose, restore manual permission audit rows, and make exploration details expandable.

Also align diff content/header predicates and use display-width truncation for compact tool output.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tui

Tested: env GOCACHE=/tmp/zero-go-cache go test -race ./internal/tui

Tested: env GOCACHE=/tmp/zero-go-cache go vet ./...

Tested: git diff --check
2026-06-28 13:41:00 +05:30
Vasanth T d99ac4d1e3 fix(openai): forward reasoning effort to the Codex Responses API (#336)
The Codex provider speaks the Responses API, where reasoning_effort is nested
under `reasoning.effort` rather than sent as a top-level field. buildResponsesRequest
never set it, so a user's chosen effort was silently dropped for every Codex
model — the request always ran at the model default.

Add a `reasoning` object to the Responses request and populate it from the
request's effort, reusing the chat path's openAIReasoningEffort normalizer so
only API-accepted values (minimal/low/medium/high) are sent and an empty or
unsupported effort omits the field (no empty object, no 400).
2026-06-28 13:11:04 +05:30
Vasanth T aa45edacc0 fix(modelregistry): one source of truth for a model's reasoning efforts (#335)
The /effort picker and the run-time effort resolver disagreed about which
tiers a model supports. Registry.ReasoningEfforts falls back to name-based
inference (reasoningEffortsForModelName) when the catalog lists no efforts,
so the picker shows controls for proxy-served GPT-5 / Codex / o-series
models. EffectiveReasoningEffort read model.ReasoningEfforts directly and
never saw that fallback, so it coerced the same request to "none" — the
picker advertised a tier the resolver silently dropped.

Route both through a single effectiveReasoningEfforts helper so they can
never diverge. A model the catalog enumerates with no efforts but whose
name is a known reasoning family now has its requested effort honored end
to end; non-reasoning models still resolve to "none".

Also drop the orphaned, stale NOTE above forwardedReasoningEffort claiming
effort "is not yet forwarded" (it is) and restore the reasoningEffortNotice
doc comment to its own function.
2026-06-28 13:07:14 +05:30
KRATOS ca5e0b9bbb feat: context-aware compaction + usage gauge for all models (#332)
* fix(agent): enable auto-compaction for all models, not just registered ones

Auto-compaction keys off the model's context window (enabled = ContextWindow > 0),
resolved only from the curated registry. Unregistered models — GPT-5/Codex, xAI,
Ollama-cloud, custom endpoints — resolved to 0, so both the proactive (~80%) and the
reactive post-overflow compaction were silently disabled and long sessions overflowed.

- TUI modelContextWindow now also consults live-discovered windows (accurate for
  proxy/custom models once /model has discovered them); unknown still returns 0 so
  display gauges show no guessed denominator.
- New modelregistry.AgentContextWindow applies a positive FallbackContextWindow (200k)
  ONLY on the compaction-enable path (TUI run + CLI exec/spec), so compaction is on for
  every model while display stays accurate-or-empty. Exact windows always win.

* feat(tui): show context usage (used / total + % gauge) for all models from launch

The sidebar/status-line context gauge only shows a total + fill % when the active
model's context window is known. For proxy/custom models (GPT-5/Codex, xAI, Ollama-
cloud) the window isn't in the registry, so it showed only a used-token count.

Warm model discovery for the active provider in Init() (background, non-blocking) so
the active model's window is learned at launch and the gauge renders 'used / total
tokens' + the fill % for every model — not just catalogued ones. Builds on the
discovered-window resolution from the compaction fix.

* fix(tui): address review — agent fallback on model-switch + active-provider window

- Apply modelregistry.AgentContextWindow to /model and /mode pre-switch compaction
  requests (TargetContextWindow), so the 'compaction for all models' behavior also
  covers undiscovered/custom switch targets, not just the active run.
- Scope the live-discovered context-window lookup to the active provider first
  (then others), so a model ID shared across providers resolves to the provider in
  use rather than an arbitrary match.
- Tighten the resolution test to compare against the registry's actual gpt-4.1
  window instead of != FallbackContextWindow.

* fix(cli): give provider-discovered context windows precedence in headless exec

Address the remaining review point: CLI exec/spec runs collapsed every uncatalogued
model to the 200k fallback, ignoring its real window. resolveAgentContextWindow now,
on a registry miss, queries the provider's live model list (resolving the credential
from inline/stored/env) to get the model's actual context window before falling back.
Discovery runs only on a registry miss (catalogued models pay no latency), is bounded
by a 5s timeout, and degrades to the fallback on any error so a headless run is never
blocked or failed.
2026-06-28 13:06:51 +05:30
KRATOS 653cee60c1 feat(tui): dim the transcript backdrop behind overlays for visibility (#333)
Slash-command/file palettes (and pickers/wizards) were composited directly over the
live transcript with no separation, so the chat bled through around the box and the
overlay was hard to read. Add a scrim: when an overlay is open, dim the transcript
backdrop (strip each line's colors and re-render faint) before compositing the bright
overlay on top. Header and composer render separately and stay bright. Text is
preserved (just dimmed), so context remains readable behind the palette.
2026-06-28 13:03:00 +05:30
49 changed files with 2332 additions and 2475 deletions

No files matched your search

+62 -15
View File
@@ -16,6 +16,8 @@ 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"
@@ -500,7 +502,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: modelContextWindow(modelRegistry, resolved.Provider.Model),
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
SessionID: preparedSession.Session.SessionID,
@@ -998,30 +1000,71 @@ func resolveSelectedModel(registry modelregistry.Registry, input string) (string
return entry.ID, notice
}
// 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.
// 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.
func modelContextWindow(registry modelregistry.Registry, modelID string) int {
trimmed := strings.TrimSpace(modelID)
if trimmed == "" {
return 0
}
entry, ok := registry.Resolve(trimmed)
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))
if !ok {
return 0
}
return entry.ContextLimits.ContextWindow
// 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
}
// 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
@@ -1044,6 +1087,10 @@ 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 == "" {
+1 -1
View File
@@ -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: modelContextWindow(run.modelRegistry, run.resolved.Provider.Model),
ContextWindow: resolveAgentContextWindow(runCtx, run.modelRegistry, run.resolved.Provider),
SessionID: draftSession.SessionID,
SessionTitle: run.sessionTitle,
ProviderName: run.resolved.Provider.Name,
+26 -6
View File
@@ -7,6 +7,27 @@ 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 (
@@ -172,13 +193,12 @@ func (registry Registry) SupportsCapability(pattern string, capability ModelCapa
}
func (registry Registry) ReasoningEfforts(pattern string) []ReasoningEffort {
if model, ok := registry.Get(pattern); ok && len(model.ReasoningEfforts) > 0 {
return append([]ReasoningEffort{}, model.ReasoningEfforts...)
if model, ok := registry.Get(pattern); ok {
return append([]ReasoningEffort{}, effectiveReasoningEfforts(model)...)
}
// 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.
// 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.
return reasoningEffortsForModelName(pattern)
}
+1 -5
View File
@@ -25,10 +25,6 @@ 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
@@ -410,7 +406,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh:
return true
default:
return false
+22 -4
View File
@@ -47,10 +47,13 @@ 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).
// 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.
func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) ReasoningEffort {
efforts := effectiveReasoningEfforts(model)
if requested != "" {
for _, effort := range model.ReasoningEfforts {
for _, effort := range efforts {
if effort == requested {
return requested
}
@@ -59,8 +62,23 @@ func EffectiveReasoningEffort(model ModelEntry, requested ReasoningEffort) Reaso
if model.DefaultReasoningEffort != "" {
return model.DefaultReasoningEffort
}
if len(model.ReasoningEfforts) > 0 {
return model.ReasoningEfforts[0]
if len(efforts) > 0 {
return efforts[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)
}
+40
View File
@@ -79,3 +79,43 @@ 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)
}
}
}
+23 -7
View File
@@ -71,13 +71,21 @@ 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"`
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"`
}
// inputItem is one element of the Responses `input` array. The Type field
@@ -242,6 +250,14 @@ 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
}
+61
View File
@@ -454,6 +454,67 @@ 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
+14 -5
View File
@@ -385,6 +385,14 @@ 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))
}
@@ -445,11 +453,12 @@ 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 {
// 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
}
// 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
} else {
parts := make([]contentPart, 0, len(message.Images)+1)
if message.Content != "" {
+65 -85
View File
@@ -1000,57 +1000,6 @@ 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"},
@@ -1076,38 +1025,6 @@ 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,
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
}
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
if empty.Content != nil {
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.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)
}
}
@@ -1246,3 +1163,66 @@ 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])
}
}
+7 -2
View File
@@ -18,8 +18,13 @@ type streamOptions struct {
}
type chatMessage struct {
Role string `json:"role"`
Content any `json:"content,omitempty"`
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"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
-147
View File
@@ -1,147 +0,0 @@
// 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
}
-99
View File
@@ -1,99 +0,0 @@
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))}
}
}
-249
View File
@@ -1,249 +0,0 @@
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
+267 -5
View File
@@ -1,6 +1,8 @@
package tui
import (
"crypto/sha256"
"fmt"
"strings"
"unicode"
"unicode/utf8"
@@ -44,6 +46,11 @@ 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
@@ -82,11 +89,8 @@ 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 := highlightCode(code, lang, tableMeasure); ok {
if highlighted, ok := highlightCodeAuto(code, lang, tableMeasure); ok {
lines = append(lines, highlighted...)
continue
}
@@ -107,6 +111,13 @@ 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 —
@@ -126,6 +137,27 @@ 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++
@@ -144,6 +176,58 @@ 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 {
@@ -158,6 +242,10 @@ 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
@@ -222,6 +310,26 @@ 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 '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
@@ -245,12 +353,166 @@ func markdownStartsBlock(lines []string, index int) bool {
if _, ok := markdownFenceMarker(trimmed); ok {
return true
}
if markdownHeadingText(trimmed) != "" || isMarkdownListLine(trimmed) || strings.HasPrefix(trimmed, ">") {
if markdownHeadingText(trimmed) != "" || isMarkdownHorizontalRule(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) {
+9 -9
View File
@@ -48,8 +48,8 @@ func TestSuggestionsSurfaceMatchingCommands(t *testing.T) {
t.Fatal("expected suggestions active after typing /mo")
}
names := suggestionNames(m)
if !contains(names, "/model") || !contains(names, "/mode") {
t.Fatalf("expected /model and /mode in suggestions, got %v", names)
if !contains(names, "/model") {
t.Fatalf("expected /model 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, "/mo")
m = typeRunes(t, m, "/s") // ambiguous prefix with several matches
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, "/mo")
m = typeRunes(t, m, "/s") // ambiguous prefix with several matches
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, "/mo")
m = typeRunes(t, m, "/s") // ambiguous prefix (stop, search, spec, …)
// 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.
// With multiple matches, Tab cycles the highlighted suggestion rather than
// completing the input, so the typed prefix stays put.
updated, _ := m.Update(testKey(tea.KeyTab))
m = updated.(model)
if m.input.Value() != "/mo" {
if m.input.Value() != "/s" {
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") || strings.Contains(plain, "/mode") {
if strings.Contains(plain, "/model") {
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"} {
+2 -121
View File
@@ -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: modelContextWindow(target.modelID),
TargetContextWindow: modelregistry.AgentContextWindow(m.modelContextWindow(target.modelID)),
}, "Model"); requested {
return guarded, text
}
@@ -613,109 +613,13 @@ 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 = modelContextWindow(m.modelName)
request.CurrentContextWindow = m.modelContextWindow(m.modelName)
request.EstimatedTokens = estimateTranscriptTokens(m.transcript)
request.SessionEventCount = len(m.sessionEvents)
request.CompactRequests = m.compactRequests
@@ -743,29 +647,6 @@ 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"
-8
View File
@@ -20,7 +20,6 @@ const (
commandSandboxSetup
commandProvider
commandModel
commandMode
commandContext
commandConfig
commandDebug
@@ -84,13 +83,6 @@ 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",
+51 -35
View File
@@ -286,7 +286,7 @@ type model struct {
exitConfirmSeq int
// picker, when non-nil, is an open interactive selector overlay (/model,
// /effort, /mode with no argument). It captures ↑/↓/Enter/Esc and applies
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
// the chosen value through the existing command handlers.
picker *commandPicker
providerWizard *providerWizardState
@@ -727,6 +727,16 @@ 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
@@ -2261,6 +2271,12 @@ 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
@@ -2272,6 +2288,17 @@ 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 {
@@ -2457,17 +2484,13 @@ func (m model) interimBlock(width int) string {
}
return strings.Join(blocks, "\n")
}
// 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)
// 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)
for index, line := range lines {
// 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.
// styleStreamingLine fades plain prose but leaves already-highlighted
// markdown/code lines alone, so live colors match the committed row.
lines[index] = m.styleStreamingLine(line, index, len(lines))
}
lines = m.appendStreamingCursor(lines, width)
@@ -3096,6 +3119,17 @@ 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
@@ -3150,8 +3184,7 @@ 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>", "/effort <v>",
// or "/mode <name>".
// the picker. Behavior is identical to running "/model <id>" or "/effort <v>".
func (m model) choosePicker() (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
@@ -3182,10 +3215,6 @@ 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).
@@ -3327,21 +3356,6 @@ 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
@@ -3778,9 +3792,11 @@ 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. An unknown/custom model resolves to 0, leaving compaction off.
options.ContextWindow = modelContextWindow(m.modelName)
// 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))
// 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,
+38 -8
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func (m model) modelListText() string {
@@ -54,20 +55,49 @@ 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.
func modelContextWindow(modelName string) int {
// 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 {
trimmed := strings.TrimSpace(modelName)
if trimmed == "" {
return 0
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return 0
if registry, err := modelregistry.DefaultRegistry(); err == nil {
if entry, ok := registry.Resolve(trimmed); ok && entry.ContextLimits.ContextWindow > 0 {
return entry.ContextLimits.ContextWindow
}
}
entry, ok := registry.Resolve(trimmed)
if !ok {
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
}
}
return entry.ContextLimits.ContextWindow
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
}
func activeModelID(registry modelregistry.Registry, modelName string) string {
+197 -37
View File
@@ -13,6 +13,7 @@ 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"
@@ -1297,7 +1298,10 @@ 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)
}
assertContains(t, next.renderRow(row, 80, buildRowContext(next.transcript)), "@@ -1 +1 @@")
rendered := next.renderRow(row, 80, buildRowContext(next.transcript))
assertContains(t, rendered, "old")
assertContains(t, rendered, "new")
assertNotContains(t, rendered, "@@")
}
func TestAgentResponsePreservesPermissionMetadata(t *testing.T) {
@@ -1549,7 +1553,7 @@ func TestAgentEventRenderingMappingCoversRuntimeContract(t *testing.T) {
tool: "read_file",
detail: "README.md",
},
wants: []string{"read_file", "README.md"},
wants: []string{"Read", "README.md"},
},
zeroruntime.AgentEventToolResult: {
row: transcriptRow{
@@ -1565,7 +1569,7 @@ func TestAgentEventRenderingMappingCoversRuntimeContract(t *testing.T) {
"+new",
}, "\n"),
},
wants: []string{"apply_patch", "@@ -1 +1 @@"},
wants: []string{"Patched", "file.txt", "old", "new"},
},
zeroruntime.AgentEventPlanUpdate: {
row: transcriptRow{kind: rowSystem, text: "Plan updated\n- inspect: completed"},
@@ -1926,11 +1930,9 @@ func TestSelectionHighlightUsesGutterShiftedCoordinate(t *testing.T) {
}
// TestTranscriptSelectionPaintsHighlightOnceNotTwice guards the "two highlights"
// 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.
// bug: assistant/user/reasoning rows used to self-paint the selection and
// finalizeTranscriptBodyRow re-painted it, so a single selection lit up twice.
// The highlight must land exactly once.
func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 160, 30
@@ -1939,9 +1941,6 @@ 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, "") {
@@ -1967,8 +1966,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 gutter-shifted coordinate the
// mouse hands to anchor/cursor).
// Select the first 5 columns of that line in the same coordinate system mouse
// selection uses.
m.transcriptSelection = transcriptSelectionState{
active: true,
anchor: transcriptSelectionPoint{bodyY: line.bodyY, x: line.textStart},
@@ -2016,20 +2015,127 @@ 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: 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)
// Wide terminal: no body gutter, full content width.
if g := transcriptGutter(160); g != 0 {
t.Fatalf("wide gutter = %d, want 0", g)
}
if cw := transcriptContentWidth(160); cw != 160-2*8 {
t.Fatalf("wide contentWidth = %d, want %d (column minus both margins)", cw, 160-2*8)
if cw := transcriptContentWidth(160); cw != 160 {
t.Fatalf("wide contentWidth = %d, want full 160", cw)
}
// 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)
// Very wide terminal: still no gutter, so code/tool blocks use the width.
if g := transcriptGutter(400); g != 0 {
t.Fatalf("very wide gutter = %d, want 0", g)
}
// Tiny terminal: no margins, full width so prose never collapses.
// Tiny terminal: full width so prose never collapses.
if g := transcriptGutter(40); g != 0 {
t.Fatalf("tiny gutter = %d, want 0", g)
}
@@ -2038,7 +2144,7 @@ func TestTranscriptReadingColumnHelpers(t *testing.T) {
}
}
func TestTranscriptBodyRowsUseReadingColumnAndAlignSelection(t *testing.T) {
func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4"})
m.width, m.height = 160, 30
m.altScreen = true
@@ -2048,8 +2154,8 @@ func TestTranscriptBodyRowsUseReadingColumnAndAlignSelection(t *testing.T) {
width := m.chatColumnWidth()
gutter := transcriptGutter(width)
if gutter <= 0 {
t.Fatalf("expected a gutter at width %d", width)
if gutter != 0 {
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
}
items := m.transcriptBodyItems(width, "")
@@ -2064,24 +2170,21 @@ func TestTranscriptBodyRowsUseReadingColumnAndAlignSelection(t *testing.T) {
}
rendered := row.render(0)
maxLine := transcriptContentWidth(width) + gutter // content plus the left indent
wroteIndented := false
maxLine := transcriptContentWidth(width) + gutter // content plus any left indent
sawContent := false
for _, line := range rendered.lines {
if w := lipgloss.Width(line); w > maxLine {
t.Fatalf("body line width %d exceeds reading column %d: %q", w, maxLine, line)
t.Fatalf("body line width %d exceeds chat column %d: %q", w, maxLine, line)
}
if strings.TrimSpace(line) != "" {
if !strings.HasPrefix(line, strings.Repeat(" ", gutter)) {
t.Fatalf("non-blank body line is not gutter-indented: %q", line)
}
wroteIndented = true
sawContent = true
}
}
if !wroteIndented {
t.Fatal("expected at least one indented content line")
if !sawContent {
t.Fatal("expected at least one content line")
}
// Selection alignment: text-carrying selectable lines start at/after the gutter
// so click-to-select and the highlight track the indented glyphs.
// Selection alignment: text-carrying selectable lines should never start before
// the rendered text coordinate.
for _, sl := range rendered.selectable {
if sl.text != "" && sl.textStart < gutter {
t.Fatalf("selectable textStart %d < gutter %d — selection would misalign", sl.textStart, gutter)
@@ -2316,3 +2419,60 @@ 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)
}
}
+2 -21
View File
@@ -22,7 +22,6 @@ type pickerKind int
const (
pickerModel pickerKind = iota
pickerEffort
pickerMode
pickerSession
)
@@ -45,8 +44,8 @@ type pickerItem struct {
Favorite bool
}
// commandPicker is a generic single-select overlay reused by /model, /effort,
// and /mode (invoked with no argument). It owns only list state; the chosen
// commandPicker is a generic single-select overlay reused by /model and /effort
// (invoked with no argument). It owns only list state; the chosen
// value is applied through the existing command handlers.
type commandPicker struct {
kind pickerKind
@@ -727,21 +726,3 @@ 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}
}
+36 -1
View File
@@ -659,7 +659,6 @@ func TestPickersRefuseToOpenWhileRunPending(t *testing.T) {
command string
}{
{name: "model", command: "/model"},
{name: "mode", command: "/mode"},
{name: "effort", command: "/effort"},
}
for _, tc := range cases {
@@ -841,3 +840,39 @@ 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)
}
}
+1
View File
@@ -150,6 +150,7 @@ 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)
+625 -175
View File
File diff suppressed because it is too large. Load diff
+250 -61
View File
@@ -47,15 +47,8 @@ func TestStyleAssistantMarkdownLinePassesAnsiVerbatim(t *testing.T) {
in := "\x1b[31mtoken\x1b[0m" // an already-colored code token
out := styleAssistantMarkdownLine(in, lipgloss.NewStyle().Bold(true))
// 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)
if out != in {
t.Fatalf("already-styled input must pass through verbatim:\nin: %q\nout: %q", in, out)
}
}
@@ -326,6 +319,40 @@ 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 |",
@@ -734,44 +761,38 @@ func TestDoneLineOnlyOnLongTurns(t *testing.T) {
}
}
func TestInterimAssistantRowRendersAsBulletedProse(t *testing.T) {
func TestInterimAssistantRowRendersAsPlainProse(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, "│") {
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, "│") || strings.Contains(got, "●") {
t.Fatalf("non-final assistant row = %q, must not carry activity chrome", got)
}
if !strings.Contains(got, "No provider configured.") {
t.Fatalf("non-final assistant row should carry the body text, got %q", got)
}
}
// 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) {
// 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) {
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 carry the bullet:\n%s", rendered)
if strings.Contains(plainRender(t, rendered), "●") {
t.Fatalf("narration display should not carry the old 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 != gutter {
t.Errorf("meta %d textStart = %d, want %d (gutter)", i, meta.textStart, gutter)
if meta.textStart != 0 {
t.Errorf("meta %d textStart = %d, want 0", i, meta.textStart)
}
if strings.Contains(meta.text, "●") {
t.Errorf("meta %d copy text should be gutter-free, got %q", i, meta.text)
t.Errorf("meta %d copy text should be chrome-free, got %q", i, meta.text)
}
}
for _, line := range strings.Split(rendered, "\n") {
@@ -820,13 +841,11 @@ 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, "grep") || !strings.Contains(got, "internal/cli") {
t.Fatalf("running card = %q, want tool name and target in head", got)
if !strings.Contains(got, "Searching") || !strings.Contains(got, "internal/cli") {
t.Fatalf("running card = %q, want action label and target in head", 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.HasPrefix(got, "│ ") || strings.Contains(got, "\n│ ") {
t.Fatalf("running card = %q, must not carry the old left rail", got)
}
if strings.ContainsAny(got, "╭╮╰╯") {
t.Fatalf("running card = %q, must not carry box-border corners", got)
@@ -850,20 +869,32 @@ func TestResolvedToolCallCollapsesIntoResultCard(t *testing.T) {
func TestDiffCardBodyRendersCountsNumbersAndCap(t *testing.T) {
m := limeTestModel()
diff := strings.Join([]string{
"--- /dev/null",
"+++ b/internal/cli/root.go",
"--- internal/cli/root.go",
"+++ 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: "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"} {
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"} {
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 @@"}
@@ -877,18 +908,58 @@ func TestDiffCardBodyRendersCountsNumbersAndCap(t *testing.T) {
}
}
func TestReadCardBodyShowsGutterAndRange(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) {
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{"read_file", "internal/agent/loop.go", "L12–L13", "func Run() {"} {
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go"} {
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) {
@@ -897,14 +968,46 @@ 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{"❯ go build ./...", "ok build", "warning: slow", "exit 1"} {
for _, want := range []string{"Ran", "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, "stdout:") || strings.Contains(got, "exit_code:") {
if strings.Contains(got, "❯") || 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) {
@@ -913,18 +1016,24 @@ 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{"❯ python3 -m http.server 8000", "Serving HTTP", "session 1000"} {
for _, want := range []string{"Ran", "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, "Use write_stdin") || strings.Contains(got, "session_id:") {
if strings.Contains(got, "❯") || 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)))
for _, want := range []string{"done", "exit 0"} {
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"} {
if !strings.Contains(got, want) {
t.Fatalf("write_stdin card = %q, missing %q", got, want)
}
@@ -937,6 +1046,61 @@ 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
@@ -946,6 +1110,10 @@ 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",
@@ -966,16 +1134,20 @@ func TestToolCallSummaryDescribesExecSessions(t *testing.T) {
}
}
func TestGrepCardBodyShowsLocationsAndMatchCount(t *testing.T) {
func TestGrepCardBodyShowsExploredSummary(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}
got := plainRender(t, m.renderRow(row, 90, buildRowContext(nil)))
for _, want := range []string{"internal/cli/root.go:41", "2 matches"} {
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"} {
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) {
@@ -1326,7 +1498,7 @@ func TestToolCardLinesAllSameWidth(t *testing.T) {
}
}
func TestToolResultCardRendersAsLeftRule(t *testing.T) {
func TestToolResultCardRendersInlineWithoutRail(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
kind: rowToolResult,
@@ -1337,19 +1509,18 @@ func TestToolResultCardRendersAsLeftRule(t *testing.T) {
}
card := plainRender(t, m.renderRow(row, 80, buildRowContext(nil)))
lines := strings.Split(card, "\n")
// Every line carries the left rail; none carries a box corner or a right
// border — the unified left-rule card shape (matches specialist cards).
// No line carries the old left rail or box borders.
for i, line := range lines {
if !strings.HasPrefix(line, "│ ") {
t.Fatalf("line %d = %q, want left-rule prefix", i, line)
if strings.HasPrefix(line, "│ ") {
t.Fatalf("line %d = %q, must not carry 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 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])
// 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])
}
}
@@ -1421,8 +1592,16 @@ func TestGrepCardHeadShowsTargetAndPatternColumns(t *testing.T) {
}
rc := buildRowContext(rows)
got := plainRender(t, m.renderRow(rows[1], 110, rc))
if !strings.Contains(got, "internal/cli") || !strings.Contains(got, `flag\.|RegisterFlag`) {
t.Fatalf("grep card head = %q, want separate target and pattern columns", got)
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")
}
}
@@ -1464,6 +1643,9 @@ 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)
}
@@ -1475,6 +1657,9 @@ 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)
}
@@ -1486,6 +1671,9 @@ 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)
}
@@ -1504,7 +1692,7 @@ func TestUnpromptedAllowRowsCollapseIntoAutoTag(t *testing.T) {
}}
rc := buildRowContext([]transcriptRow{allow})
if !rc.skip(allow) {
t.Fatal("an unprompted (auto) allow row must collapse — the tool card carries [auto]")
t.Fatal("an unprompted (auto) allow row must collapse")
}
}
@@ -1611,7 +1799,8 @@ 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 still collapses as before.
// Same-run prompt+decision collapses the prompt but keeps the manual decision
// as an audit line.
sameRunPrompt := transcriptRow{kind: rowPermission, id: "gemini_tool_1", runID: 3, permission: &agent.PermissionEvent{
ToolCallID: "gemini_tool_1", ToolName: "bash", Action: agent.PermissionActionPrompt,
}}
+4 -5
View File
@@ -5,16 +5,15 @@ import (
"testing"
)
// 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.
// 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.
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)
+1 -1
View File
@@ -519,7 +519,7 @@ func (m model) compactRequest() CompactRequest {
return CompactRequest{
SessionID: strings.TrimSpace(m.activeSession.SessionID),
ModelName: strings.TrimSpace(m.modelName),
ContextWindow: modelContextWindow(m.modelName),
ContextWindow: m.modelContextWindow(m.modelName),
SessionEventCount: len(m.sessionEvents),
EstimatedTokens: estimateTranscriptTokens(m.transcript),
VisibleTranscriptRows: len(m.transcript),
+2 -105
View File
@@ -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 != modelContextWindow("gpt-4.1") {
t.Fatalf("expected request context window %d, got %d", modelContextWindow("gpt-4.1"), request.ContextWindow)
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.EstimatedTokens <= 0 || request.VisibleTranscriptRows != len(m.transcript) {
t.Fatalf("expected request estimate and transcript count, got %#v", request)
@@ -951,106 +951,3 @@ 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)
}
}
+7 -21
View File
@@ -113,30 +113,16 @@ func (m model) chatColumnWidth() int {
return chatWidth(m.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.
// 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.
func transcriptGutter(columnWidth int) int {
if columnWidth <= transcriptGutterMinColumn {
return 0
}
return clamp(columnWidth/20, 4, 12)
return 0
}
// 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.
// transcriptContentWidth is the wrap width for transcript body rows.
func transcriptContentWidth(columnWidth int) int {
cw := columnWidth - 2*transcriptGutter(columnWidth)
cw := columnWidth - transcriptGutter(columnWidth)
if cw < 24 {
return columnWidth
}
@@ -837,7 +823,7 @@ func (m model) sidebarTokenText() string {
if used <= 0 {
return ""
}
if window := modelContextWindow(m.modelName); window > 0 {
if window := m.modelContextWindow(m.modelName); window > 0 {
return fmt.Sprintf("%s / %s tokens", humanCount(used), humanCount(window))
}
return humanCount(used) + " tokens"
+2 -2
View File
@@ -406,10 +406,10 @@ func toolCallSummary(event streamjson.Event) string {
}
case "bash", "exec_command":
if cmd, ok := args["command"].(string); ok {
return truncateRunes(cmd, 40)
return truncateRunes(singleLineToolHeadText(cmd), 40)
}
if cmd, ok := args["cmd"].(string); ok {
return truncateRunes(cmd, 40)
return truncateRunes(singleLineToolHeadText(cmd), 40)
}
case "write_stdin":
sessionID := toolCallIntArg(args, "session_id")
+38 -9
View File
@@ -17,6 +17,7 @@ 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
@@ -32,7 +33,7 @@ type streamingDecoder struct {
func newStreamingDecoder() *streamingDecoder {
return &streamingDecoder{
pathKeys: []string{"path", "file_path", "filename"},
pathKeys: []string{"path", "file", "file_path", "filepath", "filename"},
contentKeys: []string{"content", "new_string", "new_str", "new_text", "new", "replacement", "patch", "input"},
tailCap: streamingTailLines,
}
@@ -42,27 +43,54 @@ func newStreamingDecoder() *streamingDecoder {
func (d *streamingDecoder) feed(fragment string) {
d.rawLen += len(fragment)
if d.closed {
d.scanPathFragment(fragment)
return
}
if d.inContent {
d.consume(fragment)
if remainder := d.consume(fragment); remainder != "" {
d.scanPathFragment(remainder)
}
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.path = streamingFilePath(string(d.head))
d.setPathFrom(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
d.consume(rest)
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))
}
// contentValueStart returns the index in head just past the opening quote of the
@@ -78,9 +106,9 @@ func (d *streamingDecoder) contentValueStart() int {
return best
}
// 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) {
// 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 {
for i := 0; i < len(b); i++ {
c := b[i]
if d.uSkip > 0 {
@@ -108,11 +136,12 @@ func (d *streamingDecoder) consume(b string) {
d.pendingEsc = true
case '"':
d.closed = true // closing quote of the content value
return
return b[i+1:]
default:
d.cur = append(d.cur, c)
}
}
return ""
}
func (d *streamingDecoder) newline() {
+21
View File
@@ -31,6 +31,27 @@ 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).
+7 -5
View File
@@ -262,12 +262,14 @@ func (m *model) resetStreamingFade() {
m.lastStreamActivity = time.Time{}
}
// 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.
// 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.
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)
}
+10
View File
@@ -362,3 +362,13 @@ 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)
}
}
+13 -50
View File
@@ -3,8 +3,6 @@ package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/x/ansi"
)
// streamingTailLines is how many trailing lines of a file's in-progress content
@@ -96,7 +94,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_path", "filename"} {
for _, key := range []string{"path", "file", "file_path", "filepath", "filename"} {
if v, ok := decodeStreamingJSONString(args, key); ok && v != "" {
return v
}
@@ -104,64 +102,29 @@ func streamingFilePath(args string) string {
return ""
}
// 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.
// 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.
func (m model) streamingToolCallView(width int) string {
if m.streamCallID == "" || !isFileWritingTool(m.streamCallName) || m.streamCallDecoder == nil {
return ""
}
d := m.streamCallDecoder
head := zeroTheme.accent.Render("✎ ") + zeroTheme.toolName.Render(m.streamCallName)
if d.path != "" {
head += " " + zeroTheme.toolTarget.Render(d.path)
}
headTag := ""
switch {
case d.hasContent():
head += zeroTheme.faint.Render(fmt.Sprintf(" · %d lines", d.lineTotal()))
if m.streamCallName == "write_file" {
headTag = diffCountTag(d.lineTotal(), 0)
} else {
headTag = 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.
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))
}
headTag = fmt.Sprintf("receiving %.1f KB", float64(d.rawLen)/1024)
}
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)
}
}
+27 -18
View File
@@ -62,13 +62,38 @@ 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, line count, and a content tail.
// Active write_file → shows path and live count, but buffers the code body
// until the final tool result lands.
out := viewModel("write_file", `{"path":"a/b.go","content":"package main\n\nfunc main() {}\n"}`).streamingToolCallView(80)
for _, want := range []string{"write_file", "a/b.go", "lines", "func main()"} {
for _, want := range []string{zeroTheme.diffAdd.Render("+3"), zeroTheme.diffDel.Render("-0")} {
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) {
@@ -82,19 +107,3 @@ 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)
}
}
+88 -1
View File
@@ -1,6 +1,8 @@
package tui
import (
"image/color"
"path/filepath"
"strings"
"sync"
@@ -37,6 +39,25 @@ 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
@@ -66,10 +87,73 @@ 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
}
@@ -87,6 +171,9 @@ func highlightCode(code []string, lang string, measure int) ([]string, bool) {
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 {
+103 -9
View File
@@ -5,15 +5,109 @@ import (
"testing"
)
// 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)
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)
}
}
+4 -4
View File
@@ -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, muted
toolTarget lipgloss.Style // head-row target path, ink
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: "#15201d",
addBg: "#18352c",
delBg: "#241819",
addBgWord: "#26503d", // changed span within an added line — brighter green (sep 1.83 vs addBg, addInk 7.1:1)
addBgWord: "#2e654d", // 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.muted),
toolTarget: fg(p.ink),
toolArg: fg(p.faintest),
cardRun: fg(p.cardRun),
cardErr: fg(p.cardErr),
+21 -17
View File
@@ -120,18 +120,29 @@ func TestRedundantConfirmationCollapsesSuccessBody(t *testing.T) {
}
}
func TestRealOutputKeepsBody(t *testing.T) {
func TestExplorationOutputSummarizesBody(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
rows := []transcriptRow{{
kind: rowToolCall,
id: "c2",
tool: "grep",
detail: "internal/cli",
arg: "flag.NewFlagSet",
}, {
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)
}
}
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)
if strings.Contains(card, "internal/cli/root.go:41") {
t.Fatalf("exploration card must not dump raw grep output, got:\n%s", card)
}
}
@@ -151,9 +162,7 @@ func TestErrorConfirmationKeepsBody(t *testing.T) {
}
}
// --- Fix 5: blank line between consecutive tool cards -------------------------
func TestConsecutiveToolCardsAreBlankSeparated(t *testing.T) {
func TestConsecutiveExplorationResultsGroup(t *testing.T) {
m := limeTestModel()
m.headerPrinted = true
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "go"})
@@ -168,16 +177,11 @@ func TestConsecutiveToolCardsAreBlankSeparated(t *testing.T) {
body, _ := m.transcriptBody(96, "")
got := plainRender(t, body)
// 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, "Explored") != 1 {
t.Fatalf("adjacent exploration results should group into one card, 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)
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)
}
}
+7 -1
View File
@@ -12,7 +12,13 @@ func TestToolDisplayNameCleansMCPNames(t *testing.T) {
"mcp_exa_web_search_exa": "web search",
"mcp_exa_web_fetch_exa": "web fetch",
"mcp_foo_bar": "bar",
"edit_file": "edit_file", // built-in, unchanged
"write_file": "Create",
"edit_file": "Edit",
"read_file": "Read",
"bash": "Run",
"browser_open": "Open browser",
"browser_snapshot": "Browser snapshot",
"capture_artifact": "Capture",
"web_search": "web_search", // built-in, unchanged
}
for in, want := range cases {
+21 -6
View File
@@ -8,9 +8,11 @@ import (
type toolBodyRequest struct {
name string
hint string
arg string
detail string
width int
opts cardRenderOptions
failed bool
}
type toolBodyRenderer interface {
@@ -40,9 +42,13 @@ 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)
registry.register("read_file", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
return readCardBody(req.detail, req.width, req.opts)
})})
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("bash", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
return bashCardBody(req.hint, req.detail, req.width, req.opts)
})})
@@ -52,9 +58,18 @@ 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", diffFirstToolBodyRenderer{next: toolBodyRendererFunc(func(req toolBodyRequest) cardBody {
return grepCardBody(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)
}
// 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.
+16 -6
View File
@@ -12,6 +12,7 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
tests := []struct {
name string
hint string
arg string
detail string
want []string
}{
@@ -24,7 +25,7 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
"-old",
"+new",
}, "\n"),
want: []string{"app.go", "-1", "+1", "new"},
want: []string{"(+1 -1)", "-1", "+1", "new"},
},
{
name: "apply_patch",
@@ -35,23 +36,25 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
"-old",
"+new",
}, "\n"),
want: []string{"app.go", "-1", "+1", "new"},
want: []string{"(+1 -1)", "-1", "+1", "new"},
},
{
name: "read_file",
hint: "README.md",
detail: "File: README.md\n\n 7 | # Zero",
want: []string{"# Zero", "L7"},
want: []string{"Read", "README.md"},
},
{
name: "bash",
hint: "go test ./internal/tui",
detail: "stdout:\nok\nexit_code: 0",
want: []string{"go test ./internal/tui", "ok", "exit 0"},
want: []string{"ok"},
},
{
name: "grep",
arg: "func render",
detail: "internal/tui/rendering.go:41: func render()",
want: []string{"internal/tui/rendering.go:41", "1 matches"},
want: []string{"Search", "func render"},
},
{
name: "unknown_tool",
@@ -65,6 +68,7 @@ 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,
@@ -75,6 +79,12 @@ 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)
}
})
}
}
@@ -107,7 +117,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, "go test ./internal/tui") || !strings.Contains(got, "exit 0") {
if !strings.Contains(got, "ok") || strings.Contains(got, "exit 0") {
t.Fatalf("bash body = %q, want original bash renderer", got)
}
}
+133 -20
View File
@@ -11,6 +11,8 @@ import (
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/tools"
)
type transcriptSelectionPoint struct {
@@ -59,6 +61,7 @@ const (
transcriptBodyItemTitle transcriptBodyItemKind = iota
transcriptBodyItemEmpty
transcriptBodyItemSeparator
transcriptBodyItemRule
transcriptBodyItemRow
transcriptBodyItemPendingPrompt
transcriptBodyItemPendingInterim
@@ -118,10 +121,11 @@ func (l transcriptBodyLayout) visibleLines(window transcriptViewportWindow) []st
return append([]string(nil), l.lines[start:end]...)
}
// 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.
// 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.
func padTranscriptBodyLines(lines []string, gutter int) []string {
if gutter <= 0 {
return lines
@@ -161,9 +165,9 @@ func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcrip
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
items := []transcriptBodyItem{}
// 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.
// 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.
contentWidth := transcriptContentWidth(width)
gutter := transcriptGutter(width)
@@ -173,6 +177,8 @@ 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)))
@@ -182,7 +188,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]
@@ -191,10 +197,54 @@ 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) {
items = append(items, transcriptBlankBodyItem())
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
} else {
items = append(items, transcriptBlankBodyItem())
}
}
if (shownAny || (m.flushedAny && havePreviousKind)) && previousKind == rowUser && row.kind == rowReasoning {
items = append(items, transcriptBlankBodyItem())
@@ -204,7 +254,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 && isToolCardKind(previousKind) && isToolCardKind(row.kind) {
if shownAny && havePreviousKind && needsSeparatorBeforeToolCard(previousKind, row.kind) {
items = append(items, transcriptBlankBodyItem())
}
// A "Thought for X" reasoning header opens the next think→act group, so
@@ -263,7 +313,12 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB
}
if m.pending {
items = append(items, transcriptBlankBodyItem())
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())
}
switch {
case m.pendingPermission != nil:
perm := m.pendingPermission
@@ -336,6 +391,61 @@ 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
@@ -357,7 +467,11 @@ func (m model) transcriptBodyItemsFromRows(rows []transcriptRow, width int) []tr
continue
}
if shownAny && startsTurn(row.kind) {
items = append(items, transcriptBlankBodyItem())
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
} else {
items = append(items, transcriptBlankBodyItem())
}
}
if shownAny && havePreviousKind && previousKind == rowUser && row.kind == rowReasoning {
items = append(items, transcriptBlankBodyItem())
@@ -570,6 +684,13 @@ 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
@@ -706,16 +827,8 @@ 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))
+3 -3
View File
@@ -67,7 +67,7 @@ func (m model) titleBar(width int) string {
}
model := m.titleModelSegment()
ctx := ""
if window := modelContextWindow(m.modelName); window > 0 {
if window := m.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 = modelContextWindow(m.modelName)
window = m.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 strings.TrimSpace(text)
return singleLineToolHeadText(text)
}
}
}
+2 -2
View File
@@ -54,8 +54,8 @@ func TestWidthTierSegments(t *testing.T) {
}
rows := []transcriptRow{
{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"},
{kind: rowToolCall, id: "c", tool: "custom_tool", detail: "internal/cli", arg: "RegisterFlag"},
{kind: rowToolResult, id: "c", tool: "custom_tool", status: tools.StatusOK, detail: "ok"},
}
rc := buildRowContext(rows)
card := plainRender(t, m.renderRow(rows[1], tc.width, rc))
+2 -2
View File
@@ -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 #26503d -> "38;80;61"; delBgWord #502d30 -> "80;45;48"
if !strings.Contains(joined, "38;80;61") || !strings.Contains(joined, "80;45;48") {
// addBgWord #2e654d -> "46;101;77"; delBgWord #502d30 -> "80;45;48"
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
t.Errorf("expected changed spans on the brighter word bg, got:\n%s", joined)
}
}